Hello everyone!
I'd like to revisit the topic of reliably testing concurrent Swift code and gather your opinions. The original discussion is a few years old now, the follow-up on task executors is also no longer new, and quite a lot has landed since. I think it is worth taking stock of what is possible today, what still seems difficult, and whether the available tools cover concurrency testing well enough.
The testing problem
A common pattern in async tests is that we want to check an ordering like this:
- Start some async work.
- Wait until it reaches some point.
- Trigger a relevant dependency.
- Assert what happened before and after.
In practice, tests often approximate step 2 with Task.yield() or Task.sleep(). Those tests may pass locally and then fail under load, on CI, or after unrelated runtime changes.
The Point-Free team gave a great demonstration of this problem in their series of videos "Testing async code" back in 2023.
For context, let's start with a deliberately simple ProfileProvider:
struct Profile: Codable, Equatable {
let name: String
}
struct ProfileProvider {
let httpClient: HTTPClient
func loadProfile() async throws -> Profile {
try await httpClient.get("/profile", as: Profile.self)
}
}
There is nothing difficult to test here. A simple HTTPClient stub can return a profile immediately (or throw), and the test can await loadProfile() and assert the result.
Now let's add a common piece of concurrent behavior:
Given a profile request is already in progress,
When another caller requests the profile before the first request completes,
Then only one HTTP request is performed and both callers receive its result.
The interesting part is the test: the two loadProfile() calls must overlap. Whichever call reaches the provider first needs to remain suspended until both calls have had a chance to run. Otherwise, the first call may finish before the second one starts, so we cannot use a classic stub with data prepared in advance.
It is easy to end up writing something timing-based. Before implementing request sharing, I ran the following test against the original ProfileProvider. It failed in all 10,000 repetitions, as expected, with no false passes:
@Test func `Loads share the same process`() async throws {
let expectedProfile = Profile(name: "test")
let (clientResult, clientResultContinuation) = AsyncStream.makeStream(
of: Profile.self,
bufferingPolicy: .bufferingNewest(0),
)
let getHandlerCount = Mutex(0)
let httpClient = HTTPClientDouble(getHandler: { _ in
if getHandlerCount.withLock({ $0 += 1; return $0 == 1 }) {
try #require(await clientResult.first(where: { _ in true }))
} else {
Profile(name: "unexpected")
}
})
let provider = ProfileProvider(httpClient: httpClient)
async let taskOne = provider.loadProfile()
async let taskTwo = provider.loadProfile()
await Task.yield() // hope both calls have started
clientResultContinuation.yield(expectedProfile)
clientResultContinuation.finish()
let (profileOne, profileTwo) = try await (taskOne, taskTwo)
#expect(getHandlerCount.withLock(\.self) == 1)
#expect(profileOne == expectedProfile)
#expect(profileOne == profileTwo)
}
The clientResult stream should keep the first loadProfile() call suspended, whether that is taskOne or taskTwo. HTTPClientDouble is a more general-purpose test double that delegates the result of get to getHandler. This lets the test suspend the first loadProfile() call and stub any subsequent calls. I use the getHandlerCount mutex because confirmation { _ in } would not provide the same flexibility here.
Here is the implementation intended to satisfy this test:
actor ProfileProvider: Sendable {
enum State {
case pending
case inProgress(Task<Profile, any Error>)
}
private let httpClient: HTTPClient
private var state: State = .pending
init(httpClient: HTTPClient) {
self.httpClient = httpClient
}
func loadProfile() async throws -> Profile {
if case .inProgress(let task) = state {
return try await task.value
}
let task = Task { try await httpClient.get("/profile", as: Profile.self) }
state = .inProgress(task)
let result = await task.result
state = .pending
return try result.get()
}
}
In a few local experiments, running the test 10,000 times against the new implementation produced a success rate of about 95%. The Task.yield() above is unreliable: the test usually passes, but it is flaky.
There are two failure scenarios:
-
Neither task has started by the time
clientResultis finished because oneTask.yield()was not enough. This accounts for most of the failures. TheclientResultstream makes this clearly visible because it is configured as a pass-through sequence (.bufferingNewest(0)): if no consumer is waiting, the element is dropped. Otherwise the element can be buffered and the test may pass without exercising the intended behavior. -
The first task completes just as the second one starts. This is much rarer because whichever task runs first suspends while awaiting
clientResult.
The test needs to represent a clear relationship between the two tasks, but it cannot do so reliably. Solving the first scenario is not difficult with additional manual coordination, such as an expectation. The second scenario is the bigger challenge because there is no side effect that tells us when the second task has started.
What we have now
In this section, I'll point to tools that improve the reliability of the test above.
ConcurrencyExtras helpers
-
Replacing
Task.yield()withTask.megaYield()helps in practice, but repeatedly yielding still cannot prove that the second task reached the relevant state. It also adds measurable overhead. -
Wrapping the test in
withMainSerialExecutor {}and using a singleTask.yield()worked better in this experiment and was faster:
| Coordination | Time for 10,000 repetitions |
|---|---|
withMainSerialExecutor + Task.yield() |
~2 seconds |
Task.megaYield() |
~6 seconds |
These numbers are from a local experiment rather than a general benchmark, but they show that repeatedly yielding is not free.
withMainSerialExecutor has a more important cost, though. It temporarily overrides the global hook, swift_task_enqueueGlobal_hook. Swift Testing runs tests in parallel by default, and the hook is not scoped to the test. Jobs enqueued by unrelated tests during that window are also redirected to the main executor. I put together a small reproduction of this cross-test interference with more details.
TaskExecutor and SerialExecutor
- SE-0392: Custom Actor Executors (Swift 5.9) —
SerialExecutor - SE-0417: Task Executor Preference (Swift 6.0) —
withTaskExecutorPreferenceandTaskExecutor
TaskExecutor and SerialExecutor together make an interesting combination: you can route the work of the system under test through your own queue and observe it directly. For this test, much like with withMainSerialExecutor, we can apply withTaskExecutorPreference using NaiveQueueExecutor and a serial DispatchQueue, as follows:
@Test func `Loads share the same process`() async throws {
let queue = DispatchQueue(label: #function)
try await withTaskExecutorPreference(NaiveQueueExecutor(queue)) {
// test content
}
}
In my local experiment, this version completed 10,000 repetitions successfully in around one second. Unlike withMainSerialExecutor, the executor preference is test-scoped and no cross-test interference.
For this example, task executor preference with a serial executor seems like the best option. It provides test-scoped execution control, is faster than the other approaches, and makes the required ordering reliable.
What I am less sure about is how far this approach generalises. The clearest next boundary for me is code that combines execution ordering with time, such as retries or debouncing. Those tests need to control virtual time as well as the order in which work runs. Two limitations seem relevant here:
- SE-0505: Delayed Enqueuing for Executors (
SchedulingExecutor) was returned for revision. Without it, a custom executor cannot own delayed work, which is exactly what a test scheduler needs to drive virtual time. Task.yield()andTask.sleep()still do not respect task executor preference.
I also run into related coordination problems when testing AsyncSequence or systems with many dependencies. Swift Async Algorithms' AsyncSequenceValidation is interesting prior art, and I have experimented with AsyncStream of Sendable closures to describe more complex event sequences in tests. Those cases deserve concrete examples of their own, though, rather than being treated as the same problem in this opening post.
When working with Swift concurrency, I keep encountering the same trade-off: a test either relies on timing (Task.sleep(), Task.megaYield()), serialises jobs to control execution ordering, or explicitly coordinates each event and accumulates a lot of manual machinery.
In practice, I can only use or combine the latter two options because test speed and reliability are essential.
What I would like to hear
- Do you encounter tests like this? If so, do you see the same trade-offs, and what approaches do you use?
- Is a test-scoped serial
TaskExecutorthe approach you would use for this kind of test? - Have I missed any relevant capabilities, prior art, or ongoing work?
Mostly I want to understand how far the tools available today can take us before discussing or proposing a particular API.
Thanks for reading.