XCTest expectations are great but it is not easy to use them.

Here is a simple extension that makes it possible to wait for a condition until it is fulfilled or timed-out.

extension XCTestCase {

    /// Wait for a condition to be fulfilled
    /// Example:
    ///     var fulfilled = false
    ///     wait(fulfilled)
    public func wait(
        _ condition: @autoclosure @escaping () -> Bool,
        timeout: TimeInterval = 10,
        description: String = "wait condition expectation"
    ) {
        let expectation = self.expectation(description: description)

        func testForCondition() {
            if condition() {
                expectation.fulfill()
            } else {
                DispatchQueue.main.async { testForCondition() }
            }
        }

        testForCondition()

        wait(for: [expectation], timeout: timeout)
    }
}

Let’s talk about the code. The condition is passed as a closure. @autoclosureis a syntactic sugar so that instead of wait({ a==b }) we can write wait(a==b) @escaping tells the compiler that the closure is retained.

Another line we should pay attention to: DispatchQueue.main.async { testForCondition() } The only reason we need DispatchQueue.main.async is that it makes sure that testForCondition is executed once per run-loop.

And finally the usage:

func testWaitForCondition1() throws {
    var fulfilled = false
    let subject = PassthroughSubject<Int, Never>()

    subject
        .debounce(for: .milliseconds(100),
                  scheduler: DispatchQueue.global())
        .sink(receiveCompletion: { _ in
            print("finished")
        }, receiveValue: { value in
            fulfilled = true
        })
        .store(in: &tasks)

    subject.send(1)
    subject.send(2)

    wait(fulfilled)
}

The code can be found in the XCTestCase wait helper extension on GitHub.

FAQ

How can I wait for a Boolean condition in XCTest without manually setting up expectations?
You can add a wait helper extension on XCTestCase that takes a condition closure, repeatedly checks it, and fulfills a single expectation once the condition becomes true or a timeout is reached. Your tests then simply call wait(fulfilled) instead of wiring expectations by hand.
Why does the wait helper use DispatchQueue.main.async when polling the condition?
Dispatching the next check onto the main queue ensures the helper runs once per run loop iteration instead of spinning synchronously. That keeps the main thread responsive while still letting the condition flip to true as Combine publishers or async work complete.

Welcome to The infinite monkey theorem

Somewhere a monkey just typed Shakespeare in TypeScript. Be the first to read the masterpieces (and the hilarious misfires) landing on the blog.

Subscribe to The infinite monkey theorem

We fling fresh posts—no banana peels attached—straight to your inbox.