Unit tests

Test boundaries with tables

Cover a family of related edge cases with data-driven tests while preserving useful case names and diagnostics.

Validation bugs live at the edges. Empty versus one character. Two hundred characters versus two hundred and one. A valid year versus a year that can’t exist.

When I have a family of related cases like this, I put them in a table. Same setup for every row, one row per case, and the missing cases become easy to spot.

A table of cases

Here are two rows for the Books API validator. Each row has a name, an input, and the field we expect to be rejected:

const cases = [
  {
    name: 'rejects an empty title',
    input: { title: '', author: 'A' },
    field: 'title'
  },
  {
    name: 'rejects a blank author',
    input: { title: 'Dune', author: '  ' },
    field: 'author'
  }
]

for (const { name, input, field } of cases) {
  test(name, () => {
    const result = validateBook(input)
    assert.equal(result.ok, false)
    assert.equal(result.field, field)
  })
}

Notice we call test() inside the loop. Each row becomes its own test, with its own name in the output. If you put the loop inside a single test(), the first bad row stops the test and hides every failure after it.

Pick the values that matter

A boundary is a value where behavior changes. If titles allow 1 to 200 characters, the informative values are 0, 1, 200, and 201. A title of 73 characters tells you nothing that 1 didn’t already.

For a publication year, ask more questions. Is the limit inclusive? Do we accept the string '1965' or only the number? What happens with null, an empty string, 1965.5, or NaN? Each answer is a row.

Name every row

The name is the failure message. rejects a blank author tells you which rule broke. case 3 sends you back to the source to count rows.

Keep rows alike

Tables work when every row exercises the same decision. If one row tests title validation and another tests a repository timeout, their setup and their evidence are different. Two separate tests will read better than one clever table.

Always include at least one valid row too. A validator that rejects everything makes an all-invalid table look perfect.

Try this: add the valid minimum and maximum for the title, plus one invalid value on each side. Then open the implementation and change <= to <. A boundary row should turn red. If only a middle value catches it, or nothing does, the table is missing the case that matters.

Lesson completed