How do you test async code: async test functions, expectations, confirmations, actors and the main actor?
Predict first
An async test, a callback test with an expectation, and an inverted expectation. Do all three pass?
// === Package.swift ===
// swift-tools-version:6.0
import PackageDescription
let package = Package(name: "Lib", targets: [.target(name: "Lib"), .testTarget(name: "LibTests", dependencies: ["Lib"])])
// === Sources/Lib/Lib.swift ===
import Foundation
public actor Store {
private var items: [String] = []
public init() {}
public func add(_ s: String) async { try? await Task.sleep(nanoseconds: 1_000_000); items.append(s) }
public func count() -> Int { items.count }
}
public final class Loader: @unchecked Sendable {
public init() {}
public func load(_ done: @escaping @Sendable (Int) -> Void) { DispatchQueue.global().asyncAfter(deadline: .now() + 0.01) { done(42) } }
}
// === Tests/LibTests/T.swift ===
import XCTest
@testable import Lib
final class T: XCTestCase {
func testAsyncAwait() async {
let store = Store()
await store.add("a")
let n = await store.count()
XCTAssertEqual(n, 1)
}
func testCallbackWithExpectation() async {
let done = expectation(description: "loaded")
let result = ResultBox()
Loader().load { v in result.set(v); done.fulfill() }
await fulfillment(of: [done], timeout: 2)
XCTAssertEqual(result.value, 42)
}
func testInvertedExpectation() async {
let never = expectation(description: "must not fire")
never.isInverted = true
await fulfillment(of: [never], timeout: 0.1)
}
}
final class ResultBox: @unchecked Sendable {
private let lock = NSLock(); private var v = 0
func set(_ x: Int) { lock.lock(); v = x; lock.unlock() }
var value: Int { lock.lock(); defer { lock.unlock() }; return v }
}
- Yes, all three pass
- The callback test fails because it is not on the main thread
- The inverted one fails because nothing fires
Mental model. Waiting for a parcel: you do not stand outside for an hour hoping it comes; you ask the courier to ring when it arrives, and decide in advance how long is too long.
If the code under test is async, make the test async and await it: both XCTest and Swift Testing accept async throws test functions, so there is nothing to wait for explicitly and a failure is reported on the line that awaited. For callbacks and notifications, bridge them: in XCTest create an XCTestExpectation, fulfil it in the callback and await fulfillment(of:timeout:) (the async form avoids blocking the thread, unlike the older wait(for:)); set expectedFulfillmentCount for an event that must fire N times, and isInverted for an event that must not happen. In Swift Testing the equivalent is confirmation(expectedCount:), which fails if the closure’s confirmations do not match. Better still, wrap the callback API in a continuation (withCheckedContinuation) so the test is just an await.
A few rules keep these tests honest. Never sleep to “give it time”: it is slow when generous and flaky when tight. Await the result, or wait on an expectation with a timeout that only matters when something is genuinely broken. Mark tests that touch UI or a @MainActor type as @MainActor. Test an actor by awaiting its methods like any caller, and test cancellation by cancelling the task and asserting the work stopped. Make concurrency deterministic where you can: inject the executor, queue or clock rather than depending on real scheduling, and keep shared mutable state out of tests so parallel runs do not interfere. For long tasks assert on states reached, not elapsed time.
Awaiting is enough for async code, the expectation is fulfilled by the callback, and an inverted expectation passes when it is not fulfilled.
Common traps
- Using
sleepor a fixed delay in a test. It is either wasted time or a flake; wait on an event. - Forgetting to await the expectation. The test finishes before the callback runs and passes without checking anything.
They might follow up with
- How do you assert something does not happen? An inverted expectation (XCTest), which passes if it is not fulfilled in the timeout.
- Why prefer
await fulfillmenttowait(for:)? It suspends instead of blocking the thread, which matters on the main actor.
Related questions
- 22.3.2 How do you test code that depends on time: debounce, timeouts, retries and expiry?
- 22.2.1 How do XCTest and Swift Testing differ, and when do you still use XCTest?
- 11.6.2 How do you test Combine pipelines, and what is a TestScheduler pattern?
How do you test code that depends on time: debounce, timeouts… →