Using DispatchQueue.async or Task.detached in Swift Testing

I'm converting my XCTest to Swift Testing. The tested code dispatches some work to the main queue and from there calls delegate methods that I implement in the test to check the results.

import Foundation

class problemTests {

    private var continuation: CheckedContinuation<Void, Never>!
    
    @Test func example() async throws {
        DispatchQueue.main.async { [self] in
            #expect(Bool(false))
            continuation.resume()
        }
        await withCheckedContinuation { continuation in
            self.continuation = continuation
        }
    }

}

Running this sample test crashes with the error

Fatal error: Issue recorded event did not contain a test

I'm assuming the problem is with DispatchQueue.main.async. Replacing it with Task allows the test to complete (by failing), while replacing it with Task.detached crashes as well.

Is there a workaround?

There is a known issue in Xcode 16 that causes a crash like that, however your code is not concurrency-safe and is likely going to fail spuriously anyway. Reading from and writing to your continuation property is subject to data races.

Try this approach instead:

@Test func example() async {
  await MainActor.run {
    #expect(Bool(false))
  }
}

Or, even simpler (if the entire body of the test should run on the main thread/queue/actor:

@MainActor @Test func example() {
  #expect(Bool(false))
}

Or, if you really need to use a dispatch queue here instead of the main actor:

@Test func example() async {
  await withCheckedContinuation { continuation in
    DispatchQueue.main.async { [self] in
      #expect(Bool(false))
      continuation.resume()
    }
  }
}

All three options avoid the need for a private var continuation and avoid data races.

3 Likes

The call to DispatchQueue.main.async is done by a tested class, so it cannot be inlined in the test. The code inside it is is what I can change in the test since it's called in response to a delegate method called by the tested object and implemented by the test class.

Unfortunately, your third example still causes the same crash as my code.

Ah, yeah, the crash on the third example is a known issue in Xcode 16. My fault for not realizing that my example would trigger it.

The problem you're running into with Xcode is that if you say DispatchQueue.main.async {}, you hop out of Swift's task model and into the unknown lands of "somebody else's thread pool". That means there's no task-local information such as the current test, and Xcode has an erroneous NSAssert() that the current test is non-nil when an issue is recorded. That bug is being tracked at Apple.

1 Like

Thanks for confirming. I also filed feedback to Apple about this.

I just realized now that even with that bug fixed, Xcode has no way of knowing which test the recorded issue belongs to, or am I misunderstanding? Is it not possible to test DispatchQueue.main.async and Task.detached?

A failure in a test when running in a detached or unmanaged task should be reported as a failure in any tests currently running (since the specific test that's at fault is impossible to determine.)

1 Like

And that behavior (attributing an issue in a detached context to all currently running tests) is still only a "best-effort" attempt to narrow down potential culprits; it is possible the issue originated from some earlier test which already finished running, but initiated a detached Task (or dispatched work via a DispatchQueue, as in your example) and that async work item caused the issue to be recorded much later.

It may be better, when we improve the handling of this situation, to make clear in the results that any test which has started running prior to the issue could be responsible, regardless of whether it’s still running.

Good idea.

So is in this case the best solution to have all issues recorded to the correct test using @Suite(.serialized)?

I think the best solution is for your code-under-test to either be async or expose a completion-handler style API which you can transform into an async API via withCheckedContinuation in the @Test function. Then the @Test function can await that condition, and perform whatever #expect(...)s you need from the non-detached context so recorded issues will be properly attributed to that test.

1 Like

Unfortunately I cannot transition to async just yet, since I'm still supporting macOS 10.15.
Thanks for the suggestion. So instead of doing #expect(...) inside the completion handler (or delegate method in my case), I would store the object passed to the delegate method, then a) resume the continuation and b) check the object, instead of b) checking the object and a) resuming the continuation.

1 Like

macOS 10.15 has support for async/await! :slight_smile:

Sorry, I meant macOS 10.13.

Aw. :frowning_face:

I assume you're planning to add mutable state to your test suite type, à la private var continuation? Proceed with caution: such data is unsafe to access concurrently. Consider making your suite an actor instead of a class so that access to its state is isolated, or if that's not possible, use a lock like OSAllocatedUnfairLock or Mutex to guard access to the mutable state.

That was my plan. Thanks for the warning, but because of compatibility I cannot use async, and hence actor, just yet. Since I only have one continuation that can be triggered from DispatchQueue.main (potentially multiple times), there's no risk of data races.