skip to content

What do Kotest's withClue and asClue add to a failing assertion, and what happens when several of them are nested around the same matcher?

level: middleimportance: should knowfreq 38%

answer

  1. clue = ambient context prepended to the failure message
  2. scoped to a region, not one assertion
  3. asClue = receiver object is the clue, via toString()
  4. nested clues stack, outermost first
  5. captured per collected failure inside assertSoftly

basics

~20 s

They attach context to any failure raised inside their block: the clue text is prepended to the matcher's own message. Nested clues stack — every active clue appears, outermost first — and asClue is the form that uses the receiver object itself as the clue.

solid answer

~60 s

A matcher message tells you *what* differed (`expected:<3> but was:<4>`); it cannot tell you *which case* you were on. `withClue("user=$id") { ... }` pushes a clue onto Kotest's clue context for the duration of the block, and any failure raised inside — by any matcher, not just the next one — is reported with that clue prepended to the matcher's own message. `withClue` returns the block's value, so it wraps expressions cleanly. `x.asClue { ... }` is the receiver-flavoured form: the object itself becomes the clue (its `toString()` is used) and is passed into the block, so `order.asClue { it.total shouldBe 100 }` reports the whole order on failure. Nesting is additive: clues stack, and all currently active clues appear in the failure, outermost first, then the matcher message. Clues are lazily rendered — the `toString()` cost is only paid when something actually fails — and they compose with `assertSoftly`: each collected failure keeps the clue context that was active when it was recorded, which is what makes a soft loop over a collection readable.

code

kotlin · 8 lines
kotlin
assertSoftly {
    users.forEach { user ->
        withClue("user=${user.id}") {
            user.email shouldContain "@"
            user.age shouldBeGreaterThan 0
        }
    }
}

go deeper

for a junior

Know that withClue adds context to failure messages and that asClue uses the object itself as the context.

for a middle

Explain region scoping, the return value, lazy toString() rendering, and that nested clues stack outermost-first.

for a senior

Combine clues with soft assertions so a loop yields one report line per element, and set conventions about what belongs in a clue.

for a principal

Treat failure diagnostics as a design surface: identifiers in clues, data-class toString() as the default clue, and test names carrying the fixture so clues stay about the varying input.

## The problem clues solve Matcher messages are precise about values and silent about context. In a loop, a data-driven block, or a helper function called from several tests, `expected:<200> but was:<404>` leaves you guessing which input produced it. The usual workarounds are bad: adding the context into the expected value (breaks the comparison), or writing a custom message per assertion (verbose and duplicated). Kotest's answer is a **clue context** — an ambient stack of clues that failures pick up automatically. ## withClue ```kotlin withClue("userId=$id") { response.status shouldBe 200 response.body shouldNotBe null } ``` Semantics: - the clue is pushed for the duration of the block and popped afterwards, including when the block throws; - **any** failure raised inside the block — from any matcher, at any nesting depth, including inside functions the block calls — is reported with the clue prepended to the matcher's message; - the clue parameter is `Any?`, and it is rendered via `toString()` **only when a failure occurs**, so an expensive rendering costs nothing on the happy path; - `withClue` returns the block's value, so you can wrap an expression and keep using its result. The scoping point is what people miss: a clue is not attached to one assertion, it is attached to a *region*. Wrapping a helper call in `withClue` annotates every assertion the helper makes. ## asClue ```kotlin order.asClue { it.total shouldBe 100 it.lines shouldHaveSize 3 } ``` `asClue` is an extension on the value: the receiver becomes the clue (rendered with `toString()` on failure) and is also passed to the block. It is the natural form when the useful context *is* the object under test — a data class, whose generated `toString()` prints every field, gives you the entire object in the failure message for free. Like `withClue`, it returns the block's value. Use `withClue` when the context is a computed label ("case 3", "tenant=acme"); use `asClue` when the context is the object itself. ## Nesting and layering Clues stack. With ```kotlin withClue("tenant=acme") { withClue("order=42") { order.total shouldBe 100 } } ``` the failure carries **both** clues, outermost first, followed by the matcher's message. Nothing is overwritten: an inner clue narrows the context rather than replacing it. Because the stack is scoped to the block, leaving an inner block removes only that layer. That layering is the reason clues scale: a spec-level `withClue` for the fixture, a loop-level clue for the current element, and an inner clue for the sub-case all appear together, giving a breadcrumb trail from the outside in. ## Composition with soft assertions Clues and `assertSoftly` are designed to work together. When a matcher fails inside a soft block, the failure is recorded **with the clue context active at that moment**. So: ```kotlin assertSoftly { users.forEach { u -> withClue("user=${u.id}") { u.email shouldContain "@" } } } ``` produces one aggregate error in which every line names its own user. Without the clue you would get a list of identical-looking failures; without soft mode you would learn about one user per run. Together they turn a loop into a table of results. ## Practical guidance - Put the **identifying** information in the clue (ids, keys, iteration index), not a restatement of the assertion — "total should be 100" adds nothing the matcher does not already print. - Prefer `asClue` on data classes; the generated `toString()` is usually the best possible clue. - Keep clue construction cheap in the common path; rendering is lazy but building the argument is not, so avoid heavy string interpolation of large structures if the block is hot. - Clues do not change pass/fail behaviour at all. They are purely diagnostic — a failing test fails identically with or without them. - Don't reach for clues where a better test name would do. If every assertion in a spec needs the same clue, the fixture probably belongs in the test name or in separate tests. ## Interview framing The expected answer covers: what a clue is (ambient context prepended to failure messages), the region scoping, the `asClue` receiver form, the stacking behaviour when nested, and the soft-assertion interaction. Mentioning laziness and "purely diagnostic, never changes pass/fail" marks a candidate who has actually read the mechanism rather than copied a snippet.

  • If a clue is set around a block, does it apply to assertions made inside a helper function that the block calls?
    Yes. The clue is pushed onto an ambient context for the duration of the block, so any failure raised while that block is executing — including inside nested calls — picks it up. That is what makes `withClue` useful around shared verification helpers: you annotate the region, not an individual matcher.
  • Do clues cost anything when the test passes?
    Effectively nothing beyond building the argument you pass in: the clue is rendered via `toString()` only when a failure is reported. Still avoid interpolating huge structures eagerly in hot loops, since constructing the argument itself happens on every iteration even though rendering does not.

saying these in an interview costs you the question

  • Thinking a clue replaces the matcher's own message rather than being prepended to it
  • Believing an inner clue overwrites the outer one instead of stacking
  • Assuming clues affect whether an assertion passes or fails
  • Expecting a clue to apply to assertions made after the block has exited
  • Restating the assertion in the clue instead of naming the case or the input

context