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
  • @MainActor on 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 async Swift Testing functions — try await the
  • Used confirmation to 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), @MainActor suites,

Mental model to take away

  • An async test is just an async functionawait the code under test and assert. Testing actors
  • Use confirmation for "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