In Kotest you can register a lifecycle callback either as a DSL block in the spec body or by overriding a suspend function such as beforeTest on the spec class. How do the two differ, and what happens if an abstract base spec overrides beforeTest and a subclass also registers a beforeTest block?
answer
- DSL block + override are additive, both fire
- override suspend fun beforeTest(testCase: TestCase)
- base spec forwards body: FunSpec(body)
- missing `override` → silently never called
- subclass re-override without super drops parent setup
basics
~20 sBoth feed the same callback machinery, so both run — the override does not replace the DSL block or vice versa. Overrides are suspend members inherited from the spec type, so they are the way to share setup through a base spec; DSL blocks are local to one spec body.
solid answer
~50 sKotest's spec types implement the listener interface that declares `beforeTest`, `afterTest`, `beforeSpec` and friends as open `suspend` functions, so you can either call the DSL function inside the spec body or override the member: ```kotlin abstract class IntegrationSpec(body: FunSpec.() -> Unit) : FunSpec(body) { override suspend fun beforeTest(testCase: TestCase) { db.truncateAll() } } ``` A subclass that also writes `beforeTest { seedReferenceData() }` in its body gets **both** — they are additive, not overriding. Use overrides for cross-spec reuse via a base class (or in Kotlin, factor the callback into a listener/extension); use DSL blocks for setup that belongs to this one spec. Two things bite: the override must be `suspend` and match the signature, or it is just a new function that Kotest never calls; and if a subclass overrides the same function again without calling `super`, the base implementation is skipped — plain Kotlin inheritance, not a Kotest rule. Don't depend on the relative ordering of an inherited override and a local block.
code
kotlin · 13 linesabstract class IntegrationSpec(body: FunSpec.() -> Unit) : FunSpec(body) {
override suspend fun beforeTest(testCase: TestCase) {
Database.truncateAll()
}
}
class UserRepositoryTest : IntegrationSpec({
beforeTest { seedReferenceData() } // runs in addition to the inherited override
test("finds a user by email") {
userRepository.findByEmail("[email protected]").shouldNotBeNull()
}
})go deeper
Know both forms exist and that they run together rather than replacing each other.
Show the base-spec pattern with the forwarded body lambda and name the suspend/override requirement.
Raise the super-call trap and argue for listeners/extensions over deep base hierarchies for shared fixtures.
Discuss fixture architecture: one shared mechanism, opt-in composition, no ordering dependencies between layers.
## Two registration surfaces, one pipeline A Kotest spec class implements the framework's listener contract, which declares the lifecycle callbacks as open `suspend` member functions: `beforeSpec(spec: Spec)`, `afterSpec(spec: Spec)`, `beforeTest(testCase: TestCase)`, `afterTest(testCase: TestCase, result: TestResult)`, plus the `beforeEach`/`afterEach`, `beforeContainer`/`afterContainer`, `beforeAny`/`afterAny` variants. The DSL functions you call in the spec body (`beforeTest { … }`) register a callback in the same collection the engine consults. So the choice is about *where the code lives*, not about what the engine does with it. ```kotlin class A : FunSpec({ beforeTest { println("dsl") } }) { override suspend fun beforeTest(testCase: TestCase) { println("override") } } ``` Both lines print. Nothing is shadowed. ## Why the override form exists The DSL block is lexically inside one spec body, so it cannot be reused. The override is a member, so it is inherited — which makes an abstract base spec the standard way to give a family of specs the same fixture: ```kotlin abstract class IntegrationSpec(body: FunSpec.() -> Unit) : FunSpec(body) { override suspend fun beforeTest(testCase: TestCase) { Database.truncateAll() } } class UserRepositoryTest : IntegrationSpec({ beforeTest { seedReferenceData() } test("finds a user") { } }) ``` Every spec extending `IntegrationSpec` gets the truncate; `UserRepositoryTest` adds its own seeding on top. Note the base class takes the body lambda and forwards it to `FunSpec(body)` — without that parameter, subclasses could not supply their own tests. The alternative to inheritance is to package the callback as a reusable listener/extension and attach it, which composes better than a deep base-class hierarchy; that is a separate configuration surface, but it is the answer to give when the interviewer asks "and if you don't like base classes?". ## The traps **Signature and `suspend`.** The callbacks are suspending. Writing `override fun beforeTest(testCase: TestCase)` without `suspend` will not compile against the suspending member — and if you simply write `fun beforeTest(testCase: TestCase)` with no `override` at all, you have declared an unrelated method that Kotest never calls, and your fixture silently does nothing while the tests still run. Always keep the `override` keyword so the compiler checks you. **Super calls in deeper hierarchies.** If `IntegrationSpec` overrides `beforeTest` and `WebIntegrationSpec` extends it and overrides `beforeTest` again, only the most-derived implementation runs unless it calls `super.beforeTest(testCase)`. This is ordinary Kotlin virtual dispatch, but it surprises people who assume Kotest aggregates overrides the way it aggregates DSL registrations. It is one reason to prefer one override per hierarchy level, or DSL blocks / listeners for the additive cases, where nothing can be accidentally lost. **Ordering.** Multiple registrations all fire, but do not build a fixture that depends on the inherited override running strictly before or after a locally registered block. If step B must follow step A, put A and B in the same callback, or make each step idempotent and order-independent. This keeps the fixture robust across Kotest versions and refactorings. **Isolation interplay.** DSL blocks are registered while the spec body executes. When Kotest creates a fresh spec instance — which it does under the non-default isolation modes — the body runs again on the new instance, so the DSL registration happens again for that instance; overrides are simply members of whatever instance exists. In practice both fire the expected number of times, but it explains why a `println` in the spec body (outside any hook) appears more than once under fresh-instance isolation while the hook count stays proportional to the tests. ## Choosing between them - One spec's own setup → DSL block, right next to the tests it serves. - Shared across a family of specs → base-class override, or better, a reusable listener/extension registered by those specs. - Cross-cutting for the whole project → project-level configuration rather than either of these. ## What interviewers listen for That the two forms are additive rather than competing; that the override is the inheritance-friendly form; that `suspend` and `override` must both be present or the hook silently never runs; and awareness that re-overriding in a subclass without `super` drops the parent's fixture.
- A colleague added `fun beforeTest(testCase: TestCase) { … }` to their spec class and the setup never runs. Why?Without the `override` keyword (and without `suspend`) it is not the framework's callback at all — it is a brand-new method on the class that nothing ever calls. The compiler cannot warn about intent, so the tests run happily against unprepared state. Adding `override suspend` makes the compiler verify the signature against the inherited member.
- What is the alternative to a base-class override for sharing lifecycle behaviour across many specs?Package the behaviour as a reusable listener/extension object and register it on the specs that need it, or apply it project-wide through the project configuration surface. Composition avoids the fragile deep base-class hierarchies where a subclass override can quietly drop the parent's fixture, and lets one spec opt into several independent behaviours.
saying these in an interview costs you the question
- Believing the override replaces or disables the DSL block
- Forgetting `suspend` or `override` and assuming the hook runs
- Assuming Kotest aggregates overrides across a class hierarchy without super calls
- Depending on a fixed execution order between an inherited override and a local block
- Claiming the DSL form cannot be used at all in a subclass of a custom base spec