Skip to main content

Async Assertions

When the action under test completes asynchronously — a message lands on a queue, a status flips after a callback — a plain then() races the system. The assertion mixins provide two polling variants:

MethodSemantics
thenEventually(...) / andEventually(...)Re-runs the collector + assertion until it passes, or the timeout expires (fails with the last assertion error)
thenContinually(...)Re-runs the collector + assertion for the whole duration and fails the moment it stops passing — "this must keep being true"

Both render in the report as first-class sentence keywords (Then eventually …), and every assertion mixin (WithKotest, WithAssertJ, WithHamcrest, WithHamkrest) provides them.

Defaults: 10-second timeout, 25 ms poll interval, no initial delay.

Kotest

import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds

@Test
fun `order is eventually confirmed`() {
whenever { orderService.placeOrder() }

// polls the collector until the block passes (default: 10s timeout, 25ms interval)
thenEventually(theOrderStatus()) { this shouldBe "CONFIRMED" }
}

@Test
fun `order is eventually confirmed with custom timeout`() {
whenever { orderService.placeOrder() }

thenEventually(30.seconds, theOrderStatus()) { this shouldBe "CONFIRMED" }
andEventually(theOrderReference(), startWith("ORD-"))
}

Timing is fully tunable via named parameters:

thenEventually(
initialDelay = 500.milliseconds,
duration = 30.seconds,
interval = 100.milliseconds,
collector = theOrderStatus(),
match = startWith("CONF")
)

And the inverse — assert a condition keeps holding:

@Test
fun `order is never cancelled`() {
whenever { orderService.placeOrder() }

// asserts the condition holds for the whole duration
thenContinually(5.seconds, theOrderStatus()) { this shouldBe "CONFIRMED" }
}

Both accept either a matcher (Matcher<T>) or an assertion block (T.() -> Unit), just like then().

AssertJ (Java)

WithAssertJ provides thenEventually with the same collector-plus-assertion shape as its then:

import java.time.temporal.ChronoUnit;

@Test
void orderIsEventuallyConfirmed() {
whenever(ctx -> orderService.placeOrder());

// polls the collector until the assertion passes (default: 10 seconds)
thenEventually(theOrderStatus(), status -> assertThat(status).isEqualTo("CONFIRMED"));
}

@Test
void orderIsEventuallyConfirmedWithCustomTimeout() {
whenever(ctx -> orderService.placeOrder());

thenEventually(30L, ChronoUnit.SECONDS, theOrderStatus(),
status -> assertThat(status).isEqualTo("CONFIRMED"));
}

Hamcrest / HamKrest

WithHamcrest and WithHamkrest provide thenEventually and thenContinually taking their respective Matcher<T> types, with the same optional leading Duration:

thenEventually(theOrderStatus(), equalTo("CONFIRMED"))
thenContinually(5.seconds, theOrderStatus(), equalTo("CONFIRMED"))

Multiple assertions in one window

Chaining andEventually waits for each assertion in turn — the timeouts add up, and each assertion samples the system at a different moment. When several things should all become true within the same window, pass a block instead:

// all assertions poll in parallel inside one shared 10s window
thenEventually(10.seconds) {
then(theOrderStatus(), be("CONFIRMED"))
and(theOrderReference()) { this should startWith("ORD-") }
}
  • All assertions poll in parallel — the total wait is the slowest assertion, never the sum.
  • Each assertion must pass at some point inside the window. Once it passes it stops polling ("locks in"), so a later regression of an already-passed assertion is not detected.
  • Collectors inside one block run concurrently: if they touch shared mutable state, it must be thread-safe.
  • If exactly one assertion never passes, its failure is thrown directly. If several never pass, they are aggregated — first failure as the cause, the rest attached as suppressed exceptions so no stack trace is lost. For example, a block with three assertions where two never pass:
2 of 3 assertions did not pass within 10s:
[2] expected: a value that is equal to "CONFIRMED" ...
[3] expected: a value that is equal to "ORD-1" ...

The per-assertion lines above come from the hamkrest bridge — each carries that assertion's own last failure message, so kotest users see kotest-style messages instead.

The thenContinually block is the inverse: every assertion must hold on every tick for the whole duration, polled in parallel, and the block fails immediately when any assertion stops passing:

// every assertion must hold on every tick for the whole duration
thenContinually(5.seconds) {
then(theOrderStatus(), be("CONFIRMED"))
and(theOrderReference()) { this should startWith("ORD-") }
}

Matcher, assertion-block and ThenSpec forms are all usable inside the braces, and timing is configured at block level only: the full initialDelay / duration / interval triple on thenEventually, duration alone on thenContinually. The duration argument is optional on both block forms — omit it and the page's defaults apply (10-second timeout, 25 ms interval, no initial delay). The block forms are available on WithKotest and WithHamkrest (the lambda-friendly mixins) — not on WithAssertJ or WithHamcrest.

In the report

The keywords are recognised by the sentence renderer, so

thenEventually(theOrderStatus()) { this shouldBe "CONFIRMED" }

renders as Then eventually the order status … — the report reads as the requirement ("the order is eventually confirmed") rather than as polling mechanics.

A multi-assertion block renders as a head sentence for the thenEventually/thenContinually clause, with each nested then/and appearing as its own indented line underneath. For example, a block asserting two things within a 2-second window renders as:

Then eventually 2 seconds
then the first value equal to "first"
and the second value equal to "second"

Note that the nested then/and lines are plain text, not styled report keywords — only the top-level Then/And that begin a BDD step get keyword styling. The inner then(...)/and(...) calls inside a block are report content, not top-level steps, so they read as part of the sentence fragment rather than as bolded keywords.