Chapter 17 — Testing asynchronous code
Async tests are just async functions
import Testing
@testable import MyApp
@Test func fetchesProfile() async throws {
let client = APIClient(session: .mock(returning: sampleProfileJSON))
let profile = try await client.fetch(Profile.self, from: testURL)
#expect(profile.name == "Ada")
}
@Test func cacheReturnsStoredImage() async {
let cache = ImageCache()
await cache.insert(sampleImage, for: testURL)
let got = await cache.image(for: testURL)
#expect(got != nil)
}
confirmation: testing that events happen (the right number of times)
@Test func streamEmitsThreePrices() async {
await confirmation("receives 3 prices", expectedCount: 3) { confirm in
let feed = makeTestPriceFeed(emitting: [1.0, 2.0, 3.0])
for await _ in feed {
confirm() // called once per emitted value; must total 3
}
}
}
The flakiness enemy: real time
// ❌ FLAKY — depends on wall-clock timing and real delays.
@Test func debouncesSearch() async throws {
let vm = SearchViewModel()
vm.query = "ca"
try await Task.sleep(for: .milliseconds(100))
vm.query = "cat"
try await Task.sleep(for: .milliseconds(400)) // "long enough"? maybe. on a loaded CI box? maybe not.
#expect(vm.searchCallCount == 1)
}
Injecting a clock
// Production code depends on a Clock instead of calling Task.sleep directly.
struct Debouncer<C: Clock> {
let clock: C
let delay: C.Duration
func debounce(_ work: @escaping () async -> Void) async throws {
try await clock.sleep(for: delay) // sleeps against the INJECTED clock
await work()
}
}
flowchart LR
subgraph FLAKY__Flaky_test__ [\"Flaky test\"]
R["real Task.sleep"] --> T["hope the timing lines up"]
end
subgraph SOLID__Deterministic_test__ [\"Deterministic test\"]
I["injected test clock"] --> A["advance time manually"]
A --> D["assert on exact behavior"]
end
Testing cancellation
@Test func stopsWhenCancelled() async {
let tracker = WorkTracker()
let task = Task { await tracker.runUntilCancelled() }
await tracker.waitUntilStarted() // ensure it's actually running
task.cancel()
await task.value // wait for it to wind down
#expect(await tracker.didStopEarly)
}
Mocking async dependencies
protocol ProfileFetching: Sendable {
func fetchProfile(_ id: User.ID) async throws -> Profile
}
// A fake you fully control — returns a canned value, records calls, can throw on demand.
final class StubProfileFetcher: ProfileFetching, @unchecked Sendable {
var result: Result<Profile, Error> = .success(.sample)
private(set) var callCount = 0
func fetchProfile(_ id: User.ID) async throws -> Profile {
callCount += 1
return try result.get()
}
}
Isolation and parallel tests
- Swift Testing runs tests in parallel by default. That's great for speed but means tests must not
@MainActoron a test (or a whole suite) pins it to the main actor when you're testing main-actor
What we built in this chapter
- Tested async functions and actors as plain
asyncSwift Testing functions —try awaitthe - Used
confirmationto assert that events (stream emissions, callbacks, single resumes) happen the - Identified real elapsed time as the #1 cause of flaky async tests and fixed it by **injecting a
- Tested cancellation deterministically by synchronizing on "work has started" before cancelling, and
- Covered the test runtime: parallel-by-default (don't share mutable state),
@MainActorsuites,
Mental model to take away
- An async test is just an
asyncfunction —awaitthe code under test and assert. Testing actors - Use
confirmationfor "did this event happen N times," especially single-resume continuations and - Never depend on real time. Inject a
Clock(and every other non-deterministic dependency) so - Tests run in parallel — give each its own instances and inject collaborators, and your concurrency