Using Nimble’s waitUntil for Clean Async Swift Tests
Simplify async Swift tests with Nimble’s waitUntil matcher. Learn how to replace semaphores, set timeouts, and handle failures with clear, actionable guidance and a concrete example.
02 Jul 2026, 15:48 UTC

Problem: Testing Asynchronous Code Is Harder Than It Looks
When a Swift function performs work on a background queue and later updates the UI or a property on the main thread, a unit test must wait for that completion before making assertions. Traditional solutions involve semaphores, expectations, or manual polling loops that clutter test code and make it brittle. The result is test files that are hard to read and prone to flakiness when the timing of the async work shifts.
Nimble’s waitUntil Matcher: A Brief Overview
Nimble is a popular assertion library for Swift that pairs well with the Quick testing framework. Its waitUntil matcher was introduced to address the exact problem above. Instead of manually coordinating a semaphore or expectation, you write an expect statement that automatically retries a closure until a condition is met or a timeout expires.
The matcher signature looks like this:
waitUntil(timeout: TimeInterval = 5, pollInterval: TimeInterval = 0.05, action: @escaping (DoneCallback) -> Void)
• timeout – how long to keep retrying before giving up. The default is 5 seconds.
• pollInterval – how often the closure is invoked. The default is 0.05 seconds.
• action – a closure that receives a DoneCallback. Call it when the async work is finished, passing any value you want to test.
Under the hood, waitUntil schedules repeated checks on the main queue using Grand Central Dispatch. This keeps the test runner responsive and avoids blocking the entire test suite.
Concrete Example: Asserting a Flag After a 1‑Second Delay
Assume you have a function that flips a Boolean after a delay:
func setFlagAfterDelay(completion: @escaping (Bool) -> Void) {
DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
completion(true)
}
}
Here’s how you would test it with Nimble’s waitUntil:
import Quick
import Nimble
class AsyncTests: QuickSpec {
override func spec() {
describe("setFlagAfterDelay") {
it("sets the flag to true after a delay") {
var flag: Bool?
setFlagAfterDelay { result in
flag = result
}
// The matcher waits until the closure above sets flag.
expect(flag).toEventually(beTrue(), timeout: .seconds(2))
}
}
}
}
Key points:
- Run the test in a test target that includes Quick and Nimble.
- The
expect(flag).toEventually(beTrue(), timeout: .seconds(2))line is equivalent towaitUntilunder the hood, but usingtoEventuallykeeps the syntax consistent with Nimble’s other matchers. - If you want to see the value returned by the async operation directly, you can use
expect { flag }.toEventually(equal(true), timeout: .seconds(2)).
When the test runs, Nimble will keep checking flag every 0.05 seconds until it becomes true or the timeout is reached. If the timeout occurs, the failure message will include the last evaluated value, making debugging easier.
Trade‑offs and Limitations to Keep in Mind
- Blocking the Test Thread:
waitUntilblocks the current test thread until completion. If you place it inside abeforeEachthat runs on the main thread, and the async code also dispatches to the main queue, you may deadlock. KeepwaitUntilin the test body or in a separate helper that runs on a background thread. - Long Timeouts Delay the Suite: If an async operation never completes, the test will wait for the full timeout before failing. Consider using a shorter timeout during development and only increase it for known long‑running operations.
- Polling Overhead: The default poll interval of 0.05 seconds may be more frequent than necessary for some tests. Adjust
pollIntervalif you have a very fast or very slow operation to reduce unnecessary checks. - Complex Assertions: For complex state changes, you might still need to use
expect { ... }.toEventually(...)with a custom matcher, rather than relying on a simple Boolean.
Actionable Guidance for Adopting waitUntil in Your Test Suite
- Add Quick and Nimble – If you haven’t already, add the Quick and Nimble pods or Swift Package Manager dependencies to your test target.
- Replace Manual Semaphores – Search for
XCTestExpectationorDispatchSemaphorepatterns and replace them withexpect(...).toEventually(...)orwaitUntil. - Set Reasonable Timeouts – Start with a 2‑second timeout for most async tests. Increase only when you know the operation can legitimately take longer.
- Use Custom Poll Intervals for Long‑Running Tests – For a 5‑second operation, a poll interval of 0.2 seconds reduces the number of checks while still catching early failures.
- Verify Failure Messages – Run a failing test intentionally (e.g., by setting the timeout to 0.5 seconds) to confirm that Nimble reports the last evaluated value in the failure log. This helps you spot the exact state that caused the failure.
- Keep Tests Idempotent – Ensure that the async operation can be restarted cleanly in each test run. If the operation has side effects, reset state in
afterEach.
Conclusion
Nimble’s waitUntil matcher turns a painful, boilerplate‑heavy async test into a concise, readable assertion. By letting the matcher handle polling, timeouts, and failure reporting, you free yourself to focus on the logic you actually care about. Just remember the trade‑offs: avoid deadlocks by not using it on the main thread when the async code also touches the main queue, and keep an eye on timeout values to prevent long‑running tests from dragging down your CI pipeline.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.