← Back to Blog

Stop Sleeping: Deterministic Tests for Concurrent Swift Code


You know the situation. You are writing—or, these days, more likely asking an agent to generate—concurrent code for a non-trivial feature that you want covered by tests. Because the feature is important, you need deterministic tests for each relevant execution order, including errors and cancellation at different steps. Ideally, each test should have a clear structure that makes it easy to understand and follow from top to bottom.

If you prefer reading code to articles, see the complete example on GitHub Gist.

In my experience, even the latest models, such as Astra 6 and Fable 5, generate tests that rely on Task.sleep(for:) or polling when they are not given good examples or instructions.

A test that relies on Task.sleep(for:) to wait for other work is not deterministic: when many tests run concurrently, the scheduler may not complete the expected work within the guessed interval. Because sleeping does not control execution order, reliably testing every relevant ordering of asynchronous operations becomes difficult and often impossible with sleeps alone. Every sleep also makes the suite slower. Even a short wait with Task.sleep(for: .milliseconds(100)) adds up: a thousand such waits total 100 seconds. One hour wasted in only 36 runs of such tests.

How can it be avoided? I’ll use a simple loader with a timeout, TimedValueLoader, to show how to test even more complex concurrent code deterministically, including error propagation and cancellation at an exact step.

The technique uses test spies to observe progress and control what happens next.

A test spy is a test double that replaces a dependency, provides controlled responses, and records how it is called.

I’ll start with the implementation of TimedValueLoader, a component that awaits a value or throws a timeout error. Then I’ll show how to test its successful result, timeout, error handling, and cancellation. Finally, I’ll explain how the test spies report calls and let the test control what happens next.

Test examples

Implementation of the TimedValueLoader example

TimedValueLoader is the system under test (SUT). Its load(within:) method starts two child tasks: one waits for a value, and the other throws a timeout error when the timeout expires.

// Provides an asynchronous value so tests can control its result and delivery.
private protocol AsyncValueProviding: Sendable {
    func value() async throws -> String
}

// Replaces direct Task.sleep calls so tests can control when the wait ends.
private protocol Sleeping: Sendable {
    func callAsFunction(for duration: Duration) async throws
}

// Races the value against a timeout and cancels the remaining work.
private struct TimedValueLoader: Sendable {
    enum Error: Swift.Error, Equatable {
        case timedOut
    }

    let provider: any AsyncValueProviding
    let sleep: any Sleeping

    func load(within timeout: Duration) async throws -> String {
        try await withThrowingTaskGroup(of: String.self) { group in
            group.addTask {
                try await provider.value()
            }
            group.addTask {
                try await sleep(for: timeout)
                throw Error.timedOut
            }
            defer {
                // Cancel whichever child loses the race.
                group.cancelAll()
            }

            guard let firstResult = try await group.next() else {
                throw CancellationError()
            }
            return firstResult
        }
    }
}

If the value arrives first, the loader returns it and cancels the timeout task. If the timeout occurs first, the loader throws a timeout error and cancels the task waiting for the value. The tests must verify both the result and cancellation of the remaining task in each case.

How to test a successful result

To test TimedValueLoader, we pass test spies to it instead of real dependencies. Each test spy reports when its method is called and which arguments it receives. The test waits for events from both test spies to confirm that the value and timeout tasks have started. It then tells the provider test spy which value to return and checks that the loader returns the same value:

@Test("A value returned before the deadline wins", .timeLimit(.minutes(1)))
func valueBeforeTimeout() async throws {
    // Given: a provider and a timeout that both wait for input from the test.
    let provider = AsyncValueProviderSpy()
    let sleep = SleepSpy()
    let sut = TimedValueLoader(provider: provider, sleep: sleep)

    // When: loading starts and both child tasks report their calls.
    async let loadedValue = sut.load(within: .seconds(3))

    // These awaits confirm that both the value provider and sleep were called.
    let valueCall = try #require(await provider.nextCall())
    let sleepCall = try #require(await sleep.nextCall())

    #expect(valueCall == .value)
    #expect(sleepCall == .seconds(3))

    // Provide a value while the timeout continues to wait.
    provider.respond(with: .success("loaded value"))

    // Then: the loader returns the provided value.
    let value = try await loadedValue
    #expect(value == "loaded value")
}

There is no timing guess. Receiving both call events confirms that the provider and sleep operations have started. The test then decides which one can proceed by calling provider.respond(with:) or sleep.resume().

If neither is called and no error or cancellation ends the operation, the test waits indefinitely. Add .timeLimit(.minutes(1)) to make such a test fail and request cancellation instead. You can apply the limit to individual tests, or preferably to the whole suite with @Suite(.timeLimit(.minutes(1))), so every test in the suite has the same protection.

If only the result matters, the test can omit the nextCall() awaits. Here, they confirm that both dependencies were called, and the following assertions check the call details and requested timeout.

How to test a timeout

SleepSpy waits for the test to call resume() instead of waiting for time to pass. Calling sleep.resume() lets the sleep call return, so the loader throws its timeout error while the provider is still waiting:

@Test("The timeout finishes before a value arrives", .timeLimit(.minutes(1)))
func timeoutBeforeValue() async throws {
    // Given: a provider and a timeout controlled by the test.
    let provider = AsyncValueProviderSpy()
    let sleep = SleepSpy()
    let sut = TimedValueLoader(provider: provider, sleep: sleep)

    // When: both child tasks start.
    async let loadedValue = sut.load(within: .seconds(3))

    let valueCall = try #require(await provider.nextCall())
    let sleepCall = try #require(await sleep.nextCall())

    #expect(valueCall == .value)
    #expect(sleepCall == .seconds(3))

    // Trigger the timeout while the provider continues to wait.
    sleep.resume()

    // Then: the loader throws the expected error.
    do {
        _ = try await loadedValue
        Issue.record("Expected the timeout to win")
    } catch {
        #expect(error as? TimedValueLoader.Error == .timedOut)
    }
}

The test reads from top to bottom: start the operation, wait for both call events, trigger the timeout, then assert the result. The duration is asserted directly through sleepCall == .seconds(3), but the test does not wait three seconds to do it.

How to test an asynchronous error

respond(with:) accepts either a successful value or an error. Once both call events arrive, provide the exact error for the pending request:

@Test("The loader propagates the provider error", .timeLimit(.minutes(1)))
func providerError() async throws {
    enum ExampleError: Error {
        case valueFailed
    }

    // Given: a provider and a timeout controlled by the test.
    let provider = AsyncValueProviderSpy()
    let sleep = SleepSpy()
    let sut = TimedValueLoader(provider: provider, sleep: sleep)

    // When: both child tasks start.
    async let loadedValue = sut.load(within: .seconds(3))

    let valueCall = try #require(await provider.nextCall())
    let sleepCall = try #require(await sleep.nextCall())

    #expect(valueCall == .value)
    #expect(sleepCall == .seconds(3))

    // Respond to the pending request with the expected error.
    provider.respond(with: .failure(ExampleError.valueFailed))

    // Then: the loader throws the expected error.
    do {
        _ = try await loadedValue
        Issue.record("Expected the provider error")
    } catch {
        #expect(error as? ExampleError == .valueFailed)
    }
}

The test proves that the provider error is propagated unchanged. No sleep, polling loop, or scheduler yield is needed.

How to test cancellation of unfinished work

Inside TimedValueLoader.load(within:), defer calls group.cancelAll() when the task-group body exits:

defer {
    // The completed winner ignores cancellation.
    // The still-running loser receives it.
    group.cancelAll()
}

guard let firstResult = try await group.next() else {
    throw CancellationError()
}
return firstResult

Whether group.next() returns a value or throws an error, defer cancels the remaining child task. The task group then waits for both child tasks to finish before load(within:) returns or throws.

Cancellation does not stop a task immediately. The task must respond to the cancellation request. Here, cancellation ends the waits in AsyncValueProviderSpy and SleepSpy, allowing both child tasks to finish.

How to test caller cancellation

To test cancellation, we need to stop TimedValueLoader.load(within:) while the test continues to verify the result. A Task handle lets us do this by calling loadingTask.cancel(). With async let, unfinished work is automatically cancelled and awaited when its scope ends. A task created with Task { … } has no such guarantee, so we must forward cancellation from the test and stop the loading task if the test throws an unexpected error.

Wait for provider.nextCall() and sleep.nextCall() to confirm that both child tasks have started. Then cancel the loading task before calling provider.respond(with:) or sleep.resume():

@Test("Caller cancellation stops both child tasks", .timeLimit(.minutes(1)))
func callerCancellation() async throws {
    // Given: a provider and a timeout controlled by the test.
    let provider = AsyncValueProviderSpy()
    let sleep = SleepSpy()
    let sut = TimedValueLoader(provider: provider, sleep: sleep)

    // When: loading starts in a task that can be cancelled.
    let loadingTask = Task {
        try await sut.load(within: .seconds(3))
    }
    // A #require can throw before we reach loadingTask.cancel().
    // Without do/catch, the test would exit while loadingTask keeps waiting.
    // The catch block cancels that task and waits for it to finish.
    do {
        try await withTaskCancellationHandler {
            let valueCall = try #require(await provider.nextCall())
            let sleepCall = try #require(await sleep.nextCall())

            #expect(valueCall == .value)
            #expect(sleepCall == .seconds(3))

            // Cancel after both child tasks have started.
            loadingTask.cancel()

            // Then: loading ends with cancellation.
            await #expect(throws: CancellationError.self) {
                try await loadingTask.value
            }
        } onCancel: {
            loadingTask.cancel()
        }
    } catch {
        loadingTask.cancel()
        _ = await loadingTask.result
        throw error
    }
}

loadingTask.cancel() asks the loader to stop. The test then awaits loadingTask.value and checks that it throws CancellationError. At that point, the loader and both child tasks have finished.

The do-catch block handles errors thrown by the test. If a #require throws before the test reaches loadingTask.cancel(), the catch block cancels loadingTask, waits for it to finish, and rethrows the original error. withTaskCancellationHandler handles cancellation of the test itself—for example, when its time limit expires. Its onCancel closure calls loadingTask.cancel() because a task created with Task { … } does not automatically receive cancellation from the task that created it.

When the time limit expires, Swift Testing records a failure and cancels the test’s task. Cancellation does not immediately stop the test: Swift Testing still waits for the test function to return or throw. In this example, onCancel forwards cancellation to loadingTask, allowing the stream-based spies to stop waiting and the loader to finish. If a dependency ignores cancellation and never returns, awaiting loadingTask.value or loadingTask.result can keep the test—and the overall test run—stuck even after the time limit expires.

How to simplify tests that only check the result

Many tests only need to check the returned value, without checking individual calls or controlling their order. In those cases, provide the response before calling load(within:). The stream keeps the response until the provider reads it, so the test can use a direct await without waiting for call events:

@Test("The loader returns the prepared value", .timeLimit(.minutes(1)))
func preparedResponse() async throws {
    // Given: a provider with a successful response already prepared.
    let provider = AsyncValueProviderSpy()
    provider.respond(with: .success("loaded value"))
    let sut = TimedValueLoader(provider: provider, sleep: SleepSpy())

    // When: the loader requests a value.
    let value = try await sut.load(within: .seconds(3))

    // Then: the loader returns the prepared value.
    #expect(value == "loaded value")
}

Test spy implementations

The tests use two types of methods to control progress. nextCall() lets the test wait until a dependency is called and check its arguments. respond(with:) and resume() let the test provide a response or release the waiting sleep call. The test spies implement both types of methods with AsyncStream.

AsyncValueProviderSpy

When the loader calls value(), the test spy sends a call event that the test can read with nextCall(). It then reads the next response supplied through respond(with:), waiting if none is available yet. A .success response supplies the value to return; a .failure response supplies the error to throw. Both responses leave the stream open for later requests.

private actor AsyncValueProviderSpy: AsyncValueProviding {
    enum Call: Equatable, Sendable {
        case value
    }

    enum Error: Swift.Error, Equatable {
        case finishedWithoutResponse
    }

    private var callIterator: AsyncStream<Call>.Iterator?
    private let responsesContinuation: AsyncStream<Result<String, any Swift.Error>>.Continuation
    private let responses: AsyncStream<Result<String, any Swift.Error>>
    private let callEventsContinuation: AsyncStream<Call>.Continuation

    init() {
        (responses, responsesContinuation) = AsyncStream<Result<String, any Swift.Error>>.makeStream()
        let (callEvents, continuation) = AsyncStream<Call>.makeStream()
        callEventsContinuation = continuation
        callIterator = callEvents.makeAsyncIterator()
    }

    // Read call events sequentially; overlapping reads are a test error.
    func nextCall() async -> Call? {
        guard var iterator = callIterator else {
            preconditionFailure("Only one nextCall() may be active at a time")
        }
        callIterator = nil
        defer { callIterator = iterator }
        return await iterator.next(isolation: self)
    }

    nonisolated func respond(with response: Result<String, any Swift.Error>) {
        responsesContinuation.yield(response)
    }

    func value() async throws -> String {
        callEventsContinuation.yield(.value)

        var iterator = responses.makeAsyncIterator()
        guard let response = await iterator.next() else {
            try Task.checkCancellation()
            throw Error.finishedWithoutResponse
        }
        return try response.get()
    }
}

SleepSpy

When the loader calls sleep(for:), the test spy sends the requested duration as a call event. The test reads it with nextCall() and checks that it matches the expected timeout. The test spy then waits for resume() before returning, or stops waiting if the operation is cancelled.

private actor SleepSpy: Sleeping {
    private var callIterator: AsyncStream<Duration>.Iterator?
    private let progressContinuation: AsyncStream<Void>.Continuation
    private let progress: AsyncStream<Void>
    private let callEventsContinuation: AsyncStream<Duration>.Continuation

    init() {
        (progress, progressContinuation) = AsyncStream<Void>.makeStream()
        let (callEvents, continuation) = AsyncStream<Duration>.makeStream()
        callEventsContinuation = continuation
        callIterator = callEvents.makeAsyncIterator()
    }

    // Read call events sequentially; overlapping reads are a test error.
    func nextCall() async -> Duration? {
        guard var iterator = callIterator else {
            preconditionFailure("Only one nextCall() may be active at a time")
        }
        callIterator = nil
        defer { callIterator = iterator }
        return await iterator.next(isolation: self)
    }

    nonisolated func resume() {
        progressContinuation.yield(())
    }

    func callAsFunction(for duration: Duration) async throws {
        callEventsContinuation.yield(duration)
        // One consumer at a time: each token releases one sequential call.
        for await _ in progress {
            try Task.checkCancellation()
            return
        }
        // Normal completion releases current and later waits in this test double.
        try Task.checkCancellation()
    }
}

The test spies keep the stream details out of the tests. Use nextCall() to wait for a call and inspect its arguments, respond(with:) to provide a value or an error, and resume() to let a sleep call return. If the test only needs to check the result, prepare the response in advance and skip nextCall(), as in the previous example.

A failure supplied through respond(with:) affects only one request. The response stream stays open, so the same provider test spy can return a value on the next request. This is useful when testing retries.

Cancellation is different: it ends the stream used by the cancelled call. For example, after the provider throws an error, the loader (SUT) cancels the task waiting in SleepSpy. Reusing that test spy can then make the next sleep return without resume(). Create fresh test spies for each load(within:) call, as the examples do. To test retries that reuse a dependency after cancellation, that test spy would need a new stream for the next call.

Each test waits for the loader to finish before returning. The loader cancels its remaining child task, which stops waiting for input from its test spy. This is why the examples do not need to call finish() on every stream.

These test spies support one pending nextCall() per spy. The sleep test spy also assumes that sleep calls happen one at a time. If your code needs to start several sleep calls concurrently, try to use a fresh SleepSpy instance for each call.

Applying the same approach elsewhere

The same approach works for retries, cache refreshes, connectivity changes, and other operations with several steps. A test spy lets you choose when a dependency responds, so you can test the scenarios that matter without relying on timing guesses.

In every case, wait for the work started by the test to finish. Add a time limit so a missing response is reported as a test failure and cancellation is requested. The operation must respond to cancellation, as these test spies do; a time limit cannot forcibly stop it.

Concurrent code can have many possible execution orders, but each test should tell one clear story. Prepare the dependencies, start the operation, control the steps that matter, and verify the result. With test spies, that story can remain easy to follow from top to bottom—even as the code being tested becomes more complex.

The GitHub Gist includes every example and test spy from this article. The examples compile with Swift 6 strict concurrency checking, and the tests run deterministically without real-time sleeps, keeping them fast without wasting time waiting for delays to elapse.


← Back to Blog