skip to content

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%

answer

  1. intercept = around hook, returns TestResult
  2. forget execute() → test silently never runs
  3. skip = return TestResult.Ignored without executing
  4. bracket: withContext / transaction / use
  5. listeners observe, extensions decide

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.

solid answer

~50 s

`TestCaseExtension.intercept(testCase, execute)` is an *around* hook. Kotest hands you the `TestCase` plus a suspending `execute` that actually runs it and yields a `TestResult`; your function must return a `TestResult`. That gives three powers listeners lack: 1. **Skip** — return `TestResult.Ignored("reason")` without calling `execute`, so the body never runs and the report shows it as ignored. 2. **Bracket** — wrap the call in something that must lexically surround the body: a coroutine context, a transaction you roll back, a `use` block, an MDC scope. A `beforeTest`/`afterTest` pair cannot do this because the two callbacks are separate stack frames. 3. **Transform the result** — inspect the returned `TestResult` and return a different one, e.g. downgrade a known environment failure to ignored, or attach diagnostics. The contract obligation is the flip side: if you forget to call `execute`, the test silently never runs. Listeners (`BeforeTestListener`/`AfterTestListener`) are strictly observational — they see the test and result but cannot alter either.

code

kotlin · 21 lines
kotlin
object SkipOnLinux : TestCaseExtension {
    override suspend fun intercept(
        testCase: TestCase,
        execute: suspend (TestCase) -> TestResult
    ): TestResult =
        if (System.getProperty("os.name").contains("Linux"))
            TestResult.Ignored("not supported on Linux")
        else
            execute(testCase)
}

class Transactional(private val db: Db) : TestCaseExtension {
    override suspend fun intercept(
        testCase: TestCase,
        execute: suspend (TestCase) -> TestResult
    ): TestResult = db.beginTransaction().use { tx ->
        val result = execute(testCase)
        tx.rollback()
        result
    }
}

go deeper

for a junior

Know that a TestCaseExtension wraps a test and that listeners only watch it.

for a middle

State the signature and the must-call-execute obligation, plus one real use (skip or transaction rollback).

for a senior

Contrast wrapping with the beforeTest/afterTest pair — scoping constructs, concurrency safety, result rewriting — and name the silent-green failure mode.

for a principal

Discuss policy: when result rewriting is acceptable, how to keep an interception layer auditable, and why observational listeners should be the default in a shared codebase.

## The signature In Kotest 5: ```kotlin interface TestCaseExtension : Extension { suspend fun intercept( testCase: TestCase, execute: suspend (TestCase) -> TestResult ): TestResult } ``` The engine does not call your extension *around* an already-decided run; it hands you the run itself. `execute` is the continuation: calling it executes the test body (and everything nested inside it) and returns the outcome. Whatever `TestResult` you return is what Kotest reports. `TestResult` in Kotest 5 is a sealed type with `Success`, `Failure` (an assertion failed), `Error` (an unexpected throwable), and `Ignored(reason)`. ## Power 1 — deciding whether the test runs ```kotlin object SkipOnLinux : TestCaseExtension { override suspend fun intercept( testCase: TestCase, execute: suspend (TestCase) -> TestResult ): TestResult = if (System.getProperty("os.name").contains("Linux")) TestResult.Ignored("not supported on Linux") else execute(testCase) } ``` No listener can do this. `beforeTest` runs *before* a test that is already going to run; throwing from it produces a failure, not a skip. ## Power 2 — bracketing Anything that must lexically surround the body belongs here: `withContext(...)`, a database transaction opened and rolled back, a `Closeable.use`, a mocked clock installed and restored, a thread-local set and cleared with the guarantee that the clear happens even if the body throws. ```kotlin class Transactional(private val db: Db) : TestCaseExtension { override suspend fun intercept( testCase: TestCase, execute: suspend (TestCase) -> TestResult ): TestResult = db.beginTransaction().use { tx -> val result = execute(testCase) tx.rollback() result } } ``` A `beforeTest`/`afterTest` pair can imitate this only by stashing state in a field between two separate callbacks — which breaks the moment tests run concurrently, and cannot use scoping constructs like `withContext` or `use` at all, because those need one continuous frame. ## Power 3 — rewriting the outcome Because you return the `TestResult`, you can inspect and replace it: convert a specific known infrastructure `Error` into `Ignored` so a flaky environment does not redden the build, or leave failures alone but enrich them with captured logs before rethrowing. Use this sparingly — an extension that silently swallows failures is a trust problem, not a feature. If you do it, log loudly. ## The obligations - **Call `execute` exactly once on the normal path.** Forgetting it is the classic bug: every test in scope reports as passing (or as whatever you returned) while nothing ran. Reviewers should look for the `execute` call first. - **Return a real result.** Do not fabricate `TestResult.Success` when you did run the body — return what `execute` gave you. - **Be suspend-safe.** `intercept` is a suspending function running on the test's coroutine; do not block it, and do not assume a particular thread. - **Be concurrency-safe.** If tests run concurrently, one extension instance may be intercepting several tests at once, so per-test state must live in local variables, not fields. ## When a listener is the better choice If you only need to *observe* — record timings, log the test name, count results, snapshot metrics — implement `BeforeTestListener`/`AfterTestListener` (or the bundled `TestListener`). They are simpler, harder to get wrong, and cannot accidentally cancel your suite. Reach for `TestCaseExtension` only when you need to decide, bracket, or transform. ## Scoping Register it in a spec (`extension(...)` / `extensions()`) to affect that spec's tests, in `AbstractProjectConfig.extensions()` to affect the whole run, or on a single test through `TestCaseConfig`'s `extensions` list. Note also that in Kotest 5 `intercept` is called for containers as well as leaf tests in nested spec styles, so guard on `testCase.type` if your logic only makes sense for leaves.

  • An engineer writes a TestCaseExtension that logs the test name and returns TestResult.Success without calling execute. What does the suite report?
    Every test in that extension's scope reports as passing while no test body ever ran. Kotest treats the returned TestResult as authoritative, so the suite goes green on nothing. It is the single most dangerous mistake with interception, and the reason code review should check for exactly one execute call on the normal path.
  • When would you implement AfterTestListener instead of TestCaseExtension?
    When you only need to observe: recording durations, logging results, exporting metrics, asserting no threads leaked. Listeners cannot skip a test, cannot bracket the body in a scoping construct, and cannot change the reported result — which is precisely why they are safer for read-only concerns. Reserve interception for cases that genuinely need control over execution.

saying these in an interview costs you the question

  • Returning a fabricated TestResult.Success instead of the one execute produced
  • Forgetting to call execute and not noticing that tests stopped running
  • Thinking beforeTest can skip a test by throwing (it produces a failure, not a skip)
  • Storing per-test state in extension fields when tests may run concurrently
  • Assuming intercept only fires for leaf tests in nested spec styles

context