skip to content

A Kotest spec passes when you run a single test on its own but fails when the whole class runs, and the spec body holds a mutable list the tests add to. Explain the mechanism and the ways to fix it.

level: seniorimportance: must knowfreq 40%

answer

  1. SingleInstance default → one object, shared properties
  2. container bodies run once too — state shared by leaves
  3. fix ladder: local state → reset in beforeTest → isolation mode
  4. reassign rather than clear
  5. companions, singletons, DB rows survive any mode

basics

~20 s

Kotest's default SingleInstance mode constructs the spec once, so a mutable property in the spec body is one object shared by every test; earlier tests leave data behind. Fix by resetting it in beforeTest, keeping state local to each test, or switching isolation mode.

solid answer

~60 s

Under the default `IsolationMode.SingleInstance` Kotest creates the spec class once and runs every test against that instance. A `val items = mutableListOf<…>()` in the spec body is therefore **one list for the whole class**, and the same applies to state built inside a `context` block, whose body also runs once. Run one test and the list is empty; run the class and it carries whatever earlier tests appended — a classic order-dependent failure that also explains "passes locally, fails in CI" when execution order or filtering differs. Fixes, in the order I'd try them: 1. **Make state local** — declare it inside the test body. Best fix: nothing to leak. 2. **Reset it in `beforeTest`** — `beforeTest { items.clear() }`, or `lateinit var` reassigned per test. 3. **Change isolation** — `override fun isolationMode() = IsolationMode.InstancePerLeaf` (or `InstancePerRoot`) so each test gets a fresh instance; costs re-execution of the spec body and container path. And know the limit: no isolation mode resets `companion object` fields, singletons, or rows in a real database.

code

kotlin · 23 lines
kotlin
// Leaky: one list for the whole class under the default SingleInstance mode
class CartTestLeaky : FunSpec({
    val cart = mutableListOf<String>()
    test("one item")  { cart += "apple"; cart shouldHaveSize 1 }
    test("another")   { cart += "pear";  cart shouldHaveSize 1 } // fails: 2
})

// Fix A: rebuild per test
class CartTestFixed : FunSpec({
    lateinit var cart: MutableList<String>
    beforeTest { cart = mutableListOf() }
    test("one item")  { cart += "apple"; cart shouldHaveSize 1 }
    test("another")   { cart += "pear";  cart shouldHaveSize 1 }
})

// Fix B: fresh spec instance per leaf test
class CartTestIsolated : FunSpec({
    val cart = mutableListOf<String>()
    test("one item")  { cart += "apple"; cart shouldHaveSize 1 }
    test("another")   { cart += "pear";  cart shouldHaveSize 1 }
}) {
    override fun isolationMode() = IsolationMode.InstancePerLeaf
}

go deeper

for a junior

Say that the default mode reuses one spec instance so the list is shared, and that building it inside each test fixes it.

for a middle

Add the beforeTest rebuild and the isolation-mode option, and explain container bodies sharing state too.

for a senior

Diagnose it as order dependence, rank the fixes by cost, and state clearly what isolation mode cannot isolate.

for a principal

Turn it into policy: immutable spec-level declarations, randomised ordering in CI to expose order dependence, and no retry-on-flake culture.

## The mechanism A Kotest spec body is a lambda that runs once *per spec instance* to register tests. Anything you declare in it — `val queue = mutableListOf<String>()`, `var counter = 0`, a mock, a builder — is a property of that instance, created when the body runs. The default isolation mode is `SingleInstance`: Kotest constructs the class once and executes every test in it against that one object. So a mutable property is shared by all tests in declaration order. Test 1 appends two items; test 2 asserts `list shouldHaveSize 1` and sees three. In isolation test 2 passes; in the suite it does not. The same reasoning applies one level down in nested specs: a `context("…") { }` body also runs once under SingleInstance, so anything built inside a container is shared by all leaves beneath it — a subtler version of the same bug, because the state *looks* scoped to the group. ```kotlin class CartTest : FunSpec({ val cart = mutableListOf<String>() // one list for the whole class test("adding one item") { cart += "apple" cart shouldHaveSize 1 // passes } test("adding another item") { cart += "pear" cart shouldHaveSize 1 // fails: size is 2 } }) ``` ## Why it shows up as flakiness The failure is order-dependent, so anything that changes order or membership changes the outcome: running a single test from the IDE, a tag filter that removes the polluting test, a different spec ordering setting, or someone inserting a new test in the middle. Teams often mislabel this as flakiness and retry it away, which hides a genuine defect in the test design and sometimes in the code under test (a shared cache that should not be shared). ## The fixes, ranked ### 1. Don't share state at all Move the declaration into the test body. Each test then constructs exactly what it needs and nothing survives. For a small object graph this is the cheapest, clearest fix and it makes the test readable in isolation — a property good tests have anyway. ### 2. Rebuild or reset per test in a hook When construction is non-trivial, keep the declaration at spec level but rebuild it: ```kotlin lateinit var cart: MutableList<String> beforeTest { cart = mutableListOf() } ``` Reassigning is safer than mutating (`cart.clear()`), because reassignment cannot miss a field that a nested structure accumulated. Use `beforeEach` rather than `beforeTest` in a nested spec if you want the reset only for leaf tests. ### 3. Change the isolation mode ```kotlin override fun isolationMode() = IsolationMode.InstancePerLeaf ``` Now Kotest constructs a fresh spec instance per leaf test, re-running the spec body (and the container path down to that leaf), so every test sees a newly built list. It is the most sweeping fix and the most expensive: the body and containers re-execute per leaf, side effects inside container bodies are repeated, and the suite gets slower. Use it when a spec genuinely needs per-test freshness for many pieces of state, not as a reflex for one leaky list. On Kotest 6 `InstancePerRoot` is a cheaper middle ground when the pollution is between top-level branches rather than between siblings. ## The limit worth stating unprompted Isolation mode isolates the **spec object**. It does nothing about: - `companion object` / top-level / `object` state — per class or per process, not per instance; - singletons and static caches inside the code under test; - external systems: database rows, files, message queues, an in-memory server that keeps running; - global mock or stub registries. If a test still leaks after switching modes, the state lives in one of those places, and the answer is an explicit reset in a lifecycle hook, a fresh external resource per spec, or removing the global state from the design. ## Diagnosing it quickly Run the failing test alone — if it passes, you have order dependence. Then bisect: run the suspect pair together. Reading the spec for `var`/mutable collections declared outside test bodies usually finds the culprit in seconds. A useful discipline in review: spec-level declarations should be immutable and stateless (a service under test with no mutable fields, a pure fixture), and everything mutable should be created per test. ## What interviewers listen for Naming SingleInstance and explaining *why* one instance implies shared state; giving more than one fix and ranking them by cost; and the maturity signal — knowing that switching isolation mode does not clean up companions, singletons or the database.

  • Why prefer reassigning a lateinit fixture in beforeTest over calling clear() on it?
    Reassignment guarantees a fully fresh object graph, while `clear()` only resets the one field you remembered — nested collections, cached derived values or listeners registered on the old object survive. Rebuilding is also cheaper to reason about in review: the reader does not have to verify that every mutable member was reset.
  • You switch the spec to a fresh-instance isolation mode and the tests still interfere. What do you look for next?
    State that is not part of the spec instance: companion objects and top-level properties, singletons or static caches in the production code, and external systems such as the database, files or a running server. Isolation mode only re-constructs the spec class, so those must be reset explicitly in a hook, or the shared resource must be replaced per spec.
  • How would you stop this class of bug from recurring across the codebase?
    Make it a review rule that spec-level declarations are immutable and per-test mutable state is created inside the test or rebuilt in a hook. Run the suite in a randomised or reversed order periodically so order dependence surfaces early, and treat a test that only passes in one order as a defect rather than a flake to retry.

saying these in an interview costs you the question

  • Blaming flakiness or the CI machine instead of shared spec state
  • Believing each Kotest test gets a fresh spec instance by default
  • Assuming state built inside a container block is automatically scoped per leaf
  • Reaching straight for the finest isolation mode instead of removing the shared mutable state
  • Thinking a fresh-instance mode also resets companion objects or the database

context