skip to content

How does Kotest construct spec instances and run the registration lambda, and what is the default isolation mode across spec styles?

level: seniorimportance: should knowfreq 35%

answer

  1. Lambda/init runs once to register the test tree
  2. Default isolation = SingleInstance (one instance shared)
  3. Spec-level vals = shared mutable state across tests
  4. InstancePerLeaf / InstancePerRoot for fresh instances
  5. beforeTest/beforeEach reset state; style-independent

basics

~20 s

Kotest builds your spec, runs the lambda/init block once to register all tests, then runs them. By default one spec instance is reused for every test in that class, so shared state leaks unless you change isolation mode.

solid answer

~40 s

Regardless of style, a Kotest spec is a class. Kotest instantiates it and executes the constructor lambda (FunSpec/DescribeSpec) or `init { }` block (StringSpec/BehaviorSpec) to **register** the test tree; tests then execute. The crucial gotcha: the default `IsolationMode` is **SingleInstance** — one spec instance is created and **shared by all tests in the class**, so fields/`val`s declared at spec level are shared mutable state across tests, and registration-block code runs once. To get a fresh instance per top-level test (JUnit-like isolation), set `isolationMode = IsolationMode.InstancePerLeaf` (or the legacy per-test/per-root modes), or rely on lifecycle callbacks (`beforeTest`, `beforeEach`) to reset state. This is independent of which style you chose — all four styles share the same instance/isolation semantics.

code

kotlin · 11 lines
kotlin
import io.kotest.core.spec.style.FunSpec
import io.kotest.core.spec.IsolationMode
import io.kotest.matchers.shouldBe

class StateTest : FunSpec({
    // default SingleInstance would share `seen` across tests
    isolationMode = IsolationMode.InstancePerLeaf
    val seen = mutableListOf<String>()
    test("first") { seen.add("x"); seen.size shouldBe 1 }
    test("second") { seen.add("y"); seen.size shouldBe 1 }
})

go deeper

for a junior

Knows tests are written inside the spec class and run after it's built.

for a middle

Understands the registration-then-execute flow and that init/lambda runs once.

for a senior

Explains SingleInstance default, the shared-state gotcha, and uses IsolationMode/lifecycle to control it.

for a principal

Sets isolation policy for the codebase, weighing test speed vs determinism and preventing flaky shared-state bugs.

## Construction and registration Every Kotest spec is an ordinary Kotlin class extending a spec base. The test-defining code runs **at construction time**: - **FunSpec / DescribeSpec** — you pass a lambda to the constructor; that lambda runs to register the test tree. - **StringSpec / BehaviorSpec / WordSpec** — you write the DSL inside an `init { }` block. During this phase Kotest only **collects** the test definitions (a tree of containers and leaves); it does not run the test bodies yet. After registration, the engine executes the leaf tests. ## Default isolation: SingleInstance The key cross-cutting behavior is **IsolationMode**, and the default is `IsolationMode.SingleInstance`: - **One** instance of the spec class is created. - **All** tests in that class run against that single instance. - Therefore any property declared at the spec/class level is **shared mutable state** across tests, and the registration block runs exactly once. This surprises people coming from JUnit, where a new test-class instance is created per test method. ## Changing isolation Set the mode to recreate the spec for fresh state: ```kotlin import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.IsolationMode import io.kotest.matchers.shouldBe class IsolatedTest : FunSpec({ isolationMode = IsolationMode.InstancePerLeaf // fresh instance per leaf test var counter = 0 test("a") { counter++; counter shouldBe 1 } test("b") { counter++; counter shouldBe 1 } // also 1, because instance is recreated }) ``` - `SingleInstance` (default): one instance, shared state. - `InstancePerLeaf`: a new spec instance per leaf test (and re-runs containing blocks to reach it). - `InstancePerRoot`: a new instance per top-level (root) test. ## Resetting state instead If you keep SingleInstance, reset shared state with lifecycle callbacks: `beforeTest { }` / `beforeEach { }` run before each test, `afterTest { }` after. These exist in every style identically. ## Style-independence None of this depends on the chosen style — isolation, construction, and lifecycle are shared engine features. The style only changes the registration DSL.

  • Why might a counter declared at spec level unexpectedly carry over between tests?
    Because the default SingleInstance isolation reuses one spec instance, so the field is shared mutable state across all tests.
  • How do you get JUnit-like fresh-state-per-test behavior?
    Set isolationMode to InstancePerLeaf (or per-root), or reset state in a beforeTest/beforeEach callback.

SingleInstance is one shared whiteboard the whole class writes on; InstancePerLeaf hands each test a fresh whiteboard.

saying these in an interview costs you the question

  • Assuming each test gets a fresh spec instance by default like JUnit
  • Not realizing the registration lambda runs only once
  • Believing isolation behavior differs by spec style
  • Mutating shared spec-level state without resetting it

context