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

3 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.

3 Likes

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.

Here’s more info if you’re still interested.

Settings: Default actor isolation: MainActor

Code for reproducing crash:

import XCTest
class Resource {
    var name = ""
}
final class crash: XCTestCase {
    func testExample() throws {
        let x = Resource()
    }
}

Crash happens at line

libswift_Concurrency.dylib`swift_task_deinitOnExecutorImpl:

    0x2575f4120 <+100>: bl     0x2575ffca8               ; swift::TaskLocal::StopLookupScope::~StopLookupScope()

Console output:

test_crash(53176,0x100af9e40) malloc: *** error for object 0x26254e740: pointer being freed was not allocated
CoreSimulator 1051.17.7 - Device: Clone 1 of iPad (A16) (9143E4B5-D36B-4C71-9B34-E06B1D25520E) - Runtime: iOS 26.2 (23C54) - DeviceType: iPad (A16)

backtrace:

* thread #1, queue = 'com.apple.main-thread', stop reason = signal SIGABRT
    frame #0: 0x000000010071c85c libsystem_kernel.dylib`__pthread_kill + 8
    frame #1: 0x00000001009fa2a8 libsystem_pthread.dylib`pthread_kill + 264
    frame #2: 0x00000001801b5994 libsystem_c.dylib`abort + 100
    frame #3: 0x000000018023c1ac libsystem_malloc.dylib`malloc_vreport + 896
    frame #4: 0x000000018023c36c libsystem_malloc.dylib`malloc_report + 60
    frame #5: 0x0000000180208dc4 libsystem_malloc.dylib`___BUG_IN_CLIENT_OF_LIBMALLOC_POINTER_BEING_FREED_WAS_NOT_ALLOCATED + 72
    frame #6: 0x00000002575ffd18 libswift_Concurrency.dylib`swift::TaskLocal::StopLookupScope::~StopLookupScope() + 112
    frame #7: 0x00000002575f4124 libswift_Concurrency.dylib`swift_task_deinitOnExecutorImpl(void*, void (void*) swiftcall*, swift::SerialExecutorRef, unsigned long) + 104
    frame #8: 0x00000001018e56a0 test_crashTests`Resource.__deallocating_deinit() at crash.swift:0
    frame #9: 0x0000000197646a84 libswiftCore.dylib`_swift_release_dealloc + 28
    frame #10: 0x0000000197647548 libswiftCore.dylib`bool swift::RefCounts<swift::RefCountBitsT<(swift::RefCountInlinedness)1>>::doDecrementSlow<(swift::PerformDeinit)1>(swift::RefCountBitsT<(swift::RefCountInlinedness)1>, unsigned int) + 148
  * frame #11: 0x00000001018e5790 test_crashTests`crash.testExample() at crash.swift:15:5
    frame #13: 0x00000001804fdba0 CoreFoundation`__invoking___ + 144
    frame #14: 0x00000001804fad44 CoreFoundation`-[NSInvocation invoke] + 276
    frame #15: 0x00000001016af6d0 XCTestCore`+[XCTFailableInvocation invokeErrorConventionInvocation:completion:] + 92
    frame #16: 0x00000001016af66c XCTestCore`__90+[XCTFailableInvocation invokeInvocation:withTestMethodConvention:lastObservedErrorIssue:]_block_invoke + 24
    frame #17: 0x00000001016af114 XCTestCore`__81+[XCTFailableInvocation invokeWithAsynchronousWait:lastObservedErrorIssue:block:]_block_invoke + 304
    frame #18: 0x00000001016a0a94 XCTestCore`__49+[XCTSwiftErrorObservation observeErrorsInBlock:]_block_invoke + 28
    frame #19: 0x00000001016c6554 XCTestCore`specialized static XCTSwiftErrorObservation._observeErrors(in:) + 304
    frame #20: 0x00000001016c65f4 XCTestCore`@objc static XCTSwiftErrorObservation._observeErrors(in:) + 44
    frame #21: 0x00000001016a09a8 XCTestCore`+[XCTSwiftErrorObservation observeErrorsInBlock:] + 164
    frame #22: 0x00000001016aeef4 XCTestCore`+[XCTFailableInvocation invokeWithAsynchronousWait:lastObservedErrorIssue:block:] + 184
    frame #23: 0x00000001016af4e4 XCTestCore`+[XCTFailableInvocation invokeInvocation:withTestMethodConvention:lastObservedErrorIssue:] + 156
    frame #24: 0x00000001016af97c XCTestCore`+[XCTFailableInvocation invokeInvocation:lastObservedErrorIssue:] + 68
    frame #25: 0x0000000101650cc0 XCTestCore`-[XCTestCase invokeTestMethod:] + 60
    frame #26: 0x0000000101651310 XCTestCore`__24-[XCTestCase invokeTest]_block_invoke.88 + 60
    frame #27: 0x0000000101656890 XCTestCore`-[XCTestCase(XCTIssueHandling) _caughtUnhandledDeveloperExceptionPermittingControlFlowInterruptions:caughtInterruptionException:whileExecutingBlock:] + 160
    frame #28: 0x0000000101650f08 XCTestCore`-[XCTestCase invokeTest] + 524
    frame #29: 0x0000000101652910 XCTestCore`__26-[XCTestCase performTest:]_block_invoke.125 + 32
    frame #30: 0x0000000101656890 XCTestCore`-[XCTestCase(XCTIssueHandling) _caughtUnhandledDeveloperExceptionPermittingControlFlowInterruptions:caughtInterruptionException:whileExecutingBlock:] + 160
    frame #31: 0x0000000101652310 XCTestCore`__26-[XCTestCase performTest:]_block_invoke.115 + 492
    frame #32: 0x000000010169da00 XCTestCore`+[XCTContext _runInChildOfContext:forTestCase:markAsReportingBase:block:] + 172
    frame #33: 0x000000010169d900 XCTestCore`+[XCTContext runInContextForTestCase:markAsReportingBase:block:] + 100
    frame #34: 0x0000000101651de8 XCTestCore`-[XCTestCase performTest:] + 248
    frame #35: 0x000000010164df44 XCTestCore`-[XCTest runTest] + 44
    frame #36: 0x000000010165cb90 XCTestCore`-[XCTestSuite runTestBasedOnRepetitionPolicy:testRun:] + 64
    frame #37: 0x000000010165c95c XCTestCore`__27-[XCTestSuite performTest:]_block_invoke + 176
    frame #38: 0x000000010165bee8 XCTestCore`__59-[XCTestSuite _performProtectedSectionForTest:testSection:]_block_invoke + 40
    frame #39: 0x000000010169da00 XCTestCore`+[XCTContext _runInChildOfContext:forTestCase:markAsReportingBase:block:] + 172
    frame #40: 0x000000010169d900 XCTestCore`+[XCTContext runInContextForTestCase:markAsReportingBase:block:] + 100
    frame #41: 0x000000010165be64 XCTestCore`-[XCTestSuite _performProtectedSectionForTest:testSection:] + 152
    frame #42: 0x000000010165c40c XCTestCore`-[XCTestSuite performTest:] + 216
    frame #43: 0x000000010164df44 XCTestCore`-[XCTest runTest] + 44
    frame #44: 0x000000010167bf40 XCTestCore`__89-[XCTTestRunSession executeTestsWithIdentifiers:skippingTestsWithIdentifiers:completion:]_block_invoke + 524
    frame #45: 0x000000010169da00 XCTestCore`+[XCTContext _runInChildOfContext:forTestCase:markAsReportingBase:block:] + 172
    frame #46: 0x000000010169d900 XCTestCore`+[XCTContext runInContextForTestCase:markAsReportingBase:block:] + 100
    frame #47: 0x000000010167bc4c XCTestCore`-[XCTTestRunSession executeTestsWithIdentifiers:skippingTestsWithIdentifiers:completion:] + 300
    frame #48: 0x000000010166f8e0 XCTestCore`__103-[XCTExecutionWorker executeTestIdentifiers:skippingTestIdentifiers:completionHandler:completionQueue:]_block_invoke_2 + 120
    frame #49: 0x000000010166fc20 XCTestCore`__XCTAsyncEnumerateWithWaiter_block_invoke + 224
    frame #50: 0x000000010166fdb8 XCTestCore`__XCTAsyncEnumerate_block_invoke.98 + 52
    frame #51: 0x000000010166e7d8 XCTestCore`XCTAsyncEnumerateWithWaiter + 492
    frame #52: 0x000000010166f7bc XCTestCore`__103-[XCTExecutionWorker executeTestIdentifiers:skippingTestIdentifiers:completionHandler:completionQueue:]_block_invoke + 116
    frame #53: 0x000000010166ea8c XCTestCore`-[XCTExecutionWorker runWithError:] + 76
    frame #54: 0x000000010166cab0 XCTestCore`__25-[XCTestDriver _runTests]_block_invoke.248 + 52
    frame #55: 0x00000001016811c4 XCTestCore`-[XCTestObservationCenter _observeTestExecutionForTestBundle:inBlock:] + 100
    frame #56: 0x000000010166c49c XCTestCore`-[XCTestDriver _runTests] + 1092
    frame #57: 0x000000010166e260 XCTestCore`_XCTestMain + 116
    frame #58: 0x00000001006ed48c libXCTestBundleInject.dylib`RunTestsFromRunLoop + 108
    frame #59: 0x000000018041a4ac CoreFoundation`__CFMachPortPerform + 164
    frame #60: 0x0000000180456aa8 CoreFoundation`__CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE1_PERFORM_FUNCTION__ + 56
    frame #61: 0x00000001804560c0 CoreFoundation`__CFRunLoopDoSource1 + 480
    frame #62: 0x0000000180455188 CoreFoundation`__CFRunLoopRun + 2100
    frame #63: 0x000000018044fcec CoreFoundation`_CFRunLoopRunSpecificWithOptions + 496
    frame #64: 0x0000000192a669bc GraphicsServices`GSEventRunModal + 116
    frame #65: 0x0000000186348574 UIKitCore`-[UIApplication _run] + 772
    frame #66: 0x000000018634c79c UIKitCore`UIApplicationMain + 124
    frame #67: 0x00000001da58d620 SwiftUI`closure #1 in KitRendererCommon(_:) + 164
    frame #68: 0x00000001da58d368 SwiftUI`runApp(_:) + 180
    frame #69: 0x00000001da31b42c SwiftUI`static App.main() + 148
    frame #71: 0x0000000100997340 test_crash.debug.dylib`main at test_crashApp.swift:0
    frame #72: 0x00000001007853d0 dyld_sim`start_sim + 20
    frame #73: 0x0000000100a54d54 dyld`start + 7184

That looks like it may be a bug in the Swift runtime. I'd advise you to file a GitHub issue against the main Swift repository.

Just did that for anyone looking at this in the future

1 Like

Hey, I suppose that adding the default isolation also changes the init methods isolation, so you should do something like this:

final class MyTestCase: XCTestCase {
  override nonisolated init() {
    super.init()
  }
  
  override nonisolated init(selector: Selector) {
    super.init(selector: selector)
  }
  
  @available(*, unavailable)
  override nonisolated init(invocation: NSInvocation?) {
    super.init(invocation: invocation)
  }
  
  override func setUp() async throws {

  }
}

Basically you’re overriding the problematic inits and “restoring“ the `nonisolated` signature.