skip to content

When you drive tests from a collection of values with Kotest's `withData`, how does the framework derive the name of each generated test, and what happens when the elements are not data classes?

level: middleimportance: must knowfreq 32%

answer

  1. WithDataTestName → @IsStableType → data class → type fallback
  2. identity hash = unstable names, so Kotest refuses toString
  3. one node per element, name computed at registration
  4. plain class = N tests, all the same name
  5. nameFn/map overload when the type isn't yours

basics

~20 s

Kotest asks the element for a stable identifier: a WithDataTestName implementation wins, then an @IsStableType-annotated class's toString, then a data class's toString. Anything else falls back to a type-derived name, so every case ends up named the same.

solid answer

~50 s

`withData` registers one test node per element, and each node needs a name. Kotest computes a *stable identifier* from the value in a fixed order: if it implements `io.kotest.datatest.WithDataTestName`, `dataTestName()` is used; else if its class is annotated `@IsStableType`, `toString()` is used; else if it is a Kotlin `data class`, the generated `toString()` is used; otherwise Kotest falls back to a name derived from the value's **type**, not its contents. The fallback exists because a default `toString()` on an ordinary class prints an identity hash (`Input@6d06d69c`), which changes every run — names would be unstable, so reruns, filters and CI diffs would break. Kotest prefers a boring stable name over an unstable unique one. The practical symptom: feed `withData` a non-data class and every case shows up under the same name, and Kotest's duplicate-name handling starts appending indexes. The fix is a data class, `WithDataTestName`, `@IsStableType`, or the explicit `nameFn`/map overloads.

code

kotlin · 16 lines
kotlin
data class Triple3(val a: Int, val b: Int, val c: Int)

class NamesTest : FunSpec({
   context("pythagorean triples") {
      // names: "Triple3(a=3, b=4, c=5)", "Triple3(a=6, b=8, c=10)"
      withData(Triple3(3, 4, 5), Triple3(6, 8, 10)) { (a, b, c) ->
         (a * a + b * b) shouldBe c * c
      }
   }

   context("plain class inputs") {
      class Input(val n: Int) // not a data class, no stable toString
      // every generated test gets the same base name; Kotest appends indexes
      withData(Input(1), Input(2)) { it.n shouldBeGreaterThan 0 }
   }
})

go deeper

for a junior

Know that withData produces one test per element and that using a data class is what makes the names readable.

for a middle

State the resolution order (WithDataTestName → @IsStableType → data class → type fallback) and explain why an unstable toString() is refused.

for a senior

Connect it to operations: unstable or duplicated names break IDE re-runs, name-based filtering, and CI flaky-test tracking; recommend DuplicateTestNameMode.Error and nameFn for large or binary payloads.

for a principal

Frame test names as identifiers in a reporting contract and set a codebase convention: inputs are data classes or implement WithDataTestName, names stay short and deterministic, collisions fail the build.

## Why names are generated at all Kotest's data-driven helpers live in `io.kotest.datatest` (artifact `kotest-framework-datatest`). `withData(a, b, c) { ... }` does not run one test with a loop inside it — it **registers a separate test node per element**. That is the whole point: each case appears in the IDE tree and the CI report on its own line, passes or fails independently, and can be re-run individually. A test node needs a name, and that name has to come from somewhere. Since you never wrote one, Kotest has to derive it from the element itself. ## The resolution order Kotest computes what it calls a *stable identifier* for the value, in this order: 1. **`WithDataTestName`** — if the value implements `io.kotest.datatest.WithDataTestName`, its `dataTestName()` return value is the test name. This is the explicit, type-owned hook. 2. **`@IsStableType`** — if the value's class carries the `io.kotest.datatest.IsStableType` annotation, Kotest trusts the class's `toString()` and uses it. This is how you opt a non-data class in. 3. **`data class`** — if the class is a Kotlin data class, its compiler-generated `toString()` is used: `Triple(a=3, b=4, c=5)`. 4. **Fallback** — otherwise Kotest derives the name from the value's **type**, not its contents. Strings, numbers and other values with a meaningful `toString()` behave sensibly; the interesting case is your own domain types. ## Why the fallback is a type name and not `toString()` A class that does not override `toString()` inherits `Any.toString()`, which prints something like `Input@6d06d69c` — the class name plus an **identity hash code**. That value changes between JVM runs. If Kotest used it, your test names would change every single run. Consequences: the IDE cannot map a re-run request to a node, name-based test filters never match twice, CI history shows every test as new-and-then-deleted, and flaky-test dashboards keyed on test names become noise. Kotest deliberately trades uniqueness for stability: a repeated but stable name is recoverable, an unstable unique name is not. ## What it looks like when it goes wrong You pass a list of ten non-data-class inputs and the report shows ten nodes that all carry the same name. Because Kotest requires unique names inside a scope, its duplicate-name handling (`DuplicateTestNameMode`, default `Warn` in Kotest 5.x) kicks in: it warns and disambiguates by appending an index, so you get `Input`, `Input (1)`, `Input (2)` and so on. Nothing fails, which is exactly why the problem survives code review — the suite is green and the report is useless. When a case fails you cannot tell which input it was without reading the assertion message. ## The four fixes, in order of preference - **Make the input a data class.** Usually the input is a value object anyway; a data class gives you `equals`, `hashCode`, `toString` and readable names for free. - **Implement `WithDataTestName`.** Best when the type is yours and the natural name is not its full `toString()` — e.g. return just an id or a short label. - **Annotate with `@IsStableType`.** Best when the class already has a hand-written, deterministic `toString()` you are happy with, and you only need Kotest to trust it. - **Use the `nameFn` overload or the map-of-names overload** at the call site. Best when the type is third-party (you cannot annotate it) or when different tests want different labels for the same type. ## Secondary gotchas - A data class `toString()` includes **every** property. Feed it a byte array, a long JSON blob or a nested aggregate and the generated name is a paragraph — technically stable, practically unreadable. Override with `nameFn`. - Data class `toString()` on arrays prints the array's identity (`[B@1f2a3b`), which reintroduces instability. Wrap or name explicitly. - Names are computed when the tests are **registered**, before bodies run, so mutating the element inside the test body does not change its name. - Two distinct elements can still collide if their names are equal (see duplicate-name handling); equality of the values is not what Kotest de-duplicates on. ## How to say it in an interview "`withData` registers one test per element and derives the name from the value: `WithDataTestName`, then `@IsStableType`, then data-class `toString`, then a type-based fallback. The fallback is there because a default `toString` embeds an identity hash and would make names unstable across runs. If your inputs are plain classes you get N identically-named tests, and you fix it with a data class or an explicit name function."

  • Why doesn't Kotest simply call `toString()` on every element and be done with it?
    Because the default `Any.toString()` prints the class name plus an identity hash code, which differs on every JVM run. Test names are identifiers: the IDE, name-based filters and CI history all key on them. Kotest would rather give you a stable name it can trust than a unique one that changes each run, so it only uses `toString()` when the type has signalled that its `toString()` is stable — via `data class`, `@IsStableType`, or `WithDataTestName`.
  • You are passing a third-party class you cannot annotate or subclass. How do you get readable names?
    Use the call-site overloads: `withData(nameFn = { "case ${it.someField}" }, values) { ... }`, or the map overload `withData(mapOf("empty cart" to c1, "one item" to c2)) { ... }` where the keys become the test names verbatim. Both keep the naming decision local to the test, which is also useful when the same type deserves different labels in different specs.
  • What happens if two different elements produce the same generated name?
    Kotest requires unique test names within a scope, so its `DuplicateTestNameMode` decides: the Kotest 5.x default is `Warn`, which logs a warning and disambiguates by appending an index. You can set `duplicateTestNameMode` in `AbstractProjectConfig` to `Error` to make collisions fail the build, which is the safer setting for a large data-driven suite because it turns an unreadable report into a build failure you must fix.

It is like labelling boxes in a warehouse: Kotest will happily write what is inside the box if the box declares its contents reliably (data class, @IsStableType, WithDataTestName). If it cannot trust the label, it writes only the box type rather than a serial number that changes every time you look.

saying these in an interview costs you the question

  • "withData runs one test with a loop inside it" — it registers a separate test node per element.
  • "The name is always the element's toString()" — only for data classes, @IsStableType types, or WithDataTestName; otherwise Kotest uses a type-derived fallback.
  • "Duplicate names make the run fail" — by default (Warn) they are silently disambiguated with an index; only DuplicateTestNameMode.Error fails.
  • "I'll just override toString() on my class and Kotest will pick it up" — an override alone is not enough; the class must be a data class or carry @IsStableType for Kotest to trust it.
  • "Names are computed when the test body runs" — they are computed at registration time, before any body executes.

context