Hundreds of Kotest specs need one shared piece of infrastructure — say a single database. How would you use Kotest's listener and extension surface to own its lifecycle, and what tradeoffs do you weigh?
answer
- cost multiplier: project 1x, class Nx, instance NxM
- ProjectListener owns run-scoped resources
- lazy ensureStarted + afterProject stop
- sharing shifts cost to the cleanup contract
- afterProject never runs on kill -9
basics
~20 sGive it exactly one owner: a ProjectListener (beforeProject/afterProject) registered once in project config, started lazily on first use, with per-test cleanup left to cheaper hooks. Per-spec hooks and autoClose multiply the cost by spec count and by isolation mode.
solid answer
~60 s**Own it at project scope.** A `ProjectListener` registered in `AbstractProjectConfig.extensions()` (or the config's own `beforeProject`/`afterProject`) starts it once and stops it once, regardless of how many specs run or how many instances each produces. Per-spec `beforeSpec` or `autoClose` multiplies the cost by spec count *and* by isolation mode — a per-leaf spec starts one per test. **Then decide four things.** 1. *Eager or lazy* — starting in `beforeProject` costs the startup on every run, including one that runs a single unit test. Starting lazily behind an idempotent `ensureStarted()` and only registering the stop hook keeps fast runs fast. 2. *Failure semantics* — a throwing project callback aborts the run rather than failing one test, which is right, but it must say what was wrong; wrap start failures with a diagnostic message. 3. *Cleanup contract* — one shared instance means state leaks between specs. Publish an explicit reset (truncate, per-test schema, unique keys per spec) and make it a convention, not folklore. 4. *Crash safety* — `afterProject` does not run if the JVM is killed. Anything that outlives the process needs external reaping.
code
kotlin · 15 linesobject Database : ProjectListener {
private val instance by lazy { startContainer() }
fun ensureStarted(): Db = instance
override suspend fun afterProject() {
if (instanceInitialized()) instance.close()
}
}
package io.kotest.provided
class ProjectConfig : AbstractProjectConfig() {
override fun extensions() = listOf(Database)
}go deeper
Say that run-wide setup belongs in a project-level listener rather than in each spec.
Compare project, per-class and per-instance hooks by how many times each fires, and pick project scope for a shared resource.
Add operational judgement: lazy start, diagnostics on start failure, idempotent stop, external reaping for leaks.
Own the whole contract — cleanup and isolation policy for shared state, concurrency implications, multi-module registration, discoverability of the wiring, and how the design holds as the suite triples in size.
## The shape of the decision Three scopes are available and they differ by how many times the cost is paid: | Owner | Fires | Cost multiplier | |---|---|---| | `ProjectListener` (`beforeProject`/`afterProject`) | once per run | 1 | | `PrepareSpecListener`/`FinalizeSpecListener` | once per spec class | number of spec classes | | `beforeSpec`/`afterSpec`, `autoClose` | once per spec instance | spec classes x instances (isolation mode) | For one genuinely shared resource, project scope is the only answer that does not scale with the suite. The other two are correct choices for resources that are genuinely per class or per instance — the mistake is using them for something shared and then watching CI time grow with the test count. ## Eager or lazy start `beforeProject` runs before the first spec, always. If a developer runs one unit test, they still pay the full startup. The usual refinement is: - keep a single idempotent `ensureStarted()` guarded by a lock or a `lazy`, - call it from the specs (or an extension) that actually need the resource, - register only the *stop* in `afterProject`, which no-ops if nothing started. That gives one-time cost with pay-for-what-you-use semantics. The price is that startup latency now appears inside the first test that touches it, which can look like a flaky timeout — set generous timeouts on that path, or accept eager start on CI and lazy locally. ## Failure semantics If `beforeProject` throws, the run fails as a whole rather than producing one red test among greens. That is usually the behaviour you want — a missing environment is not a test failure — but it is opaque. Wrap failures with context: what was being started, against what endpoint, with what timeout. The diagnostic quality here matters more than in a normal test, because there is no failing assertion to read. Decide too whether a broken environment should fail loudly or degrade. In most suites, loudly. If some specs can run without the resource, tag them so a degraded run is a deliberate, visible subset rather than an accident. ## The cleanup contract One shared instance means every spec sees the residue of every earlier spec. This is the real cost of sharing, and it must be handled by contract, not hope. The workable options: - **Reset between specs**: a project-level `BeforeSpecListener` truncates tables. Simple, but serialises everything and is wrong under concurrent spec execution. - **Namespace per spec**: each spec gets its own schema/prefix/tenant id. Costs a little setup, survives concurrency, and makes failures attributable. - **Own your data**: each test creates the rows it needs with unique keys and never asserts on global counts. Most robust; requires discipline and a lint-like review habit. Pick one and write it down; a suite where different teams assume different contracts produces failures that only appear in a particular execution order. ## Crash safety and idempotence Nothing in-process is guaranteed when the JVM is killed — `afterProject` included. If the resource is external (a container, a cloud fixture, a queue), assume leaks happen and add reaping outside the test run: a labelled resource plus a scheduled cleanup, or a startup step that removes stale instances. Make both start and stop idempotent so a second run does not trip over the first one's remains. ## Registration and blast radius Register the owner in exactly one place — the project config's `extensions()` — so a reader has a single file to consult. Resist classpath-discovered registration for in-house infrastructure: when someone debugging a failing spec cannot find *what started the database*, the extension has failed a maintainability test even if it works. Remember Kotest does not deduplicate registrations, so an extension listed both globally and in a spec starts the resource twice. ## Multi-module reality Each module's run resolves its own project config. A shared listener therefore lives in a small test-support artefact that modules depend on, with a per-module config class registering it. That keeps the lifecycle logic in one place while accepting the unavoidable truth that separate module runs are separate processes — and therefore separate resource lifecycles, unless you deliberately share one externally provisioned instance. ## What a strong answer sounds like It starts from cost multipliers, picks project scope for the shared resource, and then spends most of its time on the *consequences* of sharing — cleanup contract, concurrency, diagnostics, crash safety — because those are what actually determine whether the design survives a year of a growing suite.
- Why not just use autoClose in a base spec class that every integration spec extends?Because autoClose registers per spec instance. Every spec class pays the startup, and under a non-single isolation mode every instance does — so the cost scales with the number of tests, not with the number of resources. It also gives you many live copies of something you intended to have one of. Project scope decouples the cost from suite size.
- What changes about the shared-resource design once specs run concurrently?Reset-between-specs stops working, because one spec's truncate lands in the middle of another's test. You have to move to isolation by namespace — a schema, tenant id, or key prefix per spec — or to tests that only touch data they created. Diagnostics also get harder, so make failures carry the spec's namespace so you can tell whose data was wrong.
- How do you keep the wiring discoverable for someone debugging a failing spec?Register the listener explicitly in the project config rather than relying on classpath discovery, keep it in a named test-support module, and have it log a single line when it starts and stops with the endpoint it exposes. The goal is that a grep for the resource name from a failing spec leads to the owner in one hop.
saying these in an interview costs you the question
- Starting shared infrastructure in beforeSpec or autoClose 'because it works'
- Assuming afterProject always runs, including after a killed JVM
- Leaving the cleanup contract implicit once a resource is shared
- Registering the same lifecycle extension globally and in specs, starting it twice
- Sharing state across concurrently executing specs with a reset-between-specs strategy