skip to content

In Kotest's nested spec styles, what exactly happens when the body of a container block — a `describe`, a `context`, or a FreeSpec `-` group — runs, and why is putting setup code or assertions directly in that body a common source of bugs?

level: seniorimportance: must knowfreq 40%

answer

  1. Container = test node, body = executed registration code
  2. Statements interleave with child registration — ordering is textual
  3. Re-execution count decided by IsolationMode, not by the file
  4. Throw in a container ⇒ children never registered, subtree vanishes
  5. Fixtures → lifecycle callbacks; assertions → leaves only

basics

~20 s

A container is itself a test node whose body executes during the run to register and run its children — it is not a before-hook. Code in it runs inline, may re-execute per leaf depending on the isolation mode, and a throw there fails the container and prevents its unregistered children from ever running.

solid answer

~1 min

In Kotest a container (`describe`, `context`, a FreeSpec `-` group, a WordSpec `should` block) is **a node in the test tree, not a hook**. Its lambda executes as part of the run: as it runs, each registration call it makes declares a child, and children run as they are encountered. Three consequences bite in practice: 1. **Ordering.** Statements in the container body run *before* the children declared after them — but statements placed *after* a child's registration run after that child. Reading a container as "setup, then tests" is only true if you wrote it that way. 2. **Re-execution.** How many times a container body runs depends on the spec's `IsolationMode`. A fixture built inline in a container may be constructed once and shared across all descendant leaves, or rebuilt per leaf — the same code, different behaviour, purely from a configuration setting elsewhere. 3. **Failure semantics.** If the container body throws, the container is reported as failed and any children **not yet registered** simply never appear. A whole subtree can silently vanish from the report while the run stays green-ish, which is far worse than one failing test. The rule: put fixtures and side effects in Kotest's lifecycle callbacks, keep assertions in leaves, and let container bodies do nothing but declare structure.

code

kotlin · 19 lines
kotlin
// Misleading: reads like setup, behaves like inline registration code
class OrderTest : DescribeSpec({
    describe("the order service") {
        var counter = 0                 // shared across leaves in a single instance
        it("first") { counter++ }
        counter = 100                   // runs AFTER "first" has already executed
        it("second") { counter shouldBe 101 }
    }
})

// Honest: container declares structure, each leaf builds its own world
class OrderTestFixed : DescribeSpec({
    describe("the order service") {
        it("creates an order") {
            val service = OrderService(InMemoryOrderRepo())
            service.create(order).id shouldNotBe null
        }
    }
})

go deeper

for a junior

Know the core fact: the container body is code that runs during the test run to declare children — it is not a setup hook — so fixtures belong in lifecycle callbacks or inside the test.

for a middle

Add the interleaving of statements with registration and the fact that shared mutable state in a container body creates order-dependent tests.

for a senior

Cover all three consequences — ordering, isolation-mode-dependent re-execution, and subtree loss on a throw — and give the concrete remediation for each.

for a principal

Turn it into policy: containers declare structure only, fixtures have defined lifecycle ownership, expensive resources are spec-scoped with explicit cleanup, and CI watches test counts so vanished subtrees cannot pass unnoticed.

## Containers are tests, not hooks A developer arriving from annotation-driven frameworks reads ```kotlin describe("the order service") { val repo = InMemoryOrderRepo() // "setup" val service = OrderService(repo) it("creates an order") { ... } it("rejects a duplicate") { ... } } ``` as *"setup, then two tests"* — as if the first two lines were a before-each. They are not. In Kotest the `describe` block is itself a node in the test tree (a **container**), and its lambda is **executed code that runs during the test run**. When it runs, the two `val` initialisations execute, then each `it(...)` call **registers** a child, and the children execute. That is a materially different model from a hook, and every trap below follows from it. ## Trap 1 — ordering is textual, not phase-based Because the body is straight-line code interleaved with registrations, the position of a statement matters: ```kotlin context("a queue") { counter = 0 // runs before both leaves test("first") { counter++ } counter = 100 // runs AFTER "first" has run test("second") { counter shouldBe 101 } } ``` There is no phase separation that hoists all setup above all tests. Reviewers who assume a hook-like model misread such files routinely. ## Trap 2 — re-execution depends on isolation Kotest's `IsolationMode` decides how spec instances and container bodies relate to leaves. Under the default single-instance behaviour, a container body runs **once** and every descendant leaf shares whatever it created — so a mutable fixture accumulates state across leaves and tests become order-dependent. Under an isolation mode that gives each leaf its own instance, the path of containers leading to a leaf is **re-executed for every leaf**, so the fixture is fresh each time and the accumulation disappears. That is the nastiest property of inline container setup: **the same test file passes or fails depending on a setting declared somewhere else** — a spec property or a project-wide default. A test whose correctness depends on the isolation mode is not communicating its own requirements. Worse, re-execution also re-runs any *side effects* in the body: starting a container, opening a connection, seeding a database. What looked like a once-per-group cost silently becomes once-per-leaf. ## Trap 3 — a throwing container swallows a subtree Suppose the second line of the container body throws: ```kotlin context("with a seeded db") { val db = connect() // throws test("reads a row") { } // never registered test("writes a row") { } // never registered } ``` The container is reported as **failed**, and the two leaves are not "failed" — they never existed as far as the report is concerned. Counting tests across runs is the only way to notice. Compare that with a lifecycle callback failure, which Kotest attributes to the tests it guards, or with a leaf-level failure, which names exactly one test. Silent subtree loss is the reason "containers declare structure only" is a rule and not a preference. The same logic applies to **assertions** placed in a container body: a failed assertion there aborts registration of everything below it. An assertion belongs in a leaf, where it names one test and where its failure cannot delete siblings. ## Trap 4 — a container body that does real work distorts timings and hooks Because the container is a test node, its duration includes whatever you did inline. Slow work in a container shows up as a slow *container*, not as a slow test, which makes profiling a suite harder. And Kotest's lifecycle callbacks fire relative to the tree — a before-test callback does not run before the statements you inlined into the enclosing container body, because those statements are part of the container's own execution. ## What to do instead 1. **Fixtures go in lifecycle callbacks.** Kotest gives you callbacks that run around each test and around the spec; they have well-defined, isolation-independent semantics, and a failure inside one is attributed to the tests it guards rather than deleting them. 2. **Prefer per-leaf construction.** Where a fixture is cheap, build it inside the leaf. A test that constructs its own world is order-independent by construction and reads without reference to any configuration. 3. **State the isolation mode explicitly when it matters.** If a spec genuinely relies on fresh state per leaf, say so on the spec rather than inheriting a project default that someone may change. 4. **Keep container bodies to declarations.** The ideal container body contains registration calls and nothing else — perhaps a `val` holding an immutable test constant, which is safe because nothing mutates it. 5. **Be deliberate about expensive shared resources.** A single resource that must exist for a whole spec (a database container, a server) belongs in spec-level lifecycle handling with explicit cleanup, not in a container body where the isolation mode decides how many you start. ## The senior-level summary "A container is a test node whose body is executed registration code, not a before-hook. Statements run inline and interleave with child registration; the number of times they run is decided by the isolation mode, not by the file; and a throw there fails the container and erases its unregistered children from the report. So container bodies declare structure, fixtures live in lifecycle callbacks or in the leaf, and assertions live only in leaves."

  • Why is a test that only passes under one isolation mode a design problem, not just a configuration detail?
    Because the file no longer states its own requirements: its correctness depends on a setting declared on the spec or project-wide that another engineer can change without touching the test. The dependency usually comes from mutable state built in a container body and shared across leaves. Constructing state per leaf, or in a lifecycle callback with defined semantics, makes the test order-independent and mode-independent.
  • A CI run goes green but the total test count dropped by fourteen. How could a nested spec cause that?
    A container body threw before registering its children — for example a connection or fixture built inline failed. The container is reported as failed while the fourteen leaves below it were never registered at all, so they are absent rather than red. This is why registration-time work belongs in lifecycle callbacks, and why tracking test counts, not just pass/fail, is worth doing in CI.
  • Where should an expensive shared resource such as a database container live in a Kotest nested spec?
    In spec-level lifecycle handling with explicit startup and cleanup, not inline in a container body. Inline, the number of times it is created is decided by the isolation mode rather than by the code, so a mode change can multiply an expensive setup by the number of leaves — and a failure to start would delete the subtree from the report instead of failing the tests that need it.

saying these in an interview costs you the question

  • Calling a `describe`/`context` body a "before-each" or assuming it runs once before all its tests regardless of configuration
  • Believing all statements in a container body are hoisted above the child tests
  • Putting assertions in a container body and expecting the failure to be attributed to a specific test
  • Assuming children below a throwing container are reported as failed rather than missing
  • Sharing a mutable fixture across leaves via a container-body `var` and calling the resulting order-dependence flakiness

context