skip to content

Kotest's AnnotationSpec lets you write tests as annotated methods instead of a DSL lambda. How does Kotest discover and name those tests, which lifecycle annotations does it provide, and what do you give up compared with Kotest's lambda-based flat styles?

level: middleimportance: nice to knowfreq 25%

answer

  1. No DSL body — `: AnnotationSpec()` plus annotated methods
  2. Kotest's own nested annotations, not another framework's
  3. Test name = method name (backticks for sentences)
  4. @Test(expected = …), @Ignore, @BeforeEach/@AfterEach/@BeforeAll/@AfterAll
  5. No containers, no .config, no dynamic registration

basics

~20 s

You extend AnnotationSpec() and mark methods with Kotest's own @Test; Kotest reflects over the class and each annotated method becomes a root-level test named after the method. It provides @BeforeEach/@AfterEach/@BeforeAll/@AfterAll, @Ignore, and @Test(expected = ...). You lose string test names, containers and the .config DSL.

solid answer

~50 s

**AnnotationSpec** is the one Kotest style with no DSL body. You write `class FooTest : AnnotationSpec()` and annotate methods with Kotest's nested annotations — `@Test`, `@Ignore`, `@BeforeEach`, `@AfterEach`, `@BeforeAll`, `@AfterAll` — which resolve to `AnnotationSpec.Test` and friends, not to another framework's. Kotest **reflects** over the class at run time; every `@Test` method becomes a **root-level leaf**, and its **name is the method name** (so sentence-like names require backticked function names). `@Test(expected = SomeException::class)` asserts the thrown type, and `@Ignore` skips a method. What you give up: no string names by default, no containers or nesting, no `.config(...)` per-test builder, and no dynamic registration — the set of tests is fixed by the compiled method list rather than produced by running a lambda. Its purpose is familiarity for people arriving from annotation-driven frameworks, and a low-friction stepping stone before adopting a real Kotest DSL style.

code

kotlin · 21 lines
kotlin
class CalculatorTest : AnnotationSpec() {

    private lateinit var calc: Calculator

    @BeforeEach
    fun setUp() { calc = Calculator() }

    @Test
    fun `adds two positive numbers`() {
        calc.add(2, 3) shouldBe 5
    }

    @Test(expected = ArithmeticException::class)
    fun dividesByZero() {
        calc.divide(1, 0)
    }

    @Ignore
    @Test
    fun notImplementedYet() { }
}

go deeper

for a junior

Recall the shape: extend AnnotationSpec(), mark methods @Test, names come from method names, @Ignore skips.

for a middle

Add that discovery is reflective, that the annotations are Kotest's own nested ones, and list what is lost — containers, .config, dynamic registration.

for a senior

Explain the migration role: mechanical conversion of legacy annotated classes, then style-by-style movement to a DSL spec, and why new tests should not start here.

for a principal

Discuss it as a codebase-adoption lever — lowering switching cost for a large legacy suite — while keeping a convention that new specs use container-bearing styles.

## The odd one out Every other Kotest spec style registers tests by **executing code**: you pass a lambda to the spec constructor (or fill an `init` block) and the calls inside it build a test tree. **AnnotationSpec** does not. You extend it with parentheses — `class CalculatorTest : AnnotationSpec()` — and declare ordinary methods; Kotest inspects the class **reflectively** and turns each annotated method into a test case. That single difference explains every capability and every limitation of the style. ## The annotations The annotations are Kotest's own, declared as **nested annotation classes on `AnnotationSpec`**, so after importing the spec you write them unqualified: - `@Test` — marks a method as a test. - `@Test(expected = IllegalStateException::class)` — the test passes only if a matching exception is thrown. - `@Ignore` — the method is skipped. - `@BeforeEach` / `@AfterEach` — run around **each** test method in the spec. - `@BeforeAll` / `@AfterAll` — run **once** around all tests in the spec. They are Kotest's annotations, even though they intentionally mirror the shape people already know from annotation-driven frameworks. Importing a same-named annotation from elsewhere is the classic first-day mistake: the class compiles, the run finds nothing, and the spec reports zero tests. ```kotlin class CalculatorTest : AnnotationSpec() { private lateinit var calc: Calculator @BeforeEach fun setUp() { calc = Calculator() } @Test fun `adds two positive numbers`() { calc.add(2, 3) shouldBe 5 } @Test(expected = ArithmeticException::class) fun dividesByZero() { calc.divide(1, 0) } @Ignore @Test fun notImplementedYet() { } } ``` ## Naming The test name is the **method name**. Kotlin's backtick syntax rescues readability — `` fun `adds two positive numbers`() `` reports as *adds two positive numbers* — but the name is still bound to an identifier, which means it must be unique within the class (the compiler enforces that for you, which is a small silver lining versus a flat string namespace) and cannot be computed. There is no way to say "name this test after the row of data it is running", because there is no code producing the registration. ## What the reflective model costs you 1. **No containers, no nesting.** Every test method is a root-level leaf. There is no grouping vocabulary at all. 2. **No `.config(...)`.** The lambda styles let you write `test("x").config(timeout = ...) { }` or `"x".config(invocations = 3) { }`. A method has nowhere to hang that builder; your per-test knobs are the annotation parameters — `expected` and `@Ignore` — plus whatever spec-level configuration you set by overriding spec properties. 3. **No dynamic registration.** In a DSL style the body is code, so a loop can register N tests. Here the test set is exactly the set of annotated methods in the compiled class. Data-driven testing therefore has to move inside a single method's body, where the failure of any row fails the one enclosing test rather than reporting per-row. 4. **Weaker integration with name-derived features.** Anything driven by readable, structured test paths — grouped report output, path-based filtering — gets less to work with when every name is an identifier at the root. ## What you keep The test bodies are still **suspending**, so suspend calls work directly; Kotest's matchers, lifecycle callbacks defined at spec level, and the rest of the framework apply normally. AnnotationSpec is a first-class Kotest spec, not a compatibility shim bolted on the side: it participates in the same lifecycle and the same test engine as the other styles. ## Why it exists Two honest reasons. First, **familiarity** — a team whose muscle memory is annotated test methods can adopt Kotest's matchers, property testing and extensions on day one without also learning a DSL. Second, **conversion friction**: an existing annotation-style test class can be brought under Kotest with mechanical changes (change the superclass, swap the annotation imports, rename lifecycle methods) rather than a rewrite, after which individual classes can be moved to a DSL style at leisure. The standing advice in practice is that AnnotationSpec is a **destination for old tests, not for new ones**: new specs are written in a style with containers and string names, because that is where Kotest's expressiveness lives. ## Interview traps Being asked "can I generate tests from a loop in AnnotationSpec?" is a trap for the reflective model — the answer is no, because there is no registration lambda to run. Being asked "which `@Test` did you import?" is a trap for the nested-annotation detail.

  • Why can't you generate tests dynamically in Kotest's AnnotationSpec the way you can in a DSL style?
    Because AnnotationSpec discovers tests by reflecting over the compiled class rather than by running a registration lambda. The set of tests is fixed at compile time as the set of annotated methods, so there is no code path in which a loop could register additional cases. Any data iteration has to happen inside one method body, which collapses all rows into a single reported test.
  • A developer converts a class to `AnnotationSpec` and the run reports zero tests. What is the most likely cause?
    They annotated the methods with a same-named `@Test` from another testing framework instead of Kotest's `AnnotationSpec.Test`. Kotest's annotations are nested on `AnnotationSpec`, so the import must resolve to those; with a foreign annotation the class compiles fine but Kotest's reflection finds nothing to run.
  • How do you get a readable, sentence-style test name in AnnotationSpec?
    Use Kotlin's backtick-quoted function names, since the reported name is the method name. That gives spaces and punctuation in the report but the name is still an identifier: it must be unique in the class and cannot be computed at run time from data.

saying these in an interview costs you the question

  • Assuming the `@Test` annotation comes from another framework rather than being nested on Kotest's AnnotationSpec
  • Claiming AnnotationSpec supports containers or nested tests
  • Expecting `.config(...)` or per-test timeout/invocations settings on an annotated method
  • Saying test names can be supplied as strings in AnnotationSpec
  • Believing AnnotationSpec is a wrapper that runs tests outside Kotest's normal lifecycle

context