Nimble's toEventually Is a Polling Loop: Choosing It on Purpose
Nimble's toEventually matcher removes expectation boilerplate, but it replaces an event with a poll. Here's how to choose it deliberately and keep CI fast.
13 Oct 2025, 19:23 UTC

Why toEventually feels like magic — and where it isn't
Nimble replaces XCTest's assertion functions with a matcher DSL: expect(value).to(equal(expected)) instead of XCTAssertEqual(value, expected). The piece that changes how async tests get written is toEventually, which keeps re-checking an expression until it passes or a timeout elapses.
That removes a lot of XCTestExpectation boilerplate. It also hides a design decision: you have replaced an event with a poll. Most of the time that is a good trade. Sometimes it is the reason a suite takes four minutes in CI.
The useful stance is to treat toEventually as a deliberate choice with a budget attached — an explicit timeout and poll interval — and to fall back to event-driven waits when the code under test already tells you it is done.
What expect and a matcher actually do
expect wraps an expression, typically as an autoclosure, so the expression is re-evaluated rather than captured once. A matcher then inspects the resulting value and decides pass or fail. Nimble ships matchers such as equal, beTrue, beNil, contain, and beGreaterThan, and it reports failures through XCTest, so they appear in the Xcode test navigator like any other assertion.
The re-evaluation detail matters in practice. Because the expression is evaluated again on each poll, anything with a side effect inside expect(...) runs repeatedly. Keep that expression a pure read of state — a property, a computed value, a flag — and start the work before the assertion, not inside it.
A worked example: waiting for a state machine to settle
Scenario: a downloader moves through states and you want to assert it reaches .finished. Run this inside a test target that links Nimble; the app target does not need the dependency.
import XCTest
import Nimble
final class DownloaderTests: XCTestCase {
func testReachesFinishedState() {
let downloader = Downloader()
downloader.start()
// Re-reads downloader.state on each poll until it matches or time runs out.
expect(downloader.state).toEventually(
equal(.finished),
timeout: 2.0,
pollInterval: 0.05
)
}
}
Two things to check before copying this. First, confirm the parameter labels and default values in your pinned Nimble version — the defaults are short (on the order of one second, with a small poll interval), and relying on them silently is how slow suites happen. Second, make sure Downloader.State conforms to Equatable, or equal will not compile.
Expected check: run the test once with the state machine working, then deliberately break the transition — comment out the line that assigns .finished — and confirm the test fails rather than hangs. A matcher that never fails is worse than no matcher. Nimble's failure output typically includes the last value it observed, which is usually enough to tell a wrong expectation from a slow one.
When the poll is the wrong tool
If the operation you are waiting on already has a completion callback, a delegate, or a notification, waiting for that event is cheaper and more precise than polling. The same test written against a callback:
func testUploadCompletes() {
let uploader = Uploader()
let finished = expectation(description: "upload finished")
uploader.onFinish = { finished.fulfill() }
uploader.start()
wait(for: [finished], timeout: 30)
}
This waits exactly as long as the operation takes and wakes when it finishes. A toEventually with a 30-second timeout would poll for the whole window, and every poll is a closure call plus a comparison on the test thread.
Trade-offs worth naming
- CI time compounds. A single
toEventuallywith a generous timeout only costs that time when it fails. A dozen of them, each waiting out a timeout on a bad day, adds up quickly. Set timeouts to the shortest value that is reliably long enough on your slowest machine, not the longest you can imagine. - Polling can mask ordering bugs. A test that passes because a value eventually became true does not prove it was true when the caller needed it. If ordering is what you are testing, assert on the event, not the end state.
- Custom matchers are a real API surface. Nimble lets you define reusable validation logic, but implementing the matcher protocol is more involved than writing a helper function. For one-off domain checks, a plain function or a
Predicate-style matcher is usually the better first step; promote it to a full matcher when several suites need it. - It is a dependency in the test target. That adds a little build time and a version to keep current. Small, but it belongs in the decision.
What to do next
- Find every
toEventuallyin the suite and check whether an explicit timeout and poll interval are set. Add them where they are not. - For each one, ask whether the code under test already signals completion. If it does, convert that test to an expectation and keep
toEventuallyfor genuinely poll-only state. - Confirm the expression inside
expect(...)has no side effects. - Run the suite and compare wall-clock time before and after. That number is the real verdict.
Limitation: the syntax above reflects the Nimble 10–12 era of the library. Async/await support and matcher protocol details have changed across releases, so verify against the README for the version in your Package.swift or Podfile before standardizing on it. If your test target leans heavily on Swift concurrency, treat that as a separate review item rather than assuming the polling matchers behave identically.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.