In Kotest 5, how do the beforeSpec/afterSpec listener callbacks differ from prepareSpec/finalizeSpec when a spec class produces more than one instance?
answer
- beforeSpec/afterSpec take a Spec → per instance
- prepareSpec/finalizeSpec take a KClass → per class
- prepareSpec runs before any instance exists
- finalizeSpec gets Map<TestCase, TestResult>
- container start in beforeSpec + per-leaf isolation = disaster
basics
~20 sbeforeSpec/afterSpec fire once per spec instance, so under non-single isolation they fire repeatedly for the same class. prepareSpec/finalizeSpec fire exactly once per spec class — before the first instance is created and after the last one finishes, with finalizeSpec receiving all results.
solid answer
~50 sKotest may create several instances of one spec class, depending on isolation mode. The two callback families sit on opposite sides of that. - `BeforeSpecListener.beforeSpec(spec)` and `AfterSpecListener.afterSpec(spec)` take a `Spec` **instance**. They fire once per instance. With a per-test or per-leaf isolation mode, a spec with twelve leaves fires them twelve times each. - `PrepareSpecListener.prepareSpec(kclass)` and `FinalizeSpecListener.finalizeSpec(kclass, results)` take the `KClass` and fire exactly once per spec class per run — prepare before the first instance exists, finalize after every instance has completed. `finalizeSpec` gets the map of `TestCase` to `TestResult` for the class. Practical rule: anything expensive and shared for the whole class (start a container, seed a schema) goes in prepare/finalize; anything that must be fresh for each instance (reset a mock, clear a cache) goes in beforeSpec. Putting a container start in `beforeSpec` under per-leaf isolation is how suites go from seconds to minutes.
code
kotlin · 15 linesobject ContainerLifecycle : PrepareSpecListener, FinalizeSpecListener {
override suspend fun prepareSpec(kclass: KClass<out Spec>) = Containers.start()
override suspend fun finalizeSpec(
kclass: KClass<out Spec>,
results: Map<TestCase, TestResult>
) = Containers.stop()
}
class OrderTests : FunSpec() {
override fun extensions() = listOf(ContainerLifecycle)
override suspend fun beforeSpec(spec: Spec) {
Containers.truncateAllTables() // cheap, safe to repeat per instance
}
init { test("places an order") { /* ... */ } }
}go deeper
Recall that beforeSpec runs per spec instance and that prepareSpec/finalizeSpec run once per spec class.
Explain why the signatures differ (KClass vs Spec) and give the container-startup example of choosing wrongly.
Diagnose the performance regression when isolation mode changes, and pick hooks by cost and by whether instance state is involved.
Set the convention: expensive shared infrastructure never lives in per-instance hooks, per-class reporting rides finalizeSpec, and isolation-mode changes are reviewed for hook cost.
## Why a spec class can have several instances Kotest instantiates a spec class to run it. Depending on the configured isolation mode, it may instantiate the same class more than once for a single run — a fresh instance gives each test a fresh set of spec fields. The number of instances is therefore not one in general, and every callback that takes a `Spec` instance is affected. ## Two families, two granularities **Per-instance callbacks** take a `Spec`: ```kotlin interface BeforeSpecListener : Extension { suspend fun beforeSpec(spec: Spec) } interface AfterSpecListener : Extension { suspend fun afterSpec(spec: Spec) } ``` They run once for each instance — immediately before that instance's tests begin and immediately after they end. The `spec` argument is the actual object whose fields those tests will see, which is exactly why the callback is per-instance: it is the only hook that can touch that instance's state. **Per-class callbacks** take a `KClass`: ```kotlin interface PrepareSpecListener : Extension { suspend fun prepareSpec(kclass: KClass<out Spec>) } interface FinalizeSpecListener : Extension { suspend fun finalizeSpec(kclass: KClass<out Spec>, results: Map<TestCase, TestResult>) } ``` `prepareSpec` runs once, before Kotest creates the first instance of that class — note the signature could not take a `Spec` even if it wanted to, because none exists yet. `finalizeSpec` runs once after all instances of the class have finished, and receives every test case executed for the class along with its result. That results map is what makes it the natural place for per-class reporting: emit a summary, write a JSON artefact, assert an invariant about the class as a whole. ## The performance trap The usual production incident: someone puts an expensive setup in `beforeSpec`. ```kotlin class SlowIntegrationTests : FunSpec() { override suspend fun beforeSpec(spec: Spec) { startContainer() } // per instance! // ... } ``` Under a single-instance isolation mode this runs once and looks fine. The day someone switches the spec (or the project default) to a per-test or per-leaf mode, it runs once per test. A twenty-test spec now starts twenty containers. Nothing fails; the suite just becomes unusably slow, and the cause is invisible in the diff that changed the isolation mode. Moving the start to `prepareSpec` and the stop to `finalizeSpec` makes the cost independent of isolation mode. The tradeoff is that state created in `prepareSpec` is shared by every instance and every test of that class, so it must either be immutable or be reset per test by a cheaper hook. ## Choosing between them | Need | Hook | |---|---| | Expensive resource shared by the whole class | `prepareSpec` / `finalizeSpec` | | Anything touching this instance's fields | `beforeSpec` / `afterSpec` | | A per-class summary of results | `finalizeSpec` (it receives the results map) | | Something that must be fresh for every test | a per-test hook, not a spec hook | ## Registration and scope All four are ordinary `Extension` implementations. Registered inside a spec they apply to that spec; registered in `AbstractProjectConfig.extensions()` they apply to every spec — a project-level `FinalizeSpecListener` is a tidy way to build a whole-run report without touching any spec. A spec can also simply override `beforeSpec`/`afterSpec` directly (the spec class itself implements the listener interfaces), which is the common inline form. There is no equivalent inline override that gives you per-class semantics — prepare/finalize really is listener territory. ## Things people get wrong - Assuming `beforeSpec` is "the once-per-class setup". It is once per *instance*. - Expecting `prepareSpec` to receive the spec object so they can set a field on it. It cannot; no instance exists yet. - Putting cleanup only in `afterSpec` and being surprised it ran several times, e.g. deleting rows another instance still needs when specs run concurrently. - Treating `finalizeSpec`'s results map as live during the run — it is a completed summary, delivered at the end. - Assuming `afterSpec` is guaranteed if the JVM is killed. It is not; nothing in-process is. Anything that must not leak beyond a crashed run needs external reaping.
- Why does prepareSpec take a KClass rather than a Spec?Because it runs before Kotest has instantiated the class — there is no spec object to hand you. That is also the point: it is the hook whose cost is paid once per class no matter how many instances the isolation mode produces. If you need the instance, you need beforeSpec instead, and you accept its per-instance frequency.
- What is finalizeSpec's results map good for?It is the complete set of TestCase-to-TestResult pairs for that spec class, delivered once at the end, so it is the natural hook for per-class reporting: writing a summary artefact, publishing metrics, or asserting a class-wide invariant such as 'no test in this spec was silently ignored'. It is a completed snapshot, not a live feed.
saying these in an interview costs you the question
- Calling beforeSpec the once-per-class setup hook
- Starting containers or servers in beforeSpec without checking the isolation mode
- Expecting prepareSpec to give you the spec instance
- Assuming afterSpec always runs even when the JVM is killed
- Thinking finalizeSpec streams results as tests complete