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?
answer
- one defect → one report line
- fail fast on preconditions, aggregate independent fields
- soft mode is not an exception shield
- clue anything ambiguous about its input
- encode the convention in shared helpers
basics
~20 sAggregate 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 sMy 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 linesval 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
Recall the two tools and the simple rule: aggregate independent field checks, fail fast on preconditions.
Give the canonical shape — guard, soft block for fields, clue inside loops — and explain why dependent assertions should not be aggregated.
Ground the policy in the mechanism (collector-routed failures only, exceptions abort, clues stack and are captured per failure) and describe reviewing for cascades.
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