skip to content

Spec Styles

Kotest ships ten interchangeable spec base classes that shape how tests are declared, named, and nested. Interviewers ask you to compare styles and justify a choice because it reveals whether you understand the framework's test-registration model rather than one memorized syntax.

on this pageshow

explore

questions

16

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

open as a page

How do you temporarily disable a whole Given block or a single Scenario in a Kotest BDD spec, and what happens to the blocks nested inside it?

level: juniorimportance: should knowfreq 30%

basics

~20 s

Prefix the block with x: xgiven, xwhen, xthen, xand, xfeature, xscenario. Kotest marks that node disabled and never executes its body, so nested blocks are never registered and never appear individually - only the disabled node is reported as ignored.

open as a page

Kotest test names are free-text strings rather than method identifiers. How are those names supplied, and where do they end up mattering?

level: juniorimportance: should knowfreq 35%

basics

~20 s

The name is the string argument you pass to the DSL builder, evaluated when the spec registers its tests. It becomes the test's identity: shown in IDE and CI reports, used for name-based filtering, and it must be unique within its scope.

open as a page

Kotest's ShouldSpec registers tests with `should("...") { }`. What does that call actually create, and how do you group several such tests inside one ShouldSpec?

level: juniorimportance: should knowfreq 30%

basics

~20 s

should("...") { } registers one leaf test whose reported name reads as "should ...". Grouping is done with context("...") { } containers, which may hold should leaves and further nested contexts. should also works at the spec root.

open as a page

Kotest's WordSpec builds tests out of `should` and `When` blocks. What does a WordSpec declaration look like, how are the reported names assembled from it, and how deeply can it nest?

level: juniorimportance: should knowfreq 28%

basics

~20 s

You write "a stack" should { "pop returns the last item" { ... } }. The should block is a container whose name renders as "a stack should"; the inner strings are leaf tests. An optional outer When block adds one scenario layer. Nesting is fixed: When → should → test.

open as a page

In Kotest's BehaviorSpec, which blocks are containers and which are the actual leaf tests, and what is an `and` block for?

level: middleimportance: should knowfreq 35%

basics

~20 s

given and when are containers: their bodies only register children. then blocks are the leaf tests that hold assertions and get counted in reports. An and block adds one more grouping level inside a given or a when.

open as a page

What is Kotest's AnnotationSpec, and why is it normally recommended as a migration step rather than a long-term style choice?

level: middleimportance: should knowfreq 30%

basics

~20 s

AnnotationSpec lets you write annotation-driven test classes - methods marked @Test, with @BeforeEach/@AfterEach/@BeforeAll/@AfterAll and @Ignore - using Kotest's own annotations. It exists so annotation-style suites can move onto the Kotest runtime with minimal edits; it gives up free-text names and nesting.

open as a page

When picking a Kotest spec style, how does going flat versus nested change a test's identity and the scoping of setup code?

level: middleimportance: should knowfreq 30%

basics

~20 s

Flat styles give each test a single-segment name and no place to scope setup other than the spec itself. Nested styles make the identity the whole container path, so container renames ripple to every child, and setup written in a container body is scoped to that subtree.

open as a page

In Kotest's StringSpec you write a test as a string literal immediately followed by a lambda — `"returns the length" { ... }`. What Kotlin mechanism makes that compile, and what structural limits does it place on a StringSpec file?

level: middleimportance: should knowfreq 35%

basics

~20 s

Inside the StringSpec body, Kotest brings an operator fun String.invoke(...) extension into scope, so "name" { } is really "name".invoke { }. Every registration is a root-level leaf: StringSpec has no containers, so no grouping or nesting is possible.

open as a page

In Kotest's FreeSpec, `"a stack" - { ... }` declares a group while `"pops the last item" { ... }` declares a test. What is the `-` actually doing there, and what nesting rules follow from it?

level: middleimportance: should knowfreq 32%

basics

~20 s

The - is a scoped operator fun String.minus(...) extension that registers a container; a bare string with a trailing lambda registers a leaf test via String.invoke. Containers may nest arbitrarily deep and hold both kinds of node; leaves are terminal.

open as a page

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%

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.

open as a page

How do you attach per-test configuration — a timeout, repeated invocations, or disabling a single case — to one individual test in Kotest's flat spec styles, and how does the syntax differ between StringSpec and FunSpec?

level: seniorimportance: should knowfreq 30%

basics

~20 s

Both use a .config(...) builder, but it hangs off different things: in StringSpec it chains off the name string — "name".config(timeout = 2.seconds) { } — and in FunSpec off the registration call — test("name").config(invocations = 5) { }. AnnotationSpec has no equivalent.

open as a page

How does Kotest's FeatureSpec structure a test file, and how does its nesting differ from Kotest's BehaviorSpec?

level: middleimportance: nice to knowfreq 25%

basics

~20 s

FeatureSpec uses feature(...) containers holding scenario(...) leaf tests, with names rendered as Feature: / Scenario:. Features may nest inside features for arbitrary depth, whereas BehaviorSpec has a fixed given/when/then vocabulary. Disabled variants are xfeature and xscenario.

open as a page

Kotest's AnnotationSpec lets you write tests as annotated methods instead of a DSL lambda. How does Kotest discover and name those tests, which lifecycle annotations does it provide, and what do you give up compared with Kotest's lambda-based flat styles?

level: middleimportance: nice to knowfreq 25%

basics

~20 s

You extend AnnotationSpec() and mark methods with Kotest's own @Test; Kotest reflects over the class and each annotated method becomes a root-level test named after the method. It provides @BeforeEach/@AfterEach/@BeforeAll/@AfterAll, @Ignore, and @Test(expected = ...). You lose string test names, containers and the .config DSL.

open as a page

Kotest's ExpectSpec uses `context("...") { }` and `expect("...") { }`. What does each of those register, where may each appear, and how do the reported names come out?

level: middleimportance: nice to knowfreq 20%

basics

~20 s

context("...") registers a container that may hold expects and further contexts, nesting freely; expect("...") registers a terminal leaf test. Both are legal at the spec root. The leaf reads as "expect <your string>", so the string is phrased as the expectation.

open as a page

Do Kotest's different spec styles behave differently at runtime, or are they only syntax? What actually differs when you pick one?

level: seniorimportance: nice to knowfreq 22%

basics

~20 s

They are different DSLs over the same engine: every style compiles to the same container/leaf test tree, with the same matchers, extensions, configuration and reporting. What genuinely differs is the vocabulary, the shapes you can express (nesting or not), and how displayed names are derived.

open as a page