skip to content

Data-Driven Testing

Kotest's data-driven module runs one test body across many rows via withData or table/row helpers, generating a named test per row. Interviewers ask about it to contrast with JUnit's @ParameterizedTest and to see if you can keep generated test names readable.

on this pageshow

explore

questions

9

When a Kotest table-driven check (`forAll` over a `table(headers(...), row(...), ...)`) fails on some rows, do the remaining rows still run, and what does the resulting failure message contain?

level: middleimportance: must knowfreq 30%

answer

  1. all rows run, failures aggregated into one error
  2. headers(...) label the values in the failure message
  3. one Kotest test for the whole table, one report node
  4. block is an assertion block, not a boolean predicate
  5. io.kotest.data.forAll vs kotest-property forAll — import trap

basics

~20 s

Every row runs. Kotest collects the failures instead of stopping at the first one and then throws a single error that lists each failing row with its header/value pairs and that row's assertion error, so you see all bad rows in one run.

solid answer

~50 s

Kotest's table `forAll` iterates every row, catches the failure from each one, and aggregates them. Nothing stops at the first bad row, so a single run tells you whether one edge case is broken or the whole function is. The thrown error names each failing row using the labels you supplied to `headers(...)` paired with that row's values, plus the underlying assertion message. That is the entire reason `headers` exists — the lambda parameters are positional and Kotest cannot recover their names, so the header strings are what make the message readable. The key structural consequence: the whole table lives inside **one** Kotest test. The report shows one node, pass or fail, not one per row. So per-row lifecycle callbacks do not fire per row, you cannot re-run or filter a single row, and the aggregated message is your only per-row detail. That is exactly the trade-off against `withData`, which registers a test per case.

code

kotlin · 19 lines
kotlin
import io.kotest.data.forAll
import io.kotest.data.headers
import io.kotest.data.row
import io.kotest.data.table

class MaxTest : FunSpec({
   val cases = table(
      headers("a", "b", "expected max"),
      row(1, 5, 5),
      row(9, 2, 9),
      row(-1, -7, -1),
   )

   test("max of two numbers") {
      forAll(cases) { a, b, expected ->
         maxOf(a, b) shouldBe expected
      }
   }
})

go deeper

for a junior

Know that all rows execute and that a failure message shows which row's values broke, thanks to headers(...).

for a middle

Explain the aggregation, what the message contains, and that the whole table is a single test node.

for a senior

Draw the operational consequences — no per-row re-run or filtering, callbacks fire once — and flag the assertion-vs-boolean silent-green bug.

for a principal

Position it as a reporting-granularity choice: tables trade per-case addressability for compactness, so reserve them for small closed sets and use per-case registration where triage matters.

## The shape being discussed Kotest's table DSL lives in `io.kotest.data`: ```kotlin val table = table( headers("a", "b", "max"), row(1, 5, 5), row(9, 2, 9), row(3, 3, 3), ) test("max of two numbers") { forAll(table) { a, b, max -> max(a, b) shouldBe max } } ``` There is also a headerless form, `forAll(row(1, 5, 5), row(9, 2, 9)) { a, b, max -> ... }`, which is convenient for throwaway tables but gives the failure message no labels to work with. ## Every row runs The naive implementation would let the first `AssertionError` propagate and abort. Kotest does not do that: it iterates all rows, catching the failure from each, and only afterwards raises one aggregated error. This matters because the diagnostic value of a table is the *pattern* of failures. "Row 4 failed" and "rows 4, 5, 6 and 7 — every negative input — failed" call for completely different debugging, and you only learn the difference if all rows executed. The same design appears elsewhere in Kotest (soft assertions aggregate multiple failures rather than stopping at the first); it is the same principle applied to rows. ## What the message contains The aggregated error names each failing row by pairing the strings you passed to `headers(...)` with that row's values, followed by the assertion error that row produced. So a failure reads as "this row, these values, this mismatch" rather than a bare `expected:<9> but was:<2>` with no clue which inputs produced it. This explains what `headers(...)` is *for*, which candidates routinely get wrong. Headers do not name the lambda's parameters — the lambda takes positional parameters you name yourself in the lambda signature, and Kotest has no way to read those names at runtime. Headers exist purely so the failure output can label values. Omit them (headerless `forAll(row(...), ...)`) and you lose that labelling; keep them and your failure message is self-describing. ## The structural consequence: one test, N rows The table executes **inside a single Kotest test**. The report tree shows one node. Everything follows from that: - **Pass/fail is all-or-nothing at the report level.** Three failing rows out of twenty is one red test, not three red tests among seventeen green ones. - **Per-test lifecycle callbacks fire once**, for the enclosing test, not per row. If you need fresh state per case, the table gives you no hook — you must build it inside the lambda, or use `withData` instead. - **You cannot address a single row.** The IDE, and name-based filtering, can select the enclosing test but not row 7. Re-running one case means commenting rows out. - **The aggregated message is the only per-row detail you get**, which is exactly why it is worth using `headers(...)` and keeping row values small and printable. ## Two practical traps **Import collision.** `forAll` is a name Kotest uses in more than one place — the table DSL's `forAll` in `io.kotest.data` and the property-testing `forAll` in the `kotest-property` module are different functions with different contracts. An accidental import gives you a compile error that reads oddly, or the wrong function entirely. When a table-based test suddenly refuses to compile, check the import first. **Assertions vs. booleans.** The table `forAll` block is an **assertion** block: you assert inside it, and a row fails when its assertions throw. It is not a predicate whose `false` return means failure. Writing `forAll(table) { a, b, max -> max(a, b) == max }` compiles happily (the lambda just returns a `Boolean` that nobody reads) and the test passes no matter what. This is one of the most common silent-green bugs in table-driven Kotest tests. ## Rules of thumb - Always supply `headers(...)` for any table you intend to keep; the two extra seconds pay for themselves the first time it fails. - Keep row values small and with readable `toString()` — the values are what appears in the failure message. - If you find yourself wanting to re-run or filter individual rows, that is the signal to move from a table to `withData`, which registers a test per case. ## How to say it in an interview "All rows run — Kotest collects failures rather than stopping at the first — and it throws one error listing each failing row as header/value pairs plus that row's assertion message. The whole table is one test though: one report node, per-test callbacks fire once, and you can't re-run a single row. That's the trade-off against `withData`."

  • What exactly do the strings passed to Kotest's `headers(...)` do?
    They label the values in the failure output. When a row fails, the aggregated error pairs each header string with that row's value so you can see which inputs produced the failure. They do not name the lambda's parameters — those are positional and named in the lambda signature — and they are not used for filtering or reporting anywhere else. Omitting headers costs you readable failure messages.
  • Why does a table-driven test with a boolean lambda body sometimes pass no matter what?
    Because Kotest's table `forAll` block is an assertion block, not a predicate. It runs the body and treats a thrown error as a row failure; a returned `Boolean` is simply discarded. Writing `a + b == expected` compiles, returns `false` for broken rows, and nobody looks at it — so the test is permanently green. The fix is to assert inside the block, e.g. with `shouldBe`.

saying these in an interview costs you the question

  • "It stops at the first failing row" — all rows run and the failures are aggregated into one error.
  • "headers(...) names the lambda parameters" — it only labels values in the failure message.
  • "Each row becomes its own test in the report" — the whole table runs inside one test node.
  • "Returning false from the block fails the row" — the block is an assertion block; a returned boolean is ignored and the test stays green.
  • "beforeTest runs before each row" — per-test callbacks fire once for the enclosing test, not per row.

context

open as a page

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%

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.

open as a page

How is Kotest's table DSL typed — what do `headers(...)`, `row(...)` and `table(...)` produce, how does the `forAll` lambda receive the columns, and what are the limits of that design?

level: middleimportance: should knowfreq 24%

basics

~20 s

Each arity has its own generated type — row(1, "a") is a two-column row, and table(headers, rows...) fixes both the column count and each column's type. forAll then hands the lambda one typed parameter per column. Limits: fixed arity, one type per column across all rows, positional access only.

open as a page

Kotest's `withData` generates test names from the input values. What mechanisms does Kotest give you to override that generated name, and when would you pick each one?

level: middleimportance: should knowfreq 26%

basics

~20 s

Four hooks: implement WithDataTestName on the type, annotate the class @IsStableType so its toString is trusted, pass the nameFn overload withData({ "case ${it.id}" }, values), or pass a Map<String, T> whose keys become the names verbatim.

open as a page

Where can Kotest's `withData` be placed inside a spec — at the spec root, inside a container, or inside another `withData` — and what kind of node does each element become?

level: seniorimportance: should knowfreq 24%

basics

~20 s

It can sit at the spec root, inside a container such as FunSpec's context, and inside another withData. Each element is registered as a node whose body is a container scope: it reports as a leaf test if nothing nested is registered, and as a container if the body registers more tests.

open as a page

For a large parameterized suite in Kotest, how would you decide between running cases through a `table(...)` with `forAll` and registering them with `withData` — and what convention would you set for a team?

level: principalimportance: should knowfreq 22%

basics

~20 s

A table runs all rows inside one test: one report node, callbacks once, no per-row re-run, failures aggregated into one message. withData registers a test per case: individually named, reported, filtered and re-runnable. Choose by whether per-case triage matters more than compactness.

open as a page

Kotest's table DSL offers `forNone` alongside `forAll`. What does `forNone` actually assert about each row, and how do candidates typically misuse it?

level: seniorimportance: nice to knowfreq 16%

basics

~20 s

forNone inverts the pass condition: the block must FAIL for every row. A row that runs the block without throwing is what makes the test fail. It is not a predicate — a block returning false asserts nothing, so a boolean body makes forNone pass or fail for the wrong reason.

open as a page

Two elements passed to Kotest's `withData` produce the same generated test name. What does Kotest do, how do you change that behaviour, and why does it matter operationally?

level: seniorimportance: nice to knowfreq 18%

basics

~20 s

Kotest requires unique names within a scope, so its DuplicateTestNameMode decides: the 5.x default Warn logs a warning and appends an index to disambiguate. Set duplicateTestNameMode = DuplicateTestNameMode.Error in AbstractProjectConfig to fail instead. Duplicates break re-runs, filtering and CI history.

open as a page

Your codebase has a large data-driven test suite built on Kotest's `withData`, and generated test names have become a maintenance problem. How would you set a naming and reporting convention for it, and what would you enforce automatically?

level: principalimportance: nice to knowfreq 14%

basics

~20 s

Treat generated names as identifiers: require inputs to be data classes or implement WithDataTestName, use nameFn for third-party or binary payloads, wrap each withData in a named container, and enforce DuplicateTestNameMode.Error in project config so bad names fail the build.

open as a page