skip to content

A teammate's Kotest BehaviorSpec does the setup and all the assertions inside the `when` block and leaves the `then` blocks nearly empty. What actually goes wrong at runtime?

level: seniorimportance: should knowfreq 25%

answer

  1. container body executes and registers children
  2. throw in container = later leaves never registered
  3. missing tests, not failing tests
  4. failure attributed to the When node
  5. empty then = leaf that always passes

basics

~20 s

Containers register children by executing their body, so a failed assertion in a when block aborts before its then blocks are registered: those tests silently disappear from the report instead of failing, and the single failure is attributed to the container, losing per-outcome granularity.

solid answer

~50 s

In Kotest a container is executed, and its body registers the children as it runs. Assertions in a when block therefore run before the then blocks exist. Three things follow. First, on failure the exception aborts the container body, so any then blocks declared after that point are never registered - they do not show as failed or skipped, they are absent, and the suite's test count silently shrinks. Second, the failure is reported against the When node, so the report says the action broke rather than which expectation broke, and several independent outcomes collapse into one node. Third, the leaf is the unit the engine names, counts, filters and reports on, so empty then blocks give you nodes that always pass regardless of behaviour - green tests that assert nothing. The fix is mechanical: keep arrange and act in the container, capture the result into a local, and put each expectation in its own then.

code

kotlin · 8 lines
kotlin
Given("a paid order") {
    val order = order(total = 1000, paid = true)
    When("it is refunded") {
        val result = refunds.refund(order)   // act once, capture
        Then("the balance is returned") { result.balance shouldBe 1000 }
        Then("the order is marked refunded") { result.status shouldBe REFUNDED }
    }
}

go deeper

for a junior

Say assertions go in then; setup and the action go in given/when.

for a middle

Explain that container bodies execute and register children, so a container failure prevents later leaves from existing.

for a senior

Diagnose the symptom - a shrinking test count and failures always attributed to When nodes - and prescribe capture-in-container, assert-in-leaf.

for a principal

Treat leaf granularity as a reporting contract with CI: one expectation per leaf keeps failure attribution, flaky-test tracking and test counts meaningful.

## Why this is a runtime issue, not a style opinion Kotest builds a spec's tree by executing code. Running a container means invoking its lambda; each nested builder call inside that lambda registers a child node. Nothing about the tree is known before the body runs. That single fact explains every consequence below. ## Consequence 1: unregistered children disappear If an assertion inside a when block fails, it throws. The container body unwinds at that point, so every then declared after it is never registered. The engine cannot report a node it never saw, so those tests do not appear as failed and do not appear as skipped - they are simply missing. The run shows one failure and a smaller total than yesterday. Teams that track test counts notice; teams that only look at red or green do not, and coverage quietly erodes as more assertions migrate upward. ## Consequence 2: attribution and granularity The report names the failing node. With assertions in the container, the failing node is 'When: the order is refunded', which tells you the action broke but not which expected outcome broke. Splitting expectations into separate then leaves gives one node per outcome, so a report distinguishes 'the balance returns' failing from 'the receipt is sent' failing, and a partially broken behaviour shows several red leaves in one run rather than stopping at the first throw inside a shared container. ## Consequence 3: leaves are the unit of everything else The leaf is what the engine counts, names, filters by name, retries and times out on. Empty then blocks are leaves that pass unconditionally: they inflate the green count and encode no expectation at all. Anyone reading the report sees three passing outcomes when the suite actually checks nothing at that level. ## Consequence 4: repeated work A container body runs once per execution of that container. Depending on the spec's isolation configuration a container may be executed more than once across the run, so heavy setup written inline in a when is heavy setup paid repeatedly. Putting the act step in the container is correct; putting an expensive fixture build there without thought is a performance decision worth making consciously. ## What good placement looks like - given: build the world. Values the whole subtree needs. - when: perform the action once, capture its result (or the thrown exception) into a local the leaves can read. - then: one expectation each, reading the captured result. No mutation of shared state, because sibling leaves may observe it depending on isolation configuration. Capturing a thrown exception in the container and asserting on it in separate then blocks is the idiomatic way to keep 'the action throws' and 'the message says X' as distinct, independently reported outcomes. ## How to spot it in review Grep for matcher calls that are not inside a then, watch for then blocks with empty or comment-only bodies, and be suspicious when a BDD file's failure output only ever names When nodes. A shrinking total test count between commits is the strongest signal that assertions have crept into containers.

  • Where should you assert that the action threw an exception?
    Capture it in the container - assign the result of a shouldThrow call in the when block to a local - and then assert on that captured exception inside separate then leaves. That keeps 'it throws' and 'the message or field says X' as independently named, independently reported outcomes instead of one collapsed container failure.
  • Is it ever legitimate for a container body to contain a check?
    A precondition guard can be defensible - failing fast when the fixture itself is wrong. But be explicit that it is a fixture assertion, not an expectation of the behaviour under test, and accept that it aborts the whole subtree by design. Anything describing expected behaviour belongs in a leaf.

saying these in an interview costs you the question

  • Claiming the then blocks would still be reported as skipped after a container failure
  • Treating leaf-versus-container placement as pure style with no runtime effect
  • Empty then blocks used as documentation while assertions live above
  • Mutating shared state in a then and assuming siblings are unaffected
  • Assuming a failing container still reports each expected outcome separately

context