An asynchronous test should establish when an operation completed and what happened because of it. Waiting for an arbitrary amount of time establishes neither. A fast machine can hide a race that appears on a busy CI worker, while a slow test can still inspect state before the relevant work finishes.

Swift’s concurrency tools let a test await an operation directly. For callback APIs and controlled interleavings, use completion signals that describe the actual event being tested.

This January 2024 article uses Swift 5.7 syntax and XCTest. Its async expectation example uses fulfillment(of:timeout:), available in Xcode 14.3 and later. Later Swift blocks extend the definitions in this article’s test file. Apple documents both direct async tests and expectation-based tests in Asynchronous Tests and Expectations.

Await a result instead of guessing its duration

import Foundation
import XCTest

struct BookSummary: Sendable, Equatable {
    let id: Int
    let title: String
}
protocol BookLoading: Sendable { func load() async throws -> BookSummary }
struct FixedBookLoader: BookLoading {
    let book: BookSummary
    func load() async throws -> BookSummary { book }
}
final class DirectAsyncTests: XCTestCase {
    func testReturnsTheLoadedBook() async throws {
        let expected = BookSummary(id: 7, title: "Swift")
        let actual = try await FixedBookLoader(book: expected).load()
        XCTAssertEqual(actual, expected)
    }
}

An async throws test fails if the awaited call throws unexpectedly. Put explicit error assertions around a call when failure is the behavior under test. Do not use try? merely to keep the test running; it can erase the distinction between a thrown error and a valid absent result.

Awaiting a method establishes only that method’s completion contract. If the method launches an unstructured task and returns immediately, awaiting it does not wait for that task. Expose a result, task handle, or explicit completion signal when the caller needs to observe the work.

Control a suspension point to reproduce a race

An actor serializes access to its fields, but another call can run while one method is suspended. A screen model therefore needs an acceptance rule for a load that finishes after a newer load.

actor ControlledBookLoader: BookLoading {
    private var nextID = 0
    private var pending: [Int: CheckedContinuation<BookSummary, Error>] = [:]
    private var waiters: [Int: [CheckedContinuation<Void, Error>]] = [:]
    private var closed = false

    func load() async throws -> BookSummary {
        guard !closed else { throw CancellationError() }
        precondition(nextID < Int.max)
        nextID += 1
        let id = nextID
        return try await withCheckedThrowingContinuation { continuation in
            pending[id] = continuation
            let ready = waiters.removeValue(forKey: id) ?? []
            for waiter in ready { waiter.resume() }
        }
    }
    func waitUntilPending(_ id: Int) async throws {
        guard !closed else { throw CancellationError() }
        if pending[id] != nil { return }
        try await withCheckedThrowingContinuation { waiters[id, default: []].append($0) }
    }
    func succeed(_ id: Int, with book: BookSummary) {
        guard let continuation = pending.removeValue(forKey: id) else {
            preconditionFailure("No pending load for this identity")
        }
        continuation.resume(returning: book)
    }
    func failAll() {
        closed = true
        let current = Array(pending.values)
        pending.removeAll()
        for continuation in current { continuation.resume(throwing: CancellationError()) }
        let waiting = waiters.values.flatMap { $0 }
        waiters.removeAll()
        for continuation in waiting { continuation.resume(throwing: CancellationError()) }
    }
}

@MainActor
final class BookScreenModel {
    private let loader: any BookLoading
    private var generation = 0
    private(set) var book: BookSummary?
    private(set) var isLoading = false
    init(loader: any BookLoading) { self.loader = loader }
    func reload() async throws {
        precondition(generation < Int.max)
        generation += 1
        let ticket = generation
        isLoading = true
        defer { if ticket == generation { isLoading = false } }
        let loaded = try await loader.load()
        try Task.checkCancellation()
        guard ticket == generation else { return }
        book = loaded
    }
}

The controlled loader signals after its continuation has been installed. The test can then release a specific request without a timing assumption. Each continuation must be resumed exactly once; removing it before resuming enforces that ownership in the fake.

failAll is terminal cleanup for this fake: it cancels pending results and readiness waiters, and rejects later loads. This fake does not automatically remove a cancelled waiter or implement production cancellation. Tests must release pending calls explicitly before awaiting their task handles. A checked continuation diagnoses misuse; it does not supply a timeout or cancellation policy by itself.

Test reversed completion order directly

final class RequestOrderingTests: XCTestCase {
    @MainActor
    func testOldLoadCannotReplaceNewerContent() async throws {
        let loader = ControlledBookLoader()
        let model = BookScreenModel(loader: loader)
        let first = Task { try await model.reload() }
        try await loader.waitUntilPending(1)
        let second = Task { try await model.reload() }
        try await loader.waitUntilPending(2)
        await loader.succeed(2, with: BookSummary(id: 2, title: "Current"))
        try await second.value
        await loader.succeed(1, with: BookSummary(id: 1, title: "Obsolete"))
        try await first.value
        XCTAssertEqual(model.book, BookSummary(id: 2, title: "Current"))
        XCTAssertFalse(model.isLoading)
        await loader.failAll()
    }

    @MainActor
    func testCancelledCallerDoesNotPublishALateResult() async throws {
        let loader = ControlledBookLoader()
        let model = BookScreenModel(loader: loader)
        let task = Task { try await model.reload() }
        try await loader.waitUntilPending(1)
        task.cancel()
        await loader.succeed(1, with: BookSummary(id: 1, title: "Late"))
        do {
            try await task.value
            XCTFail("The cancelled load must reject publication")
        } catch is CancellationError {}
        XCTAssertNil(model.book)
        XCTAssertFalse(model.isLoading)
        await loader.failAll()
    }
}

The cancellation test deliberately returns a value after cancellation. It proves the model checks acceptance when a dependency does not cooperate. A fake that simply deletes the callback on cancellation would conceal that path.

These tests run model interactions on the main actor because the model is UI-facing. Actor isolation is not a reason to block that actor with a semaphore. Blocking can prevent the very task that should complete the operation from running.

Use expectations for callback APIs

final class CallbackBookLoader {
    func load(completion: @escaping (BookSummary) -> Void) {
        completion(BookSummary(id: 7, title: "Callback result"))
    }
}
final class CallbackTests: XCTestCase {
    func testCallbackDeliversTheExpectedIdentity() async {
        let completed = expectation(description: "Book completion received")
        completed.assertForOverFulfill = true
        CallbackBookLoader().load { book in
            XCTAssertEqual(book.id, 7)
            XCTAssertEqual(book.title, "Callback result")
            completed.fulfill()
        }
        await fulfillment(of: [completed], timeout: 1)
    }
}

The expectation is created before work begins, so synchronous completion is valid. Its timeout is a maximum failure bound, not a request to sleep for one second. A successful callback lets the wait complete immediately.

The assertions are inside the callback, and fulfillment confirms it ran. A callback containing assertions without a wait can execute after the test has already passed. The synchronous fake keeps the mechanics visible; a real callback adapter should be tested for its promised executor and completion count.

Use nonblocking fulfillment in async tests rather than blocking wait(for:). If several independent completions are required, wait for all of them. Enforce their order only when order is part of the contract.

Distinguish a task from the effect it requested

sequenceDiagram
    accTitle: A controlled test releases the newer request before the older request
    participant T as Test
    participant M as Main-actor model
    participant L as Controlled loader
    T->>M: Start first reload
    M->>L: Install request 1 continuation
    L-->>T: Request 1 is pending
    T->>M: Start second reload
    M->>L: Install request 2 continuation
    T->>L: Complete request 2
    L-->>M: Current result
    T->>L: Complete request 1
    L-->>M: Old result, rejected by generation

A local cancellation request does not prove a server rolled back an operation. For writes, distinguish known rejection from uncertain completion and use the service’s idempotency or reconciliation contract. An async test should not assert “nothing happened” merely because its caller stopped waiting.

Keep test-owned work inside its lifetime

Retain task handles, release controlled continuations, cancel obsolete work, and await completion before the test ends. Avoid spawning detached tasks that can mutate a shared fixture during the next test.

A wait that never reaches its signal can hang a test. Use the test runner’s execution limits as a backstop and make the controlled protocol fail clearly when setup is invalid. When a fake supports only a particular number of calls, state that limitation rather than silently waiting for an impossible event.

Exercise: add a controlled failure

Add a method that fails one pending request with a domain error. Verify the current request reports failure, an obsolete failure does not erase newer content, and all continuations and task handles are finished at test teardown. Keep the ordering controlled through signals rather than sleeps.

Return to the Testing roadmap.