Introduction
XCTest is designed to run assertions on the main thread. When assertions, expectations, or test completion handlers are called from background queues, tests can fail unpredictably, hang indefinitely, or produce misleading error messages. This is common when testing async code, network requests, or background processing where callbacks execute on arbitrary GCD queues.
Symptoms
Asynchronous wait failed: Exceeded timeout of 10 secondsXCTAssertcalled from background thread produces incorrect results- Test hangs forever waiting for an expectation that was fulfilled on another thread
failed: caught "NSInternalInconsistencyException"during async test- Test passes locally but fails in CI due to timing differences
Example failing test: ```swift func testAsyncDataFetch() { let expectation = XCTestExpectation(description: "Data fetched")
fetcher.fetchData { result in // Running on background queue! switch result { case .success(let data): XCTAssertEqual(data.count, 5) // May not report correctly expectation.fulfill() // May not wake up waiter case .failure: XCTFail("Fetch failed") } }
wait(for: [expectation], timeout: 10) } ```
Common Causes
- Completion handlers executing on background queues
XCTAssertcalled from a callback on a non-main threadexpectation.fulfill()called on a thread different from the waiter- Network mock returns data on a background queue
- Core Data or file I/O callbacks on private queues
Step-by-Step Fix
- 1.Dispatch assertions to the main thread:
- 2.```swift
- 3.func testAsyncDataFetch() {
- 4.let expectation = XCTestExpectation(description: "Data fetched")
fetcher.fetchData { result in DispatchQueue.main.async { switch result { case .success(let data): XCTAssertEqual(data.count, 5) case .failure(let error): XCTFail("Fetch failed: \(error)") } expectation.fulfill() } }
wait(for: [expectation], timeout: 10) } ```
- 1.Use async/await tests (Xcode 13+ / Swift 5.5+):
- 2.```swift
- 3.func testAsyncDataFetch() async throws {
- 4.// No expectations needed with async tests
- 5.let data = try await fetcher.fetchData()
- 6.XCTAssertEqual(data.count, 5)
- 7.}
// For tests that need to throw: func testAsyncFetchFailure() async throws { do { _ = try await fetcher.fetchData(from: invalidURL) XCTFail("Expected an error") } catch FetchError.invalidURL { // Expected } catch { XCTFail("Unexpected error: \(error)") } } ```
- 1.Use continuation to bridge callback-based APIs:
- 2.```swift
- 3.func testAsyncDataFetch() async throws {
- 4.let data: [Item] = try await withCheckedThrowingContinuation { continuation in
- 5.fetcher.fetchData { result in
- 6.switch result {
- 7.case .success(let items):
- 8.continuation.resume(returning: items)
- 9.case .failure(let error):
- 10.continuation.resume(throwing: error)
- 11.}
- 12.}
- 13.}
XCTAssertEqual(data.count, 5) } ```
- 1.Configure the API to return on main queue for testing:
- 2.```swift
- 3.class MockFetcher: DataFetcher {
- 4.var shouldReturnOnMainQueue = true
func fetchData(completion: @escaping (Result<[Item], Error>) -> Void) { let result = Result.success(mockItems)
if shouldReturnOnMainQueue { DispatchQueue.main.async { completion(result) } } else { completion(result) } } } ```
Prevention
- Use async/await tests instead of XCTestExpectation when possible
- Always dispatch to main thread before calling XCTAssert from callbacks
- Add
@MainActorto test methods that interact with UI - Use
XCTMainto ensure test execution order - Mock background APIs to return on main queue in tests
- Add timeout assertions:
XCTAssertGreaterThan(duration, 0, "Took too long")