Mixing async/await code with waitForExpectations in tests causes hang

I've encountered a problem with awaiting expectations in XCTestCase when testing async code. I want to test code that is supposed to execute operations in background and notify me about the result.

The problem is that once background operation finishes nothing happens. Instead my expectation times out after the specified time. Below is a simplified model and test case that simulates my code. Running it displays only "before doSomethingAsync" and "in doSomethingAsync" messages in console. It looks to me like execution is not returning to main actor while waitForExpectations is running.

@MainActor
final class ViewModel: ObservableObject {
    let didRun: () -> Void

    init(didRun: @escaping () -> Void) {
        self.didRun = didRun
    }

    func doSomething() {
        Task {
            print("before doSomethingAsync")
            await doSomethingAsync()
            print("after doSomethingAsync")
            didRun()
        }
    }

    nonisolated func doSomethingAsync() async {
        print("in doSomethingAsync")
    }
}

final class AsyncTestTests: XCTestCase {
    func testDoSomething() async throws {
        let exp = expectation(description: "task finished")
        let viewModel = await ViewModel(didRun: {
            exp.fulfill()
        })

        await viewModel.doSomething()

        await waitForExpectations(timeout: 5)
    }
}

Is there anything that I'm doing incorrectly here or perhaps is it an issue with waitForExpectations function?

I'm using Xcode 14.2 with iOS 16.2 simulator.

1 Like

I noticed that there is a note in documentation that says “Clients should not manipulate the run loop while using this API.”. Perhaps this means that something like I’m trying to do is not supported. If that is the case is there an alternative approach?

Would something like this work?

final class AsyncTestTests: XCTestCase {
    func testDoSomething() async throws {
        var didRun = false
        let viewModel = await ViewModel(didRun: {
            didRun = true
        })

        await viewModel.doSomething()

        XCTAssertTrue(didRun)
    }
}

Unfortunately that won’t work. doSomething launches a Task that won’t complete until later. There is an await in test code only because it runs outside of MainActor. I could annotate whole test with @MainActor like below but that won’t resolve the issue.

final class AsyncTestTests: XCTestCase {
    @MainActor
    func testDoSomething() async throws {
        let exp = expectation(description: "task finished")
        let viewModel = ViewModel(didRun: {
            exp.fulfill()
        })

        viewModel.doSomething()

        waitForExpectations(timeout: 5)
    }
}