Testing Async/Await Code in Swift

Testing Async/Await Code in Swift

Concurrency transformed Swift development, but it also introduced new testing challenges. How do you assert on values that arrive asynchronously? How do you test @MainActor-isolated code? This guide covers every pattern you'll need.

Async Test Functions in XCTest

XCTest supports async test functions natively since Xcode 13. Mark your test method with async and await the code under test:

import XCTest
@testable import MyApp

final class WeatherServiceTests: XCTestCase {

    func testFetchCurrentWeather() async throws {
        let service = WeatherService(client: URLSession.shared)
        let weather = try await service.fetchCurrent(for: "London")
        XCTAssertEqual(weather.city, "London")
        XCTAssertGreaterThan(weather.temperatureCelsius, -90)
    }
}

XCTestCase automatically wraps async test methods in a task and waits for them to complete. If the function throws, the test fails with the error—no extra boilerplate required.

Testing Code That Uses Task

When production code fires a detached Task, the work runs outside your test's async context. The simplest fix is to inject a clock or use Task.yield() to let the scheduler run:

func testBackgroundRefreshUpdatesState() async {
    let viewModel = FeedViewModel(service: MockFeedService())
    viewModel.startRefresh() // internally creates Task { await load() }

    // Yield several times to let the spawned Task run
    for _ in 0..<5 { await Task.yield() }

    XCTAssertFalse(viewModel.isLoading)
    XCTAssertEqual(viewModel.items.count, 3)
}

Task.yield() surrenders the current thread's time slice, giving queued tasks a chance to run. Call it in a loop when you don't know exactly how many hops the underlying work needs.

For more deterministic control, inject a Clock from Swift's standard library and use TestClock from the swift-clocks library:

func testDebounceWaitsOneSecond() async {
    let clock = TestClock()
    let search = SearchViewModel(clock: clock)

    search.query = "swift"
    await clock.advance(by: .milliseconds(800))
    XCTAssertEqual(search.results.count, 0) // debounce hasn't fired

    await clock.advance(by: .milliseconds(200))
    XCTAssertEqual(search.results.count, 5) // debounce fired at 1 s
}

MainActor-Isolated Code

@MainActor guarantees UI updates happen on the main thread. Testing it is straightforward—just run your test on the main actor too:

@MainActor
final class CounterViewModelTests: XCTestCase {

    func testIncrementUpdatesCount() async {
        let vm = CounterViewModel()
        vm.increment()
        XCTAssertEqual(vm.count, 1)
    }
}

Annotating the entire XCTestCase subclass with @MainActor means every test method inherits the isolation. You can also annotate individual methods:

func testLoadUpdatesTitle() async {
    await MainActor.run {
        let vm = ProfileViewModel()
        vm.load(user: .fixture)
        XCTAssertEqual(vm.title, "Alice")
    }
}

Async Expectations the Old Way

Before native async support, XCTest used XCTestExpectation. You may still encounter this in older codebases:

func testLegacyCallback() {
    let expectation = expectation(description: "data received")
    var receivedData: Data?

    URLSession.shared.dataTask(with: someURL) { data, _, _ in
        receivedData = data
        expectation.fulfill()
    }.resume()

    waitForExpectations(timeout: 5)
    XCTAssertNotNil(receivedData)
}

Prefer native async throws test functions for new code. They produce cleaner stack traces and respect Swift's structured concurrency model.

Swift Testing Framework: async Tests

Apple's new Swift Testing framework (available from Swift 5.10 / Xcode 16) handles async naturally:

import Testing
@testable import MyApp

@Suite("WeatherService")
struct WeatherServiceTests {

    @Test("Returns temperature for valid city")
    func fetchCurrentWeather() async throws {
        let service = WeatherService(client: MockHTTPClient())
        let weather = try await service.fetchCurrent(for: "Tokyo")
        #expect(weather.city == "Tokyo")
    }
}

Swift Testing runs each @Test in its own task, so isolation is automatic. For actor-isolated tests, attach the actor to the suite:

@Suite @MainActor
struct DashboardViewModelTests {

    @Test func initialStateIsEmpty() {
        let vm = DashboardViewModel()
        #expect(vm.items.isEmpty)
    }
}

Testing AsyncStream and AsyncSequence

AsyncStream is common for wrapping delegate callbacks or Combine publishers:

func testLocationStreamEmitsValues() async {
    let locationManager = MockLocationManager()
    var received: [CLLocation] = []

    let stream = locationManager.locationStream()
    locationManager.simulate(location: CLLocation(latitude: 51.5, longitude: -0.1))
    locationManager.simulate(location: CLLocation(latitude: 48.8, longitude: 2.3))
    locationManager.finish()

    for await location in stream {
        received.append(location)
    }

    XCTAssertEqual(received.count, 2)
    XCTAssertEqual(received[0].coordinate.latitude, 51.5, accuracy: 0.001)
}

Call finish() or continuation.finish() on your mock to terminate the sequence, otherwise for await loops forever and the test times out.

Handling Timeouts

Give async tests an explicit timeout when they rely on real network or disk I/O:

func testSlowOperation() async throws {
    try await withTimeout(seconds: 10) {
        let result = try await expensiveOperation()
        XCTAssertNotNil(result)
    }
}

func withTimeout<T>(seconds: Double, operation: @escaping () async throws -> T) async throws -> T {
    try await withThrowingTaskGroup(of: T.self) { group in
        group.addTask { try await operation() }
        group.addTask {
            try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
            throw TimeoutError()
        }
        let result = try await group.next()!
        group.cancelAll()
        return result
    }
}

struct TimeoutError: Error {}

Mocking Async Dependencies

Protocol-based mocking works exactly as with synchronous code—just mark the protocol requirement async:

protocol UserRepository {
    func fetchUser(id: UUID) async throws -> User
}

struct MockUserRepository: UserRepository {
    var stubbedUser: User = .fixture

    func fetchUser(id: UUID) async throws -> User {
        stubbedUser
    }
}

func testProfileLoadsUser() async throws {
    let repo = MockUserRepository(stubbedUser: .init(id: UUID(), name: "Bob"))
    let vm = ProfileViewModel(repository: repo)
    try await vm.load()
    XCTAssertEqual(vm.name, "Bob")
}

Key Takeaways

  • Mark test functions async throws for clean async testing without expectations boilerplate.
  • Use Task.yield() or inject a controllable clock to synchronize detached tasks.
  • Annotate test classes or methods with @MainActor when testing actor-isolated code.
  • Prefer Swift Testing's @Test annotation for new test suites—it handles async isolation automatically.
  • Always terminate AsyncStream sources in tests or add a timeout to prevent hangs.

Structured async testing keeps your suite fast and deterministic. Once you have solid async coverage running locally, HelpMeTest can monitor your app around the clock—catching regressions before users do.

Start now free