skip to content

Listeners & Extensions

Cross-cutting behavior plugs in through listener callbacks and extension interfaces that can intercept spec instantiation and test execution. Interviewers ask this to check you can package shared setup (databases, servers, fixtures) as reusable extensions instead of copy-pasted hooks.

on this pageshow

explore

questions

6

In Kotest 5, what are the ways to register a listener or extension, and how does the registration site change what it affects?

level: middleimportance: must knowfreq 42%

answer

  1. one Extension marker, three doors
  2. extension(...) / extensions() = spec scope
  3. AbstractProjectConfig.extensions() = whole run
  4. ProjectListener + ConstructorExtension: project only
  5. no dedup — double registration fires twice

basics

~20 s

Inside a spec via extension(...) or override fun extensions() — that spec only. In a class extending Kotest's AbstractProjectConfig via override fun extensions() — every spec in the run. Or @AutoScan for classpath-discovered global extensions.

solid answer

~50 s

Kotest has one marker interface, `Extension`; listeners (`BeforeSpecListener`, `AfterTestListener`, `ProjectListener`) and interceptors (`SpecExtension`, `TestCaseExtension`, `ConstructorExtension`) all implement it and all go through the same registration doors. - **Spec scope** — call `extension(MyExt())` inside the spec body, or `override fun extensions(): List<Extension>`. It applies only to that spec, and only to callbacks that happen after the spec instance exists. - **Project scope** — `override fun extensions()` in your `AbstractProjectConfig` subclass. Applies to every spec. This is the only place project-wide things work: `ProjectListener`'s `beforeProject`/`afterProject`, and `ConstructorExtension`, which must exist before any spec is instantiated. - **@AutoScan** — annotate an extension object so classpath scanning picks it up globally; convenient for library-supplied extensions but the wiring is invisible at the call site. Kotest does not deduplicate: registering the same extension in both places makes its callbacks fire twice for that spec.

code

kotlin · 11 lines
kotlin
class OrderTests : FunSpec({
    extension(TimingExtension())
    test("places an order") { /* ... */ }
})

class PaymentTests : FunSpec() {
    override fun extensions() = listOf(TimingExtension())
    init {
        test("charges once") { /* ... */ }
    }
}

go deeper

for a junior

Know that a spec can register an extension with extension(...) or by overriding extensions(), and that project config exists for global ones.

for a middle

Name all three scopes, and say which callbacks are only possible at project scope (project listeners, constructor interception).

for a senior

Add the failure modes: double registration, extensions that never fire because of scope, and why @AutoScan hurts discoverability.

for a principal

Frame it as a wiring-visibility decision for a large suite — explicit project config as the single global registry, spec-level only for genuinely local concerns, and a policy against @AutoScan for in-house extensions.

## One interface, several doors Everything pluggable in Kotest 5 implements the marker interface `Extension`. Two families sit under it: - **Listeners** — narrow callback interfaces you implement to *observe* the run: `BeforeSpecListener` / `AfterSpecListener`, `BeforeTestListener` / `AfterTestListener`, `BeforeEachListener` / `AfterEachListener`, `BeforeContainerListener` / `AfterContainerListener`, `BeforeInvocationListener` / `AfterInvocationListener`, `PrepareSpecListener` / `FinalizeSpecListener`, and `BeforeProjectListener` / `AfterProjectListener`. The convenience interface `TestListener` bundles the spec/test ones; `ProjectListener` bundles the two project ones. - **Interceptors** — interfaces that *wrap* execution and can change what happens: `SpecExtension`, `TestCaseExtension`, `ConstructorExtension`. Because they share one interface, they share one registration mechanism. What differs is where you register, and that decides scope. ## Door 1 — spec scope Inside a spec you can register inline or by override: ```kotlin class OrderTests : FunSpec({ extension(TimingExtension()) test("places an order") { /* ... */ } }) class PaymentTests : FunSpec() { override fun extensions() = listOf(TimingExtension()) init { test("charges once") { /* ... */ } } } ``` The extension only sees this spec's lifecycle. Two consequences that trip people up: a `BeforeProjectListener` registered here will never fire (the project already started), and a `ConstructorExtension` registered here can never apply to this spec, because the engine had to construct the spec instance before it could read the list. ## Door 2 — project scope A subclass of `AbstractProjectConfig` (Kotest finds one per run) can return extensions that apply to every spec: ```kotlin package io.kotest.provided class ProjectConfig : AbstractProjectConfig() { override fun extensions() = listOf(DatabaseLifecycle, TimingExtension()) } ``` This is the only scope where project-wide behaviour is meaningful: `beforeProject`/`afterProject`, and constructor interception. It is also the right place for anything cross-cutting — a listener that fails the build on leaked threads, a reporter, a global clock reset — because you get uniformity without touching hundreds of files. ## Door 3 — @AutoScan An extension annotated `@AutoScan` is discovered by classpath scanning and applied globally without any explicit registration. It is how some library extensions ship. It depends on classpath scanning being enabled in your setup, and it hides the wiring: someone reading a failing spec has no local clue that an extension is involved. Many teams disable or avoid it and register explicitly in project config instead. ## Per-test scope `TestCaseConfig` also carries an `extensions` list, so `.config(extensions = listOf(...))` on a single test attaches a `TestCaseExtension` to just that test. Rarely used, but it exists — useful when exactly one test needs wrapping and you do not want the rest of the spec paying for it. ## Duplication and ordering Kotest does not deduplicate registrations. Register the same object at project scope *and* in a spec and its callbacks run twice there — a classic source of "my counter is off by one" or "the container started twice". Keep an extension registered in exactly one place, and prefer stateless or idempotent implementations. Interceptor-style extensions nest: each `intercept` receives an `execute` lambda, so the code before your call to `execute` runs on the way in and the code after runs on the way out, with other interceptors layered inside. Do not build logic that depends on a precise ordering between independently registered extensions; if two things must happen in a fixed order, put them in one extension. ## Diagnosing "my extension never ran" Walk the scope question first. Is the callback a project-level one registered at spec level? Is the project config class actually on the test classpath of the module being executed? Is the extension a `ConstructorExtension` registered too late? Is it a spec-level registration on a spec that the current tag filter excluded entirely? Those four cover most cases before you start suspecting the framework.

  • You register the same extension object both in AbstractProjectConfig and inside one spec. What do you observe in that spec?
    Its callbacks fire twice there, once per registration — Kotest does not deduplicate. If the extension is stateful (counting tests, opening a resource) you get double counting or a double open. Keep each extension registered in exactly one scope, and make implementations idempotent so an accidental duplicate is harmless rather than corrupting.
  • Why can a ConstructorExtension not be registered inside the spec it is supposed to affect?
    The engine reads a spec's extension list from the spec instance, which means the instance must already exist. A ConstructorExtension's whole job is to create that instance, so by the time Kotest could see a spec-level registration the construction has already happened. It has to be registered at project level (or discovered by @AutoScan) to be in play.

saying these in an interview costs you the question

  • Thinking spec-level registration can hook beforeProject/afterProject
  • Assuming Kotest deduplicates the same extension registered at two scopes
  • Believing listeners and extensions are separate registration systems with separate APIs
  • Relying on a guaranteed ordering between two independently registered extensions
  • Using @AutoScan for team-critical wiring and then being unable to explain where an extension came from

context

open as a page

What does Kotest's autoClose function do inside a spec, when does the resource get closed, and where does it let people down?

level: middleimportance: should knowfreq 28%

basics

~20 s

autoClose registers an AutoCloseable held by a spec so Kotest closes it during that spec's teardown, in reverse registration order. It is per spec instance, so under per-test isolation each instance opens and closes its own copy — it is not a shared or project-scoped resource mechanism.

open as a page

In Kotest 5, how do the beforeSpec/afterSpec listener callbacks differ from prepareSpec/finalizeSpec when a spec class produces more than one instance?

level: seniorimportance: should knowfreq 30%

basics

~20 s

beforeSpec/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.

open as a page

Kotest's TestCaseExtension exposes an intercept(testCase, execute) function. What is the contract, and what can it do that a plain afterTest listener callback cannot?

level: seniorimportance: should knowfreq 34%

basics

~20 s

intercept wraps the test: you receive the TestCase and an execute lambda, and must return a TestResult. Because you control whether execute runs, you can skip a test, surround it with setup/teardown that must bracket it, or replace the reported result — none of which a listener can do.

open as a page

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?

level: principalimportance: should knowfreq 24%

basics

~20 s

Give 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.

open as a page

What is Kotest's ConstructorExtension for, and why can it only be registered at project level?

level: seniorimportance: nice to knowfreq 18%

basics

~20 s

ConstructorExtension 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.

open as a page