XCTestCase compiler error with Swift 6.2 default actor isolation MainActor

Migrating test target to Swift 6.2 while setting default-actor-isolation to MainActor and into the compiler error in XCTestCase subclass.

import XCTest

final class PlaygroundTests: XCTestCase { // compiler angry here

    override func setUp() async throws {
        try await super.setUp()
    }    

    override func tearDown() async throws {
        try await super.tearDown()
    }
}

:cross_mark: Main actor-isolated initializer 'init()' has different actor isolation from nonisolated overridden declaration

I managed to make it compile by setting default-actor-isolation to nonisolated in test target and then isolating PlaygroundTests subclass to @MainActor.

However, I am curious if is this a proper workaround? Anyone else ran into this?

This is an unfortunate legacy problem of XCTest and/or the test template code Xcode provides.
Since XCTestCase still comes from Objective-C, basically, it has not been designed for structured concurrency, so by default it and its methods are not annotated with isolation specifiers. This means its initializers and other methods are all nonisolated (since it was not compiled with default-actor-isolation set to MainActor).

Now since your test target is set to this default actor isolation, you basically have the equivalent of an explicit isolation: @MainActor final class PlaygroundTests: XCTestCase. The initializers for subclasses are basically "implicitly overridden" in this context: The @MainActor isolation obviously extends to the various init methods your test class inherits. However, the superclass's initializers are not isolated, and since the subclass initializers need to "bubble up" during initialization (i.e. call super.init(...) somewhere), you get an isolation problem.

The solution is to either make the test class as a whole or all affected (overridden) methods explicitly nonisolated. I just checked, and the second option is pretty much prevented by NSInvocation being unavailable in Swift, so it seems you'd fare best by going for nonisolated final class PlaygroundTests: XCTestCase.

If you want individual tests to be isolated to @MainActor you'll have to annotate them explicitly, which may also affect your class's properties. :frowning:

It would be nice of the Xcode template eventually updates that, perhaps...

1 Like

Thanks @Gero for the detailed explanation. Your solution of making XCTestCase subclass nonisolated does work in example code above.

However, making test classes nonisolated is not an option to me because almost all types / objects are isolated to MainActor. I tried isolating test class itself to @MainActor and it compiles but then I get __deallocating_deinit crashes in a bunch of places.

Looks like the test starts and gets executed on global executor even if my test class is @MainActor. Since my app target defaults to @MainActor, any object I instantiate in a unit test will eventually be released off-main when XCTest destroys the test case, causing __deallocating_deinit crashes.

I see the conundrum, but this is, overall speaking, just a general problem when working with specifically isolated types from an arbitrary isolation domain.
If your tested class is isolated to @MainActor that basically just means your tests (i.e. the test... functions in your XCTestCase subclass) should probably also be (individually) isolated to the @MainActor.
"Probably", because it actually depends on what your tested type is doing (i.e. its concrete implementation), there might still be functions you can await from another isolation domain.

This only leaves the setup and cleanup mechanic for your XCTestCase subclass. XCTextCase offers an asynchronous setUp variant that you can override. I think you can isolate this to @MainActor (since it is async it's okay to change the isolation from its super-implementation I think), so instantiate your tested types in there. Keep in mind that any class isolated to @MainActor entirely is implicitly Sendable, so you can store it in a property of your test class, even if that class is not isolated to the main actor.

The crash you see probably comes from the tested type being deinitialized off the main actor (and not having an isolated deinit), but this should be possible to resolve in the test class's overridden tearDown method (which you can also isolate to the main actor). I usually work with implicitly unwrapped optionals to store the tested type during the test execution and then set them to nil in the tearDown method if I have to enforce some cleanup code in the tested type's deinit. Though overall I try to have such cleanup code needed in the first place.

Right, so it looks like if we are running our tests from nonisolated context which uses actor isolated types, we must release those types in teardown method in the same isolation context.

MVP for people looking at this later:

// Main App target with default actor isolation = MainActor

class AnotherClass {
    // Empty class for simplicity
}

class MainActorClass {
    let anotherInstance = AnotherClass()
}
// Test target with default actor isolation = nonisolated

@MainActor
class ActorIsolationMVPTests: XCTestCase {

    func testActorIsolationCrash() {
        // Create an instance of MainActorClass, which holds a MainActor instance
        let mainActor = MainActorClass()
        // Access the instance to ensure it's created
        XCTAssertNotNil(mainActor.anotherInstance)
        // Let it go out of scope, triggering deallocation
        // This crashes
    }
}

Fix as suggested by @Gero

@MainActor

class ActorIsolationMVPTests: XCTestCase {
    
    var mainActor: MainActorClass!

    override func setUp() async throws {
        try await super.setUp()
        mainActor = MainActorClass()
    }

    override func tearDown() async throws {
        await MainActor.run {
            mainActor = nil
        }
        try await super.tearDown()
    }

    func testActorIsolationCrash() {
        let mainActor = MainActorClass()
        XCTAssertNotNil(mainActor.anotherInstance)
        // Does not crash
    }
}

What this means (if I am not wrong) is I have to make sure to track and release main actor isolated properties in teardown. Since I am working on project with 1000s of existing tests, looks like I am in for a great refactor :slight_smile: .

If you’re doing a big refactor you might as well just migrate them to Swift Testing where all there problems go away and life is much nicer

2 Likes

If you use MainActor isolation with Swift Testing, you lose all the parallelism Testing allows, which can be a huge performance hit. Until Testing allows process-level parallelism, there may be projects that can't reasonably move.

2 Likes

Okay, I have had some time to explicitly make a test project and play around with this, and it seems the original problem is actually a bug. I'm pretty sure I also read something about that on the forums, but cannot find it with a quick search for now. However, there's an issue on GitHub that illustrates it, too (plus potentially some more issues, feel free to look more).

The core of the underlying issue:
Having a class compiled with default actor isolation set to MainActor is NOT the same as having it compiled with default actor isolation set to nonisolated and annotating the class explicitly with @MainActor. This apparently occurs only in Swift language more 6, not 5.

So to sum up:
Language mode: Swift 6 & default actor isolation = MainActor: :cross_mark_button: Main actor-isolated initializer 'init()...
but
Language mode: Swift 6 & default actor isolation = nonisolated & class annotated with MainActor: :white_check_mark: compiles

Unfortunately manually annotating a class with @MainActor does not "overwrite" the default isolation to MainActor, meaning if the target is configured with this default isolation AND you annotate manually with @MainActor, you still see the error message.

If I understand it correctly, Swift should be able to somehow handle to have a subclass's synthesized initializer with a different isolation than the superclass's initializer. This makes sense to me as long as the superclass doesn't have a specific isolation defined, as making it "stricter" should always be possible.
In the case of XCTestCase and its subclasses (i.e. any test case you implement) that's perfectly reasonable and has worked in the past. I have often manually annotated my test case classes with @MainActor entirely if they were testing main actor bound types (e.g. view controllers).

Furthermore, I assume this issue also applies to overrides of other methods, like setUpWithError and so on.


So my advice for now would be to not have default actor isolation set to MainActor for test targets[1] and instead annotate the entire test class with @MainActor manually, if needed. This is probably a lot less refactoring than using "default actor isolation = MainActor" and then opting out of it by marking the test classes as nonisolated overall and then individually mark test functions as @MainActor.

If you go the other way around (like I originally suggested), you have to make sure that inside your test case subclasses, isolation is handled properly. Meaning: If any test state is instantiated off the main actor (in a set up method), but your test functions are bound to the main actor, you may encounter sendability problems.
By the way, my hunch is that while XCTestCase is not annotated as such, the test system probably does use it on the main actor/main thread for the most part... this would mean the sendability issues are all kind of a mirage, but obviously I don't know that. :smiley:


  1. The Xcode 26 templates do it this way, too ↩︎

XCTest directly invokes synchronous test methods on the main thread, yes. Asynchronous test methods (and this includes any/all actor-isolated methods) are invoked in an unspecified isolation domain.

Under the covers, XCTest is implemented almost entirely in Objective-C. At test process start, it enumerates the instance methods of all subclasses of the XCTest class (typically of XCTestCase but there's some nuance here.)

Any method whose selector starts with "test" and which takes zero arguments (or takes exactly one argument of type NSError *__autoreleasing *) is assumed to be a synchronous test method.

Any method whose selector starts with "test", ends with "WithCompletionHandler:" or similar, and takes a block with zero arguments (or with exactly one argument of type NSError *) is assumed to be an asynchronous test method.

Swift automagically assigns a selector of the form methodNameWithCompletionHandler: to Swift async functions when they are bridged to Objective-C, which means that if you write func testFoo() async then the compiler emits a thunk Objective-C method of this form:

@objc(testFooWithCompletionHandler:)
func __testFoo(_ completionHandler: @escaping @Sendable () -> Void) {
  Task {
    await self.foo()
    completionHandler()
  }
}

That's the method XCTest calls, and for methods explicitly marked async it works quite well. However, if a test class or test method is marked @MainActor, the compiler does not emit this thunk and XCTest instead only sees the selector @selector(testFoo) which is the selector of a synchronous test method. XCTest invokes this method synchronously on the main thread (well, main run loop, same deal) and it runs as intended.

If you're seeing crashes in XCTest when deallocating test case instances and they are being deallocated off the main actor, I'd like to see more info such as a crash log or backtrace. That would help me understand exactly what's causing the crash since the simple case I described above "just works".

Note: Everything I've written here applies to Apple's XCTest.framework that ships with Xcode. The open-source swift-corelibs-xctest library that ships with the Swift toolchain for non-Apple platforms is a completely separate implementation that does not rely on Objective-C.

1 Like

Thanks @grynspan , not sure if this will be helpful but all I see is following in debug log:

Test Suite 'ActorIsolationMVPTests' started at 2025-12-05 08:39:26.942.
Test Case '-[TestProject.ActorIsolationMVPTests testActorIsolationCrash]' started.
Playground(93589,0x104c0de40) malloc: *** error for object 0x2635c9ac0: pointer being freed was not allocated
Playground(93589,0x104c0de40) malloc: *** set a breakpoint in malloc_error_break to debug

You can reproduce this crash with this simple example.