Kotest ships integration modules such as its Spring, Testcontainers and WireMock extensions. At a mechanical level, how does an extension from one of those artifacts get attached to a spec or to a whole project?
answer
- extension(...) in the spec = spec scope
- AbstractProjectConfig extensions = whole run
- install(...) = register + get the resource back
- framework owns start/stop, including on failure
- extension artifacts version independently
basics
~20 sYou register the extension object: per spec via the spec's extension()/extensions() declaration, or project-wide by listing it in your AbstractProjectConfig. Extensions that own a resource are mounted with install(), which starts it on the spec lifecycle and returns the materialized value.
solid answer
~50 sEverything in the extensions ecosystem plugs into the same registration surface, so learning one teaches all of them. - **Per spec**: declare the extension inside the spec — `extension(SpringExtension)` (or `extensions(...)` for several). It then participates in that spec's lifecycle only. - **Project-wide**: list it in your `AbstractProjectConfig` implementation's extensions, so every spec in the run gets it — the usual choice for something like Spring support that the whole suite needs. - **Mounted resources**: extensions that own a lifecycle-managed object (a Testcontainers container, a WireMock server) implement Kotest's mountable extension contract, so you write `val container = install(ContainerExtension(PostgreSQLContainer(...)))`. `install` both registers the extension and returns the materialized resource for use in tests, and Kotest starts and stops it around the spec. So the mental model is: an extension is an object implementing Kotest extension interfaces; registration decides its scope (spec or project); `install` is the registration form for extensions that hand something back.
code
kotlin · 9 linesclass OrderRepositoryTest : FunSpec({
extension(SpringExtension)
val postgres = install(ContainerExtension(PostgreSQLContainer("postgres:15")))
test("container is available to the test") {
postgres.jdbcUrl shouldNotBe null
}
})go deeper
Know that an extension is registered in the spec, and that install() both registers it and gives you the resource.
Distinguish spec-level from project-level registration and explain what install() adds for lifecycle-managed resources.
Reason about scope: shared expensive resources project-wide or on a base spec, isolated ones per spec, and the state-leak tradeoff that follows.
Standardise the integration surface across the suite — where each resource is registered, what resets between specs, and how independently released extension artifacts are version-managed.
## One plug, many adapters Kotest's ecosystem modules — Spring, Testcontainers, WireMock, Koin and others, published separately under the `io.kotest.extensions` group — are not each a bespoke integration. They are all implementations of Kotest's extension interfaces, which is why the wiring looks the same regardless of which one you use. Learn the registration surface once and every integration in the ecosystem is approachable. ## Registration scopes **Spec-level.** Inside a spec you declare the extension you want for that spec: ```kotlin class OrderRepositoryTest : FunSpec({ extension(SpringExtension) // tests }) ``` The extension takes part only in this spec's lifecycle. Use this when the integration is expensive or only relevant to a subset of the suite. **Project-level.** Kotest's project configuration class (an implementation of `AbstractProjectConfig`) can list extensions that apply to **every** spec in the run. This is the right place for something that the whole suite depends on — Spring support in a Spring codebase, for instance — because it removes a line of boilerplate from every spec and guarantees consistency. (The full surface of `AbstractProjectConfig` — global defaults, isolation, parallelism — is a topic of its own; here it matters only as the project-wide registration point.) **Inheritance.** Because extensions are declared in the spec body, a shared abstract base spec that declares them is another common way to scope an integration to a family of specs without going project-wide. ## install() and mountable extensions Many integrations exist to manage a **resource** whose lifetime should follow the test lifecycle: a database container, a mock HTTP server, a dependency-injection context. For these, registration alone is not enough — the test also needs a handle on the thing. Kotest's mountable extension contract covers this, and the registration form is `install`: ```kotlin class RepoTest : FunSpec({ val postgres = install(ContainerExtension(PostgreSQLContainer("postgres:15"))) test("connects") { postgres.jdbcUrl shouldNotBe null } }) ``` `install` registers the extension **and returns the materialized value**, so the container is both lifecycle-managed by Kotest — started before the spec's tests, stopped afterwards — and directly usable inside them. That is the pattern the Testcontainers and WireMock extensions are built around: you never write manual start/stop code, and you never leak a resource when a test fails, because teardown is on the framework's path, not yours. ## Why go through an extension at all A fair question is why not just start the container in a `beforeSpec` block. Reasons: 1. **Teardown is guaranteed.** The framework runs the shutdown side even when tests fail or the spec aborts. 2. **Scope is declarative.** Moving a resource from per-spec to project-wide is a change of where you register it, not a rewrite of setup code. 3. **Consistency.** Every integration reads the same way, so a reader of an unfamiliar spec recognises the shape immediately. 4. **The integration knows more than you do.** The Spring extension, for example, hooks the spec into Spring's test-context machinery (whose own mechanics belong to the Spring testing topic) — reproducing that by hand is not a few lines. ## Choosing a scope - **Expensive shared resource** (a database container used by many specs): project-level or a shared base spec, so it starts once rather than per spec. - **Cheap or spec-specific resource** (a mock server with per-spec stubs): spec-level, for isolation. - **Framework support the whole suite needs** (Spring): project-level. The tradeoff is the usual one between startup cost and inter-spec isolation: a shared container is fast but lets state leak between specs unless you reset it; a per-spec container is clean but slow. ## Common mistakes - Registering an extension per spec when it is expensive and shared, then wondering why the suite takes minutes. - Calling `install` and discarding the return value, then re-creating the resource by hand inside a test. - Assuming the ecosystem artifacts version in lockstep with the core Kotest artifacts — they are a separate project with its own releases. - Writing manual start/stop in lifecycle callbacks when the extension already does it, which usually loses the failure-path teardown guarantee. ## Interview framing The answer interviewers want is the *uniformity*: extensions are objects registered either on a spec or in project config, and `install` is the form used when the extension hands back a lifecycle-managed resource. A candidate who can say that has understood the ecosystem rather than memorised one integration's README.
- When would you register an extension project-wide rather than per spec?When the whole suite needs it (framework support such as Spring) or when the resource is expensive and safe to share — starting one database container for the run instead of one per spec. The tradeoff is isolation: a shared resource can leak state between specs, so it only works if each spec resets what it touches or the data is naturally partitioned.
- Why prefer a mounted extension over starting a container yourself in a lifecycle callback?Because the framework owns the teardown path, so the resource is stopped even when tests fail or the spec aborts, and because the scope becomes declarative — moving from per-spec to project-wide is a change of registration site, not a rewrite. `install` also returns the materialized resource, so you get a handle without keeping your own mutable field around.
saying these in an interview costs you the question
- Thinking each ecosystem artifact has its own bespoke wiring rather than one shared registration surface
- Calling install() but ignoring its return value and re-creating the resource manually
- Registering an expensive shared container per spec and blaming the framework for slow tests
- Assuming extension artifacts always carry the same version number as the core Kotest artifacts
- Hand-rolling start/stop in callbacks and losing teardown on the failure path