skip to content

You own a large Kotlin test suite. How would you decide where Kotest's assertSoftly aggregation and withClue context are used, versus letting assertions fail fast?

level: principalimportance: nice to knowfreq 26%

answer

  1. one defect → one report line
  2. fail fast on preconditions, aggregate independent fields
  3. soft mode is not an exception shield
  4. clue anything ambiguous about its input
  5. encode the convention in shared helpers

basics

~20 s

Aggregate independent observations of one state (fields of a response, elements of a collection) and fail fast on preconditions and dependent assertions. Add clues wherever a failure could come from more than one input. Judge by whether one defect produces one report line.

solid answer

~60 s

My rule is: **one defect should produce one readable report line**. - **Fail fast** on preconditions and dependent chains — "result is non-null", "list is non-empty", "the call succeeded". Aggregating these turns one real defect into a cascade of derived failures, and since a plain exception aborts a soft block anyway, a null-dereference after a collected null check truncates the report regardless. - **Aggregate** independent observations of the same state: the fields of a DTO, the headers of a response, per-element checks in a loop. Here fail-fast costs a full red-green cycle per wrong field. - **Clue** anything whose failure is ambiguous about its input: loops, table-driven cases, shared verification helpers. `asClue` on a data class gives the whole object for free. The common shape is a fail-fast guard, then `assertSoftly` with `withClue` inside the loop. I'd write this down as a convention rather than leave it to taste, because the failure mode of over-aggregating — noisy cascades people learn to skim — is worse than the failure mode of under-aggregating.

code

kotlin · 13 lines
kotlin
val result = service.load(id)
result.shouldNotBeNull()

assertSoftly(result) {
    status shouldBe Status.ACTIVE
    balance shouldBe 100
}

assertSoftly {
    result.lines.forEach { line ->
        withClue("line=${line.id}") { line.amount shouldBeGreaterThan 0 }
    }
}

go deeper

for a junior

Recall the two tools and the simple rule: aggregate independent field checks, fail fast on preconditions.

for a middle

Give the canonical shape — guard, soft block for fields, clue inside loops — and explain why dependent assertions should not be aggregated.

for a senior

Ground the policy in the mechanism (collector-routed failures only, exceptions abort, clues stack and are captured per failure) and describe reviewing for cascades.

for a principal

Treat diagnostics as a designed property of the suite, encode the convention in shared helpers so it holds without vigilance, and state the tradeoff both ways rather than issuing a blanket rule.

## Framing: diagnostics are a product Assertions decide pass/fail; the *report* decides how long a failure takes to fix. On a suite of thousands of tests run by people who did not write them, report quality is a real engineering concern. Kotest gives two knobs for it — `assertSoftly` (aggregate several failures into one `MultiAssertionError`) and `withClue`/`asClue` (attach context to whatever fails) — and both are easy to over- or under-apply. The target property: **one defect produces one report line, and that line names the input**. ## Where aggregation pays Soft assertions pay when the assertions are **independent observations of the same already-computed state**: - field-by-field checks of a response, DTO, or projection; - header/metadata checks on an HTTP response; - per-element checks across a collection; - multi-column checks in a data-driven case. In all of these, fail-fast means one wrong field per run. If a serialisation change breaks six fields, that is six edit-run cycles. Aggregation turns it into one. ## Where aggregation hurts It hurts when assertions are **dependent** — when a later assertion is only meaningful if an earlier one held: ```kotlin result shouldNotBe null result!!.items shouldHaveSize 3 result.items[0].name shouldBe "Ada" ``` Aggregate this and a single null result yields a collected failure plus an NPE that aborts the block anyway — you get a confusing partial report instead of a clean "result was null". The mechanism reinforces the guidance: `assertSoftly` collects only failures routed through Kotest's error collector; ordinary exceptions escape and truncate the block. So soft mode is not an exception shield, and a chain that can blow up mid-way should be guarded before the soft block, not inside it. The second cost is cognitive. A cascade of five derived failures for one root cause trains engineers to skim aggregate reports, which destroys the value of aggregation in the places it genuinely helps. ## Where clues pay A clue is worth adding whenever a failure message alone cannot identify **which input** produced it: - inside `forEach`/`map` loops over test data; - inside shared verification helpers called from many tests; - around table-driven or parameterised cases; - wherever a fixture varies (tenant, locale, feature flag). `withClue("user=${u.id}") { ... }` for computed labels; `x.asClue { ... }` when the object itself is the context — for data classes, the generated `toString()` is usually the best clue available, at zero authoring cost. Clues stack when nested, so a fixture-level clue and a loop-level clue both show up, outermost first. Clues are lazily rendered, so the happy path pays only for building the argument. Where clues are *not* worth it: when the test name already carries the context. If every assertion in a spec needs the same clue, the clue belongs in the test name, or the spec should be split. ## The canonical shape ```kotlin val result = service.load(id) result.shouldNotBeNull() // fail fast: precondition assertSoftly(result) { // aggregate: independent fields status shouldBe Status.ACTIVE balance shouldBe 100 tags shouldContainExactly listOf("a", "b") } assertSoftly { result.lines.forEach { line -> withClue("line=${line.id}") { line.amount shouldBeGreaterThan 0 } } } ``` Precondition first, aggregation for the flat field checks, clues inside the loop. ## Rolling it out A convention only sticks if it is cheap to follow: 1. **Write it down** in the testing guide with the two rules above and one worked example. 2. **Encode it in helpers.** A shared `verifyOrder(expected)` that already wraps `assertSoftly` + `asClue` gives correct behaviour by default; nobody has to remember. 3. **Review for cascades.** When a review shows one defect producing many failure lines, that is the signal to move a precondition out of the soft block. 4. **Resist blanket application.** Wrapping every test body in `assertSoftly` by reflex is the anti-pattern: it changes nothing for single-assertion tests and produces cascades for dependent ones. 5. **Keep assertion blocks side-effect free.** Because a soft block keeps running after a failure, side-effecting assertions execute against state you have just proved wrong. ## What a strong answer sounds like It is explicitly a tradeoff answer: aggregation buys fewer cycles and costs cascade noise; clues buy identification and cost a little authoring effort. It grounds the recommendation in the mechanism — soft mode collects only collector-routed failures, plain exceptions abort, clues stack and are captured per collected failure — rather than in preference. And it ends with how the convention is enforced, because in a large suite the failure mode is inconsistency, not ignorance.

  • Someone proposes wrapping every test body in assertSoftly by default. What is your response?
    I'd push back. For single-assertion tests it changes nothing, and for dependent chains it produces cascades — one root cause reported as several derived failures — which teaches people to skim aggregate reports. It also does not protect against exceptions, since a non-assertion throwable escapes and truncates the block, so the blanket rule buys less than it appears to. Aggregate deliberately, around independent observations.
  • How do you keep the convention consistent across a large team?
    Encode it rather than document-and-hope: shared verification helpers that already wrap `assertSoftly` and `asClue` make the right shape the default, so most tests inherit it without anyone deciding. Keep a short written rule for the cases helpers do not cover, and use code review on failure output — if one defect produces a cascade of report lines, a precondition needs to move out of the soft block.

saying these in an interview costs you the question

  • Treating assertSoftly as a way to make a test resilient to exceptions
  • Wrapping every test body in soft assertions as a blanket policy
  • Putting null/emptiness preconditions inside the soft block and then debugging the resulting NPE truncation
  • Restating the assertion in the clue instead of naming the varying input
  • Assuming clues or soft mode can change whether a test passes

context