What does the kotest-runner-junit5 artifact actually contribute at test time, and how does a class like FunSpec end up being executed and reported inside a JUnit Platform run?
answer
- runner artifact = Kotest's TestEngine implementation
- discovery finds spec classes, not annotated methods
- nested tests registered dynamically as lambdas execute
- execution, isolation, coroutines, filtering = all Kotest's
- engine emits start/finish events; platform consumers render them
basics
~20 sIt contains Kotest's JUnit Platform TestEngine implementation. The platform discovers that engine alongside any others, hands it the discovery request, and the engine finds Kotest spec classes, runs their tests with Kotest's own coroutine-based executor, and reports each test case back to the platform as it goes.
solid answer
~50 s`kotest-runner-junit5` is the adapter artifact: it supplies Kotest's implementation of the JUnit Platform's `TestEngine` contract, registered so the platform's engine lookup finds it on the test classpath. Without it, Kotest's assertions and DSL still compile, but nothing on the platform knows how to *run* a spec. With it present, a platform run hands the engine a discovery request; the engine scans for classes that are Kotest specs (subclasses of the spec styles) rather than for annotated methods, and produces a descriptor for each spec. Execution then belongs entirely to Kotest: its own executor instantiates the spec, evaluates the DSL lambdas, applies Kotest's isolation mode, coroutine context, timeouts, extensions and tag filters, and emits start/finish events back to the platform for each test case. Because nested tests only exist once an outer lambda has run, Kotest registers many descriptors **dynamically** during execution — which is why nested tests appear in an IDE tree as the run progresses rather than being fully listed up front.
code
kotlin · 10 linesclass OrderSpec : FunSpec({
// discovered: the class itself, because it is a concrete Kotest spec
context("an unpaid order") {
// registered dynamically once the context lambda runs
test("cannot ship") { }
listOf("EU", "US").forEach { region ->
test("is rejected in $region") { } // names only exist at run time
}
}
})go deeper
Say that the runner artifact supplies Kotest's engine and that without it specs simply do not execute.
Describe discovery by spec class versus annotated method, and that execution and lifecycle belong to Kotest while results are reported back to the platform.
Explain dynamic registration of nested tests and use the model to diagnose "my specs don't run" from the Kotest side.
Frame the boundary: the platform owns the reporting vocabulary and Kotest owns everything about execution, so tooling interoperates while all Kotest-specific configuration stays on Kotest's own surfaces.
## The adapter, not the framework Kotest is split into pieces: the assertions library, the framework/DSL that defines spec styles and the test model, property testing, extensions — and a **runner**. On the JVM, `kotest-runner-junit5` is the runner that plugs Kotest into the JUnit Platform by implementing the platform's `TestEngine` contract. It must be on the test runtime classpath (typically declared as a test dependency) for specs to execute at all; the DSL and matchers compile happily without it, so a project missing it produces code that builds and tests that never run. The engine is registered through the platform's standard service-discovery mechanism, which is why nothing else needs configuring on the Kotest side once the artifact is present: the platform simply finds one more engine. ## Discovery: classes, not annotated methods When a run begins, the platform passes a discovery request to every engine it found. Kotest's engine looks for **Kotest spec classes** — concrete classes extending one of the spec styles (`FunSpec`, `StringSpec`, `DescribeSpec`, `BehaviorSpec` and the rest). That is a fundamentally different discovery rule from method-annotation scanning: your unit of discovery is a class, and the tests inside it are values produced by running Kotlin code, not methods enumerable by reflection. This has a consequence people meet quickly: a spec's tests are not fully knowable without executing the spec's initialisation. `FunSpec({ … })` builds its test list by running the lambda; a `context` block's children only exist once that context executes. So Kotest reports the spec as a container up front and registers the individual test cases **dynamically** as they come into existence. In an IDE tree, nested tests therefore populate progressively during a run rather than appearing as a complete static list beforehand. ## Execution belongs to Kotest Once a spec is selected, the platform is essentially a spectator. Kotest's own executor owns: - **instantiation and isolation** — how many times the spec class is instantiated and how state is reset between tests, per the spec's isolation mode; - **coroutines** — every test body is a suspending function, run in a coroutine scope the framework controls, which is what allows suspending assertions and timeouts to work naturally; - **lifecycle callbacks and extensions** — `beforeTest`, `afterSpec`, registered extensions, and project-level configuration; - **filtering** — Kotest tag expressions and its own filters decide which tests run; - **failure handling** — assertion failures are collected or thrown by Kotest's own assertion pipeline before being reported. What the engine sends back to the platform is a stream of lifecycle events: this container started, this test was registered, this test finished with success/failure/skipped, plus the throwable. That stream is what every platform-aware consumer — the IDE test view, the build's report writer, a CI report — renders. This is why Kotest results appear in the same test tree and the same reports as any other engine's, without Kotest knowing anything about those consumers. ## What this buys and what it constrains **Buys:** ordinary tooling works. Anything that can run the JUnit Platform can run Kotest specs, and results land in the standard reports. **Constrains:** the platform's vocabulary is the only shared vocabulary. Anything Kotest-specific — isolation modes, tag expressions, coroutine and timeout configuration, extension registration — is configured through Kotest's own surfaces (spec-level settings, project configuration, Kotest system properties), not through platform-level configuration. And because the engine is separate, another engine's annotations and extensions have no meaning inside specs. ## The practical diagnostic value When someone reports "my specs don't run", the Kotest-side questions follow directly from the model above: - Is the runner artifact actually on the test classpath? Without the engine there is nothing to execute specs. - Is the class a concrete spec subclass? Abstract base specs are not run directly; shared setup living in an abstract parent is expected, but only concrete subclasses are discovered. - Is the spec or test disabled by Kotest's own mechanisms — `@Ignored`, `enabled = false`, an `x`-prefix, a `!` prefix, a focused `f:` test elsewhere in the spec, or an active tag filter? - Are you looking for a nested test in a pre-run static list? It will not be there until the containing block executes. Each of those is a Kotest-model question, not a platform question — which is exactly the distinction a good answer draws.
- Why can't a Kotest spec's full list of tests be produced without running the spec?Because tests are created by executing Kotlin code: the spec body is a lambda, and nested blocks register their children only when the enclosing block runs. Test names can be computed from loops or variables, so they may not exist as literals anywhere. Kotest therefore reports the spec as a container and registers individual test cases dynamically as execution reaches them.
- Kotest assertions and specs compile fine but no tests execute. What does that tell you about the runner artifact?That the framework and assertion libraries are present but Kotest's engine is not on the test runtime classpath, so nothing on the platform knows how to execute a spec. The DSL is ordinary Kotlin and compiles without a runner; only the runner artifact supplies the TestEngine implementation that discovers and runs spec classes.
saying these in an interview costs you the question
- Thinking kotest-runner-junit5 makes Kotest run inside the Jupiter engine
- Assuming discovery scans for annotated methods rather than spec classes
- Expecting a complete static list of nested tests before the run starts
- Believing Kotest's isolation, coroutine or tag settings are configured through platform-level configuration
- Assuming the platform, not Kotest, controls spec instantiation and lifecycle