skip to content

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