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.Inject a controllable clock: replace
Task.sleepand real timers with a clock the test controls, so a two-second retry delay costs nothing. - 2.Await the work directly: write
func test() async throwsandawaitthe exact operation under test, so assertions run after it completes. Reach forXCTestExpectationonly when bridging a callback API. - 3.Control task lifetimes: avoid fire-and-forget
Task {}in production code. Use structured concurrency, such asasync letor a task group, so callers and tests can await every piece of spawned work. - 4.Isolate shared mutable state: keep it behind an
actoror a thread-safe fake. Run the scenario many times under Thread Sanitizer to surface data races early. - 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
asynctest functions and await the work directly instead of jugglingXCTestExpectation. - 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)
}



