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?
answer
- `-` → scoped operator fun String.minus = container
- bare string + lambda → String.invoke = leaf
- Containers nest arbitrarily; leaves are terminal (compile-time)
- No keyword injected — path is exactly your strings
- Identity is the full path; duplicates only collide within a scope
basics
~20 sThe - 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.
solid answer
~60 sFreeSpec gives you **two scoped operator extensions on String**, and the punctuation chooses between them: - **`"name" - { ... }`** — Kotlin's `minus` operator convention resolves to a scoped `operator fun String.minus(block: ...)` that registers a **container**. Its body scope again offers both operators, so containers nest to any depth. - **`"name" { ... }`** — the `invoke` convention registers a **leaf test**. A leaf's body scope offers no registration functions, so a leaf is terminal and an accidental nesting attempt is a compile error. Because containers are unconstrained, FreeSpec is the least opinionated nested style: no keyword is contributed to the name (unlike styles that append `should` or prefix `expect`), so the reported path is exactly your strings joined along the tree. A leaf's identity is its **full path**, so the same leaf name may appear under different parents without colliding. The cost of that freedom is discipline: nothing stops a six-level tree, and nothing enforces a house naming convention — the dash is the only structural signal a reader gets.
code
kotlin · 10 linesclass StackTest : FreeSpec({
"a stack" - {
"when empty" - {
"throws on pop" { shouldThrow<NoSuchElementException> { stack.pop() } }
"reports size zero" { stack.size shouldBe 0 }
}
"is serialisable" { } // leaf beside a container
}
"a top-level smoke test" { } // leaf at the spec root
})go deeper
Recall the rule: dash means group, no dash means test; groups can nest, tests cannot.
Explain the two operator conventions and scoped String extensions, and that the leaf scope's lack of registration functions makes the constraint a compile error.
Add path-based identity and duplicate-name behaviour, that container bodies are executed code rather than hooks, and the review discipline the style needs.
Weigh minimal syntax against absent guardrails: depth conventions, phrasing consistency, and whether a suite is better served by a style that supplies its own words.
## The mechanism Kotlin's **operator conventions** map punctuation to specially named functions: `a - b` compiles to `a.minus(b)` when a suitable `operator fun minus` is in scope, and `a(b)` compiles to `a.invoke(b)` when a suitable `operator fun invoke` is in scope. Combine that with trailing-lambda syntax — the lambda moves outside the parentheses and, as the only argument, drops them entirely — and both FreeSpec forms are plain Kotlin: ```text "a stack" - { ... } ==> "a stack".minus({ ... }) "pops" { ... } ==> "pops".invoke({ ... }) ``` Kotest declares both extensions on the **DSL receiver** that the spec body runs against, so they exist only inside a FreeSpec (and its containers), never in ordinary code. ## Containers versus leaves Kotest's test tree has two node kinds and FreeSpec maps one operator to each: - A **container** (the `-` form) declares children and contributes a name segment. It has no assertions of its own. Its body scope exposes *both* operators again, which is exactly why nesting is unbounded. - A **leaf** (the bare-invoke form) is the terminal test: it holds the assertions and is what a report counts as pass or fail. Its body scope is the *execution* scope, which does **not** expose the registration extensions, so writing a nested `"x" { }` inside a leaf does not compile. A container may mix leaves and further containers freely at the same level. ```kotlin class StackTest : FreeSpec({ "a stack" - { "when empty" - { "throws on pop" { } "reports size zero" { } } "when holding items" - { "pops in LIFO order" { } } "is serialisable" { } // leaf directly under a container } "a top-level smoke test" { } // leaf at the spec root }) ``` ## Derived names Unlike styles that inject a keyword into the rendered name, FreeSpec contributes **nothing**: the displayed path is exactly the strings you wrote, joined along the tree from the spec down to the leaf. That has two practical effects. First, **readability is entirely on you**. Nothing forces the path to read as a sentence; the common convention is to make container strings noun phrases or condition phrases ("when empty") and leaves behaviour phrases, so the path still reads like prose. Second, **a leaf's identity is its full path**, not its bare name. Two different containers may each hold a leaf called `"returns null"` without colliding, because their paths differ. Within a *single* scope, duplicate names do collide, and Kotest's `DuplicateTestNameMode` (`Warn`, `Silent`, `Error`) governs what happens — it can be set per spec or project-wide. ## Registration is executed code The spec body and every container body are ordinary lambdas, so control flow works: a loop inside a container registers one child per iteration, with interpolated names. Two caveats follow. Each generated name must be distinct within its scope, and a container body is **not a lifecycle hook** — code you place at the top of a `-` block runs at that point in the execution, and whether it re-runs for each descendant leaf depends on the spec's isolation mode. Shared setup belongs in Kotest's lifecycle callbacks, not in a container body. All bodies are **suspending**, so leaves and containers may call suspend functions directly. ## The trade FreeSpec makes FreeSpec is the most syntactically minimal of the container-bearing styles: one character of punctuation is the entire difference between a group and a test. Teams that like it cite exactly that — no `describe(`/`it(` scaffolding, no framework-supplied words, arbitrary structure that mirrors the domain. The complaints are the mirror image: 1. **The dash is easy to miss.** Deleting or forgetting it silently changes a container into a leaf (or fails to compile, if the body registers children). Reviewers scanning quickly can misread the structure. 2. **No guardrails on depth.** Because nesting is unbounded and unnamed by keywords, a FreeSpec can grow into a deep tree whose leaves only make sense with the full path in view. Most teams that adopt it also adopt a convention capping depth at two or three levels. 3. **No enforced grammar.** Styles that supply words nudge everyone into the same phrasing; FreeSpec does not, so consistency has to come from review. ## Answering well Say the mechanism (`minus` and `invoke` operator conventions on scoped String extensions), state the rule that containers nest arbitrarily while leaves are terminal and enforce that at **compile time**, note that names are joined with nothing added and that a leaf's identity is its full path, and finish with the honest trade — maximum freedom, minimum guardrails.
- What happens if you forget the `-` on a FreeSpec block that contains other tests?The string-plus-lambda form registers a leaf test instead of a container, and the registration calls you wrote inside it no longer compile, because a leaf's body scope is the execution scope and does not expose the container or leaf extensions. If the block happened to contain only assertions, it silently becomes one test whose name is the group heading — which is the quieter and more dangerous version of the mistake.
- Can two FreeSpec leaves in the same spec share a name?Yes, provided they sit under different containers, because a leaf's identity is its full path from the spec down through every container. Within one scope, identical names collide, and Kotest's `DuplicateTestNameMode` — `Warn`, `Silent` or `Error`, settable per spec or project-wide — decides whether that warns, is ignored, or fails the run.
saying these in an interview costs you the question
- Describing the `-` as syntax Kotest special-cases rather than Kotlin's `minus` operator convention
- Claiming leaf tests can contain nested tests in FreeSpec
- Expecting FreeSpec to inject a keyword such as "should" into the reported name
- Assuming any duplicate leaf name in a spec is a collision, ignoring the full-path identity
- Treating a container body as a setup hook that runs once before its children