skip to content

Kotest's `eventually` can take a configuration object built with `eventuallyConfig { }` instead of a bare duration. Which knobs does it expose, and what happens when the polled block throws an exception that is not in the configured expected-exception set?

level: seniorimportance: should knowfreq 33%

answer

  1. eventuallyConfig { duration, initialDelay, interval, retries }
  2. expectedExceptions = whitelist, others rethrow now
  3. empty set = retry on everything (default)
  4. listener logs each attempt; shortCircuit aborts early
  5. AssertionError is an Error, not an Exception

basics

~20 s

You can set the total duration, an initial delay, the polling interval (fixed or backing off), a retry cap, a per-iteration listener and a short-circuit predicate. If expectedExceptions is set, only those are swallowed and retried — any other throwable fails the test immediately instead of being polled through.

solid answer

~60 s

`eventually` has an overload taking an `EventuallyConfiguration`, normally built with the `eventuallyConfig { }` builder. The useful knobs: - **`duration`** — the overall deadline. - **`initialDelay`** — wait before the *first* attempt, for work you know cannot have finished yet. - **`interval`** — the gap between attempts; Kotest supplies interval strategies such as a fixed interval (`200.milliseconds.fixed()`) and a Fibonacci backoff. - **`retries`** — a cap on attempt count, so the loop is bounded by attempts as well as time. - **`expectedExceptions`** / **`expectedExceptionsFn`** — which throwables count as "not ready yet". - **`listener`** — a callback per iteration, handy for logging what each attempt saw. - **`shortCircuit`** — abort early when a result proves the condition can never be met. The exception rule is the subtle one. By default the expected set is empty, meaning **everything** is retried. The moment you specify expected exceptions, the semantics flip to a whitelist: an exception outside the set propagates immediately and fails the test rather than being retried until the deadline. That is what turns a `NullPointerException` in your own polling code from a slow, confusing timeout into an instant, honest failure.

code

kotlin · 13 lines
kotlin
val serviceReady = eventuallyConfig {
    duration = 20.seconds
    initialDelay = 1.seconds
    interval = 500.milliseconds.fixed()
    retries = 30
    expectedExceptions = setOf(ConnectException::class, AssertionError::class)
}

test("service comes up") {
    eventually(serviceReady) {
        client.health().status shouldBe "UP"
    }
}

go deeper

for a junior

Know that eventually can take a config instead of a duration, and name interval and duration. The exception whitelist is a bonus at this level.

for a middle

Enumerate the main knobs and explain the interval/retries tradeoff for expensive polls. State that specifying expected exceptions makes unlisted throwables fail immediately.

for a senior

Lead with the diagnostic argument: default retry-everything hides test bugs as timeouts, and a narrow whitelist gives instant stack traces. Mention the AssertionError trap and shortCircuit on terminal states.

for a principal

Talk about shared configuration profiles as suite policy — one fast in-process profile, one network profile — so deadlines are centrally tunable and reviewable rather than scattered magic numbers.

## Why the bare-duration form is often not enough `eventually(5.seconds) { ... }` uses sensible defaults: poll fast, swallow every failure, give up at the deadline. Two of those defaults hurt in real suites. Polling fast is wrong when each attempt is an HTTP call or a database round trip — you generate load and noise. Swallowing every failure is wrong when the block can fail for reasons that will never fix themselves, because a genuine bug is then reported as "condition not met after 5s" instead of the actual stack trace. The configuration overload fixes both. ## The configuration surface ```kotlin val config = eventuallyConfig { duration = 10.seconds initialDelay = 500.milliseconds interval = 250.milliseconds.fixed() retries = 20 expectedExceptions = setOf(ConnectException::class) } eventually(config) { client.health() shouldBe "UP" } ``` **`duration`** is the wall-clock deadline for the whole loop. **`initialDelay`** delays the first attempt. Useful when you know the operation has a floor latency — polling three times in the first millisecond only produces noise in the listener log and load on the system under test. **`interval`** controls the gap between attempts. Kotest models this as an interval strategy rather than a plain duration, so besides a fixed gap you can use a growing one (a Fibonacci-style backoff) — fast polls early to keep a fast test fast, longer polls later to avoid hammering a slow dependency. **`retries`** bounds the number of attempts independently of time. With both set, whichever limit is hit first ends the loop. This matters when each attempt is expensive: a 30-second deadline with a 100ms interval is potentially 300 network calls. **`expectedExceptions`** (a set of `KClass`) and **`expectedExceptionsFn`** (a predicate over the throwable) declare what "not ready yet" looks like. **`listener`** is invoked per iteration with the attempt information, so you can log the intermediate state — invaluable when a poll fails only in CI. **`shortCircuit`** is a predicate that, when it returns true for a produced value, aborts the loop early. The use case is a terminal state: if you are polling a job until it reports `SUCCEEDED` and it reports `FAILED`, waiting out the remaining 29 seconds is pure waste, and short-circuiting turns it into an immediate, clear failure. ## The expected-exception semantics — the part interviewers probe By default `expectedExceptions` is empty and that is interpreted as "retry on anything". Any `Throwable` from the block — an `AssertionError` from a matcher, a `ConnectException`, or an `IllegalStateException` caused by a typo in the test — is captured and the loop continues. Once you populate the set, the rule becomes a strict whitelist: if the thrown exception is not an instance of one of the listed types, `eventually` does **not** retry. It rethrows, and the test fails at that instant with the original exception and its stack trace. The practical value is diagnostic. Consider polling a service that is still booting: `ConnectException` genuinely means "not ready". A `NullPointerException` inside your assertion block means your test is broken, and no amount of waiting will change that. With the default configuration both look identical in the output — a timeout after N attempts. With `expectedExceptions = setOf(ConnectException::class)` the first is retried and the second fails immediately with the real cause. That converts a 10-second mystery into a 10-millisecond stack trace. A caveat worth stating: matcher failures are `AssertionError`s, which are `Error`s, not `Exception`s. If your block's normal not-ready-yet signal is a failed matcher, the expected set must accommodate that (for example by including `AssertionError::class`) or you will lose the retry behaviour you wanted. Whenever you narrow the set, re-run the test against a deliberately not-yet-ready system and confirm it still polls rather than failing instantly. ## Reuse Because the configuration is a value, teams typically define one or two shared profiles — a fast in-process profile and a slower profile for network-facing checks — and reference them everywhere instead of scattering ad-hoc durations. That gives you a single place to widen deadlines when CI hardware changes, and it keeps the numbers reviewable. ## Related knobs `continually` has an analogous configuration builder for duration, initial delay and interval. And Kotest's `retry` is a different function for a different job — it re-runs a whole flaky *operation* rather than polling an assertion — so do not reach for `eventually` config when what you want is bounded retrying of an action.

  • Your block asserts with `shouldBe` and you set `expectedExceptions = setOf(ConnectException::class)`. What breaks?
    Matcher failures throw `AssertionError`, which is an `Error` and not a `ConnectException`, so it is outside the whitelist and gets rethrown on the first attempt. The `eventually` stops polling entirely and the test fails instantly. Either include `AssertionError::class` in the set or use `expectedExceptionsFn` with a predicate that covers both.
  • When would you set `retries` in addition to `duration`?
    When each attempt is expensive or observable — an HTTP call, a query, a container exec. A time-only bound with a short interval can mean hundreds of calls against the system under test, which distorts what you are measuring and can trip rate limits. A retry cap makes the worst-case cost of the assertion explicit and reviewable.

saying these in an interview costs you the question

  • Thinking `expectedExceptions` merely adds extra retryable types, rather than switching the loop to a strict whitelist.
  • Assuming an `AssertionError` from a matcher is covered by an exception whitelist that only lists `Exception` subtypes.
  • Setting a huge `duration` as flakiness insurance instead of narrowing what is polled.
  • Believing `initialDelay` is subtracted from the deadline budget in some special way rather than simply postponing the first attempt.
  • Using `eventually` config to retry a failing *action* — that is what Kotest's `retry` is for.

context