skip to content

Kotest's assertion library ships `eventually`, `until` and `continually` for testing asynchronous behaviour. What does each one assert, and how do their pass and fail conditions differ?

level: middleimportance: must knowfreq 45%

answer

  1. eventually = first success wins
  2. continually = first failure loses
  3. until = boolean predicate flavour
  4. eventually returns the block's value
  5. continually always burns its full window

basics

~20 s

eventually re-runs a block until it stops throwing, within a deadline — the first success passes. until does the same for a boolean predicate becoming true. continually requires the block to keep passing for the whole duration — the first failure fails the test.

solid answer

~50 s

All three come from Kotest's nondeterministic assertions and are suspend functions, so they work directly in a Kotest test body. - **`eventually(duration) { ... }`** polls a block that contains assertions. Failures (thrown `AssertionError`s or exceptions) are swallowed and retried until either the block completes normally — which passes immediately and returns the block's value — or the deadline expires, at which point the test fails with the accumulated attempt information. - **`until(duration) { boolean }`** is the same polling loop over a boolean predicate: it succeeds the first time the predicate returns `true`. - **`continually(duration) { ... }`** inverts the condition: the block must succeed on *every* invocation for the full duration. The first failure aborts and fails the test; surviving the whole window passes. So `eventually`/`until` prove "this becomes true", `continually` proves "this never stops being true" — for example that a value does *not* change after an event.

code

kotlin · 20 lines
kotlin
class OutboxTest : FunSpec({
    test("message is projected into the read model") {
        publisher.publish(OrderPlaced(orderId))

        val row = eventually(5.seconds) {
            readModel.findOrder(orderId).shouldNotBeNull()
        }
        row.status shouldBe "PLACED"
    }

    test("a duplicate message is not projected twice") {
        publisher.publish(OrderPlaced(orderId))
        eventually(5.seconds) { readModel.findOrder(orderId).shouldNotBeNull() }

        publisher.publish(OrderPlaced(orderId))
        continually(2.seconds) {
            readModel.countOrders(orderId) shouldBe 1
        }
    }
})

go deeper

for a junior

Know the one-line contract of each: eventually retries until success, continually requires continuous success, until polls a boolean. Be able to write a simple eventually(5.seconds) { ... }.

for a middle

Explain first-success-wins vs first-failure-loses, that eventually returns the block's value, and why the trigger goes outside the block. Name the negative-assertion case that only continually covers.

for a senior

Discuss diagnostics (why matcher-based eventually beats boolean until), idempotency of the polled block, and the cost model — continually always spends its full window while eventually usually finishes early.

for a principal

Frame it as a suite-wide policy: polling is a fallback for genuine asynchrony, deterministic signals are preferred, and deadline budgets must be set so CI degrades visibly rather than silently doubling in duration.

## The problem these solve A lot of real behaviour is not observable the instant you trigger it: a message is consumed by a background listener, a cache invalidation propagates, a file is flushed, an HTTP server finishes starting. A plain assertion right after the trigger is a race — it passes on a fast machine and fails on a loaded CI box. Sprinkling `Thread.sleep(2000)` "fixes" it by making every run slow *and* still flaky, because the sleep is a guess. Kotest's answer is a small family of polling helpers in its assertions library. They are all `suspend` functions, which matters: Kotest test bodies are themselves suspending, so you call them directly, and the waiting between attempts is a coroutine `delay` rather than a blocked thread. ## `eventually` — "this becomes true before the deadline" ```kotlin eventually(5.seconds) { repo.findById(id).shouldNotBeNull() } ``` Semantics: run the block. If it returns normally, `eventually` returns that value immediately and the assertion is satisfied. If it throws — an `AssertionError` from a matcher, or any other exception, since by default all throwables are treated as retryable — the error is captured, the coroutine delays for the configured interval, and the block runs again. When the deadline passes without a success, `eventually` throws, reporting how many attempts ran, how long it took, and the errors it saw. Two consequences people miss. First, **the block must be idempotent and re-runnable**: it will execute many times, so putting the *trigger* (posting the message, calling the endpoint) inside `eventually` means you trigger it repeatedly. Put the trigger outside and only the observation inside. Second, **the first success wins** — `eventually` proves nothing about what happens afterwards. ## `until` — the boolean flavour ```kotlin until(5.seconds) { queue.isEmpty() } ``` `until` polls a predicate and succeeds when it first returns `true`. It is essentially `eventually` wrapped around a truth check, and the practical difference is diagnostics: a failing `until` can only say "the predicate never became true", whereas a failing `eventually` carries the matcher's message ("expected 3 but was 1"), which is far more useful in a CI log. Prefer `eventually` with real matchers unless the condition genuinely is a bare boolean. ## `continually` — "this stays true for the whole window" ```kotlin continually(2.seconds) { account.balance shouldBe 100 } ``` `continually` repeatedly invokes the block for the given duration and requires every invocation to pass. The first failure ends it and fails the test; if the window elapses with no failure, it passes. This is the assertion for *negative* propositions that `eventually` cannot express. "After I publish this event, the read model must **not** change" is unprovable with `eventually` — a block asserting no-change passes on its very first attempt, before anything could have happened. `continually` holds the assertion open long enough to catch a late, wrong write. Classic uses: a duplicate message is not reprocessed, a circuit breaker stays open for its cooldown, a debounced action does not fire early. The cost is honest: `continually(2.seconds)` always spends the full two seconds. Unlike `eventually`, it cannot finish early, so keep its windows short and its uses few. ## Choosing between them - Something must *appear* or *converge* → `eventually`. - Something must *not appear* or must *stay stable* → `continually`. - A bare boolean flag flips and you do not care about the failure message → `until`. ## Failure output and tuning When `eventually` gives up, its message includes the elapsed time, the number of attempts and the failures observed, so a red build tells you whether the condition never got close or was one poll away. That is what makes it debuggable, unlike a bare sleep. Both `eventually` and `continually` accept a configuration object as an alternative to a bare duration, letting you set the polling interval, an initial delay before the first attempt, a retry cap, and which exception types are treated as retryable. Defaults poll aggressively, which is usually right for in-process assertions and too aggressive for network calls. ## Pitfalls - Wrapping an entire scenario in `eventually` — it re-runs side effects and hides which step is slow. Poll the narrowest observable. - Using `eventually` where the code under test can be made deterministic (an injected clock, a completion signal, an awaited future). Polling is a fallback for genuine asynchrony, not a substitute for design. - Long deadlines as flakiness insurance: a 60-second `eventually` turns one broken assumption into a minute of dead CI time per run. - Assuming a passing `eventually` means the state is stable. It means it was true once.

  • Why can't you express "nothing happens" with `eventually`?
    `eventually` stops at the first successful invocation. A block asserting that a value is unchanged succeeds on attempt one, microseconds after the trigger, long before a late or erroneous write could occur — so it proves nothing. `continually` keeps re-checking for the whole window, so a wrong write arriving 800ms later still fails the test.
  • What should and should not go inside the `eventually` block?
    Only the observation: the read, the query, the matcher. The action that starts the asynchronous work belongs before the call, because the block is invoked repeatedly — a trigger inside it would publish the message or issue the command once per poll, corrupting the very state you are asserting on. The block should be idempotent and side-effect free.
  • Why prefer `eventually` with matchers over `until` with a boolean?
    Diagnostics. A failed `until` can only report that the predicate never became true. A failed `eventually` propagates the matcher's own message — the expected and actual values — plus attempt count and elapsed time, which is usually the difference between debugging from the CI log and having to reproduce locally.

eventually is watching for the kettle to boil — you stop the moment it does. continually is standing guard for a fixed shift — you only fail if something goes wrong before your shift ends.

saying these in an interview costs you the question

  • Believing `continually` stops early on success — it always runs the full duration and only stops early on failure.
  • Putting the triggering action inside the `eventually` block, so it fires once per poll.
  • Treating `eventually` as proof that a condition is stable rather than that it held once.
  • Reaching for `eventually` when the asynchrony could be made deterministic with an injected clock or an awaited completion signal.
  • Claiming these are blocking sleeps — they are suspend functions that `delay` between attempts.

context