What is Kotest's ConstructorExtension for, and why can it only be registered at project level?
answer
- instantiate(kclass): Spec? — null means fall through
- hook for DI-constructed specs with constructor params
- project-scope only: instance does not exist yet
- fires per instance → isolation mode multiplies calls
- runs before beforeSpec, sees no results
basics
~20 sConstructorExtension lets you create spec instances yourself — instantiate(kclass) returns a Spec or null to fall through to the next extension or Kotest's default no-arg construction. It must be registered in project config because it runs before any spec instance exists.
solid answer
~50 s```kotlin interface ConstructorExtension : Extension { fun <T : Spec> instantiate(clazz: KClass<T>): Spec? } ``` Kotest normally builds a spec by calling its no-arg constructor. A `ConstructorExtension` intercepts that step: for each spec class the engine asks the registered constructor extensions, in turn, to produce an instance. Returning `null` means "not mine" and lets the next extension — ultimately the default reflective construction — handle it. Returning a `Spec` makes yours the instance that runs. That is how a dependency-injection container can own spec construction, so specs can take constructor parameters instead of pulling collaborators from a static holder. It can only be registered in `AbstractProjectConfig.extensions()` (or discovered globally) for a chicken-and-egg reason: Kotest reads a spec's own `extensions()` list *from the instance*, and the instance is precisely what this extension is supposed to create. A spec-level registration is read too late to ever apply. Note it is called for every instance created, so with a non-single isolation mode it runs many times per class.
code
kotlin · 12 linesobject InjectingConstructor : ConstructorExtension {
override fun <T : Spec> instantiate(clazz: KClass<T>): Spec? =
if (clazz.constructors.any { it.parameters.isNotEmpty() })
container.resolve(clazz)
else
null // let Kotest use the default no-arg constructor
}
// project config
class ProjectConfig : AbstractProjectConfig() {
override fun extensions() = listOf(InjectingConstructor)
}go deeper
Recognise that Kotest constructs specs with a no-arg constructor and that an extension point exists to change that.
State the signature and the null fall-through, and give the DI use case.
Explain the project-scope-only constraint from first principles and connect call frequency to isolation mode.
Judge when a project should own spec construction at all — the discoverability cost of magic construction versus the ergonomics of injected specs, and how to keep the extension narrowly scoped.
## The construction step Running a spec has an often-invisible first step: turning the spec **class** into a spec **object**. By default Kotest does this reflectively via the no-arg constructor, which is why specs conventionally take no parameters and build their collaborators as properties. `ConstructorExtension` is the hook for that step: ```kotlin interface ConstructorExtension : Extension { fun <T : Spec> instantiate(clazz: KClass<T>): Spec? } ``` For each spec class about to be instantiated, Kotest consults the registered constructor extensions. The `null` return is the important part of the contract: it means "I do not handle this class", and Kotest moves on to the next extension and finally to its own default construction. A well-behaved constructor extension therefore inspects the `KClass` — an annotation, a marker interface, a package — and returns `null` for everything it does not own. An extension that unconditionally returns an object hijacks every spec in the project. ## Why it exists The motivating use case is dependency injection. If a container owns your objects, you would like to write: ```kotlin class OrderTests(private val orders: OrderService) : FunSpec({ /* ... */ }) ``` A no-arg constructor cannot express that. A constructor extension resolves the class through the container and hands the fully wired instance back to Kotest. Framework integrations use this exact mechanism; you can also write your own for a small hand-rolled factory, which is often simpler than pulling a service locator into every spec. Secondary uses: instrumenting construction (recording which specs are built and how often, which is a neat way to *see* what an isolation mode is doing), or substituting a subclass in a controlled environment. ## The registration constraint Spec-level registration works like this: Kotest constructs the spec, then reads its `extensions()`/`extension(...)` registrations, then applies them. Every step depends on the instance existing. A `ConstructorExtension`'s job is to produce that instance. If you register it inside the spec, Kotest must already have constructed the spec by conventional means before it can see the registration — the extension arrives after the moment it was meant to influence. It does not error; it simply never applies, which makes it a genuinely confusing failure. The fix is always the same: move it to `AbstractProjectConfig.extensions()`, the scope that is resolved before any spec is built (or let it be discovered globally, if your setup enables that). ## Frequency of invocation Because it hooks construction, it fires once per **instance**, not once per class. Under a non-single isolation mode a spec class is instantiated repeatedly, and your extension is asked to build each instance. Two consequences: - The work in `instantiate` must be cheap, or at least cheap on repeat. Resolving from a warm container is fine; building a container per call is not. - Anything you inject is re-resolved per instance. If the injected object is stateful and you expected one shared instance per class, the isolation mode has quietly changed your test's sharing model. Scope shared state in the container deliberately rather than by accident. ## Interaction with the rest of the lifecycle Construction sits before every per-instance callback. Ordering, roughly: `prepareSpec` (once per class) → construct instance (constructor extensions) → `beforeSpec` for that instance → tests → `afterSpec` → … → `finalizeSpec` (once per class). So a constructor extension cannot see test results, and it is not the place for setup that belongs in `beforeSpec` — keep it to *producing the object*. ## Signals a candidate understands it - Names the `null` fall-through contract rather than describing it as "a factory Kotest calls". - Knows the registration constraint and can explain the chicken-and-egg reason instead of quoting a rule. - Connects invocation frequency to isolation mode. - Recognises it as a niche hook: most projects never write one, and reaching for it to solve ordinary setup problems is a smell — that is what `beforeSpec` and injected factories are for.
- What does returning null from instantiate mean, and why does it matter?It means the extension declines to build that spec, so Kotest tries the next registered constructor extension and ultimately its own no-arg reflective construction. It matters because it keeps constructor extensions composable and non-invasive: yours claims only the classes it recognises, and every other spec in the project keeps working exactly as before. An extension that always returns an instance takes over the whole project.
- How often is a ConstructorExtension called for a single spec class?Once per instance created, which under a non-single isolation mode is once per test or per leaf rather than once per class. That makes cost per call matter, and it means anything injected is re-resolved for each instance — so an injected stateful collaborator is not automatically shared across the class's tests. Scope shared state deliberately in the container instead of assuming one instance.
saying these in an interview costs you the question
- Registering a ConstructorExtension inside the spec and expecting it to apply
- Returning an instance for every class instead of null for unclaimed ones
- Assuming instantiate is called once per spec class
- Using it for setup work that belongs in beforeSpec
- Thinking it can inspect or alter test results