skip to content

When a Kotest `eventually` block finally succeeds, what does the call give back, and what does the failure output contain when it never succeeds? How does that shape the way you write the polled block?

level: middleimportance: should knowfreq 26%

answer

  1. eventually returns the block's value
  2. bind it, assert details outside the loop
  3. failure = elapsed + attempts + collected errors
  4. matchers in the block > bare booleans
  5. listener in eventuallyConfig for per-attempt logs

basics

~20 s

eventually returns the value produced by the successful invocation, so you can bind the resolved object and assert further on it outside the loop. On timeout it fails with the elapsed time, the attempt count and the failures it saw — which is why matcher-based blocks debug far better than boolean ones.

solid answer

~50 s

`eventually` is not a `Unit` function. The value of the last — successful — invocation of the block is returned, so the idiomatic shape is: ```kotlin val order = eventually(5.seconds) { repo.findOrder(id).shouldNotBeNull() } order.status shouldBe Status.PLACED ``` That keeps the polling loop minimal (wait for the row to exist) and moves the real assertions outside it, where they fail fast and report properly instead of being retried for five seconds. On failure, `eventually` throws with a diagnostic summary: how long it ran, how many attempts it made, and the failure information it collected — including the underlying matcher messages. This is the practical reason to poll with matchers rather than a bare boolean: a timeout that says "expected PLACED but was PENDING after 24 attempts in 5.01s" tells you the pipeline is slow, whereas "predicate never true" tells you nothing. For per-attempt visibility you can also attach a `listener` through `eventuallyConfig`, which is invoked on each iteration.

code

kotlin · 8 lines
kotlin
// Poll only for visibility; the value comes back non-null.
val order = eventually(5.seconds) {
    repo.findOrder(orderId).shouldNotBeNull()
}

// Real assertions fail fast, with clean messages.
order.status shouldBe Status.PLACED
order.total shouldBe Money(42)

go deeper

for a junior

Know that eventually gives back the block's value and that you should assign it. Prefer matchers inside the block.

for a middle

Explain the narrow-loop pattern — poll for visibility, bind, assert outside — and what the timeout message contains (elapsed, attempts, collected failures).

for a senior

Argue the diagnostics case: CI logs must explain failures without reproduction, which rules out boolean polling and try/catch inside the block. Mention the config listener for per-attempt tracing.

for a principal

Treat polled-assertion legibility as a suite property — reviewers should reject blocks that swallow messages or poll whole scenarios, because those are the tests nobody can debug at 3am.

## `eventually` returns a value The most-missed detail of Kotest's `eventually` is its return type: it is generic in the block's result, and the value of the successful invocation comes back to the caller. That single fact changes how good polling code is written. The naive shape puts everything inside: ```kotlin eventually(5.seconds) { val order = repo.findOrder(id).shouldNotBeNull() order.status shouldBe Status.PLACED order.total shouldBe Money(42) } ``` Every assertion in there is now subject to retrying. If `total` is genuinely wrong — a real bug — the test still spends the whole five seconds re-checking it before failing, and the message competes with the other assertions in the block for attention. The better shape narrows the loop to the *existence/visibility* condition and lifts the rest out: ```kotlin val order = eventually(5.seconds) { repo.findOrder(id).shouldNotBeNull() } order.status shouldBe Status.PLACED order.total shouldBe Money(42) ``` Now the wait covers exactly the asynchronous part, a genuine value bug fails in microseconds with a clean message, and the test reads as "wait for the thing, then assert about the thing". Note why `shouldNotBeNull()` is the right last expression: Kotest's null-check matchers return the smart-cast, non-null value, so the block's result type is the non-nullable type and the binding outside the loop needs no `!!`. ## What the failure says When the deadline passes without a successful invocation, `eventually` throws an assertion failure that summarises the attempt: the elapsed time, the number of iterations performed, and the failures observed while polling. Because a matcher failure carries its own message, that message ends up in the report. The operational consequence is a preference ordering for what you put in the block: 1. **Matchers** — best. The timeout message contains expected-vs-actual, so a CI log alone often explains the failure. 2. **A boolean with `until`** — worst for diagnostics. "Never became true" is all you get; you cannot tell whether the value was one off or wildly wrong, or whether the subsystem was even up. This is why experienced Kotest users almost always reach for `eventually` with matchers rather than `until`, even when the condition is naturally boolean — `queue.size shouldBe 0` beats `queue.isEmpty()` purely on what the failure prints. ## Per-attempt visibility Sometimes the summary is not enough — a poll that fails only on CI, where you cannot attach a debugger. `eventuallyConfig` accepts a `listener`, invoked on each iteration, which lets you log the observed state per attempt. That turns an opaque timeout into a trace showing whether the value was converging, oscillating, or never moving at all. ## Interaction with clues The block is ordinary Kotest assertion code, so anything that enriches a matcher message enriches the timeout message too — for example wrapping the assertion in a clue so the failure names the entity being polled. Keep the clue text stable across iterations; it describes the target, not the attempt. ## Common shapes that waste the diagnostic - **Swallowing inside the block.** A `try/catch` around the assertion that returns a boolean throws away exactly the message `eventually` would have printed. - **Triggering inside the block.** Besides corrupting state, it means the failure output describes the last of N triggered attempts, not one coherent observation. - **Ignoring the return value and re-querying afterwards.** `eventually { repo.find(id).shouldNotBeNull() }` followed by a fresh `repo.find(id)!!` outside is both a second round trip and a fresh race — the row could, in principle, be gone or changed. Bind what the loop already proved. - **Overlong deadlines.** The report will faithfully tell you it tried for sixty seconds; that is a lot of CI time to learn something a five-second budget would have told you. ## Summary Treat `eventually` as an expression, not a statement. Poll the smallest observable, take the value it hands back, and assert the details outside the loop where failures are immediate and legible.

  • Why is `shouldNotBeNull()` a good final expression inside an `eventually` block?
    It is both the wait condition and the value producer: Kotest's null-check matcher returns the smart-cast, non-null value, so `eventually` hands the caller a non-nullable object. You get the polling semantics and a usable binding in one line, with no `!!` afterwards and no second query outside the loop.
  • What is wrong with putting all of a test's assertions inside the `eventually` block?
    Every assertion becomes retryable, so a genuine value bug is re-checked until the deadline instead of failing instantly, and the timeout message mixes several unrelated failures. Poll only the condition that is actually asynchronous — usually existence or visibility — and assert the details on the returned value outside the loop.

saying these in an interview costs you the question

  • Treating `eventually` as returning `Unit` and re-querying the system after the loop.
  • Catching exceptions inside the block and returning a boolean, destroying the matcher message the timeout would have shown.
  • Putting every assertion in the block so real failures take the full deadline to surface.
  • Believing the timeout message only says "timed out" with no attempt or timing detail.
  • Using `until` with a boolean where a matcher would have produced a self-explaining failure.

context