Question 3

Testing asynchronous and concurrent code

How do you reliably test async/await code and concurrency-heavy features to avoid flakiness and race conditions?

Answer outline

Flaky async tests almost always come from nondeterminism: real timers, real concurrency, or shared mutable state. Remove the nondeterminism instead of waiting longer and hoping.

Five techniques cover most cases:

  1. 1.Inject a controllable clock: replace Task.sleep and real timers with a clock the test controls, so a two-second retry delay costs nothing.
  2. 2.Await the work directly: write func test() async throws and await the exact operation under test, so assertions run after it completes. Reach for XCTestExpectation only when bridging a callback API.
  3. 3.Control task lifetimes: avoid fire-and-forget Task {} in production code. Use structured concurrency, such as async let or a task group, so callers and tests can await every piece of spawned work.
  4. 4.Isolate shared mutable state: keep it behind an actor or a thread-safe fake. Run the scenario many times under Thread Sanitizer to surface data races early.
  5. 5.Swap every async dependency: networking, databases, and queues should all be replaceable with deterministic fakes, so a test can force failures and control ordering.

Principles

  • Inject a test clock so the test owns time and never waits on the real scheduler.
  • Write async test functions and await the work directly instead of juggling XCTestExpectation.
  • Loop a scenario under Thread Sanitizer locally so data races show up before CI sees them.
  • Test cancellation explicitly: cancel the task and assert the system under test respected it.
  • Keep one concern per async test, because every extra layer multiplies the race surface.

A RetryHandler waits two seconds between attempts, so injecting a no-op TestClock makes the test finish instantly:

Clock injection: retry with delay
protocol ClockType {
    func sleep(for duration: Duration) async throws
}

struct SystemClock: ClockType {
    func sleep(for duration: Duration) async throws {
        try await Task.sleep(for: duration)
    }
}

struct TestClock: ClockType {
    func sleep(for duration: Duration) async throws { /* no-op */ }
}

// Production type
struct RetryHandler {
    let clock: any ClockType

    func fetch(maxAttempts: Int, work: () async throws -> Data) async throws -> Data {
        var lastError: Error?
        for attempt in 1...maxAttempts {
            do {
                return try await work()
            } catch {
                lastError = error
                if attempt < maxAttempts {
                    try await clock.sleep(for: .seconds(2)) // skipped in tests
                }
            }
        }
        throw lastError!
    }
}

// Test: completes immediately despite the two-second retry delays
func testRetriesOnFailure() async throws {
    var callCount = 0
    let sut = RetryHandler(clock: TestClock())

    _ = try await sut.fetch(maxAttempts: 3) {
        callCount += 1
        if callCount < 3 { throw URLError(.notConnectedToInternet) }
        return Data()
    }

    XCTAssertEqual(callCount, 3)
}

Cancel the task, wait for it to exit, and assert the system under test observed the cancellation:

Cancellation test
func testCancellationIsRespected() async {
    let task = Task {
        await sut.longRunningWork()
    }
    task.cancel()
    // Wait for the work to finish so the assertion is not racing it
    await task.value
    XCTAssertTrue(sut.didCancel)
}