AlgoMaster Logo

Table-Driven Tests

High Priority18 min readUpdated June 6, 2026

The same test logic applied to a dozen different inputs is the most common shape a Go test takes. Copy-pasting that logic for each case bloats the file and hides the actual variation behind boilerplate. Table-driven tests collapse the repetition into a slice of cases, leaving one loop that runs them all and one place to add the next case. This chapter covers the pattern, the naming conventions, the failure-reporting tricks that make it usable in practice, and the cases where reaching for a table is the wrong call.

The Pattern

A single TestXxx function can run several independent checks against the same code path. The table-driven version takes that further. Instead of writing each check by hand, you put the inputs and expected outputs into a slice of structs and walk the slice with a for loop.

Here's a non-table version first, so the contrast is obvious. Imagine testing a Total function that adds prices in a cart.

Three functions, three nearly identical bodies. Adding a fourth case means another copy of the same skeleton. The table-driven rewrite collapses all three into one function with a slice:

The shape is always the same: a slice of anonymous struct values, a loop, one assertion. Add a case by adding a row. Change the assertion logic and every case picks up the change automatically.

The loop reads cases from the slice, calls the function with the case's inputs, and reports any mismatch with enough context that you can tell which case failed without re-reading the source.

The Anonymous Struct

The slice element is almost always an anonymous struct declared inline. This is Go's idiomatic way to define a one-off record type without polluting the package with a named type that has no use outside this test.

Three rules of thumb for the struct fields:

  1. First field is `name`, a short human-readable description of the case. Used in error messages.
  2. Middle fields are inputs, named after the parameters of the function under test.
  3. Last field is `want` (or wantErr when the test expects an error). This is the convention in the standard library and most Go codebases, and matching it makes your tests look familiar to anyone reading them.

The struct literal uses positional initialization (no field names) for compactness. Once a table has ten cases, positional rows read like a small spreadsheet, and named-field rows turn the same data into a wall of repetition. Use field names when the case rows have many optional fields, when the field meanings aren't obvious from context, or when you keep adding fields and existing rows would otherwise break.

Named fields here let the "negative price" row leave want at the zero value without confusing the reader. With positional fields, you'd have to write {name: "negative price", prices: []float64{-1}, want: 0, wantErr: true} or accept the silent zero. Either form is correct; the right choice depends on how much your cases vary.

Naming Cases

Case names show up in failure output, so they need to be descriptive enough that a developer reading a CI log can tell what broke without opening the test file. Three rules cover most situations:

  1. Describe the input, not the implementation. "empty cart" beats "loop doesn't execute". "10 percent off $100" beats "discount math".
  2. Keep them short. A case name is a label, not a sentence. Aim for two to five words.
  3. Use lowercase by default. Subtest names appear in -run regular expressions and in test output. Lowercase reads better as data, and it sidesteps shell-quoting issues with regexes that mix cases.

Here's a table of names that work for an ApplyDiscount(price, percent float64) float64 function:

Case nameInputsWhy this name
"zero percent"price=100, percent=0Boundary at the lower end
"hundred percent"price=100, percent=100Boundary at the upper end
"typical discount"price=100, percent=10Happy path
"negative price rejected"price=-10, percent=5, wantErr=trueError case
"price above cents precision"price=1.005, percent=0Rounding edge case

Notice how the names hint at why the case exists. "zero percent" is there because zero is a boundary. "price above cents precision" is there because float rounding can bite. Reading the case names should give a reader the shape of what your tests cover without opening the function under test.

A second pattern that works well is "input description -> expected behavior", especially for error cases:

The form is a bit longer but spells out the expected behavior, which is useful when several cases share inputs but differ in what should happen.

Failing One Case Without Short-Circuiting

The naive loop calls t.Errorf on a mismatch and keeps running. That's the right default: you want to know about every failing case in one run, not fix the first failure, re-run, fix the second, re-run, and so on.

Errorf marks the test as failed and returns, so the next iteration runs normally. Three broken cases produce three error lines, all visible in the output.

The trap is t.Fatalf. Fatalf aborts the test function entirely, which means a failure in case 2 prevents case 3 from running:

That loop short-circuits at the first failure. Two real bugs report as one. The fix is to use Errorf for case mismatches and reserve Fatalf for setup problems that affect every case, like a file that won't open or a database connection that won't initialize.

There's a middle ground: when a single case's setup fails (a precondition the case relies on), you want to skip the rest of that case but still run the other cases. The cleanest tool for that is t.Run, which Go's testing package provides for exactly this purpose. For now, the rule is: use Errorf inside the loop, use Fatalf only for problems that make the whole table impossible to run.

The got/want Convention

The error message format is part of the pattern. Go developers expect to see got = X, want Y in test output, and writing it any other way makes the test harder to read at a glance.

That format gives you four pieces of information on one line: which case failed, what input went in, what came out, and what should have come out. A CI log can be skimmed at scrolling speed and you still notice the pattern.

Variations that work for different function shapes:

A common variation is to separate the failure into two lines when the values are long (a slice of products, a big struct):

The two-line form is easier to diff visually for large outputs. Either form is correct, but stay consistent within a single test file.

One mistake to avoid is dropping the input from the message. A line that says Total = 16.48, want 16.49 doesn't tell you which case produced it, so you have to cross-reference the case order in the source. Always include the case name or the input in the format string.

When Table Tests Pay Off

Tables work well when:

  1. The function has clear inputs and a clear expected output. Pure functions are the ideal target: same inputs always produce the same outputs, and the table maps one to the other.
  2. There are several distinct cases worth covering. Three or more usually justifies a table. Two cases might still be cleaner as two separate TestXxx functions.
  3. The cases differ only in data, not in setup or assertion logic. If every case needs a different mock or a different post-check, the table pattern starts to fight you.

Functions in an e-commerce package that fit the pattern:

  • Total(items []Item) float64: input is a slice, output is a number.
  • ApplyDiscount(price, percent float64) (float64, error): two scalar inputs, scalar output plus error.
  • IsInStock(product Product) bool: one struct in, one bool out.
  • FormatPrice(cents int) string: one int in, one string out.

A table-driven test for IsInStock looks like this:

Four cases, one loop, one assertion. Adding a fifth case is one new line.

When Tables Hurt

Tables are not always the answer. Three signs to use separate TestXxx functions instead:

  1. Cases need very different setup. If one case needs a mock database, another needs a fake HTTP server, and a third needs a temp directory, cramming them all into one table makes the loop body branch on the case name. That's not a table, that's a switch statement pretending to be a table.
  1. The assertion logic differs per case. If case A checks one field, case B checks a different field, and case C checks an error type, the body of the loop becomes a tree of if c.name == .... Each case is fighting the others. Better to give each one its own function.
  1. The table is so large it obscures intent. A table with 80 rows might cover every edge case, but a reader can't tell what's important and what's filler. Pick the cases that reveal the function's contract (boundaries, errors, the happy path) and trim the rest. Coverage without comprehension is worse than coverage with a clearer point.

A toy example of a bad table:

There's no single function under test. The switch is doing the real work, the table is just data for the switch. Three separate test functions would be clearer and shorter.

Shared State Across Cases (Common Mistake)

A common trap is cases that share mutable state through a captured variable or a slice header.

This test passes, but only because the case order matches the expected lengths. Reorder the cases (which t.Run with parallelism would do) and the test breaks. The right approach is to reinitialize the state inside the loop, not before it:

Each case rebuilds the cart from scratch. The cases are now order-independent, and you can shuffle them, parallelize them, or run a single one with -run and still get the same result. Order independence is the property that makes a test trustworthy.

The same trap shows up with maps and pointers when the cases all hold the same pointer:

The first case sets sharedProduct.Quantity to whatever, the second mutates it, and now case 1's data is gone. The fix is to construct the product inside the case row or inside the loop body, not as a shared variable.

Missing Case Names

Another mistake is leaving the case name out of the struct or the error message. The slimmer the struct, the more tempting it is:

The test still works, but when a case fails the error message is Total([4.99 1.50 9.99]) = 16.49, want 16.48 with no other context. For three cases that's fine. For thirty, scrolling back through a CI log to find which case index corresponds to which intent is a small but real cost.

A name field also makes the cases easier to read in the source. Compare:

versus:

The second form tells you why each case is in the file. The first form makes you guess. The cost is one extra column; the benefit is years of less squinting at test failures.

Putting It Together

A complete table-driven test for ApplyDiscount that handles errors, edge cases, and good error reporting looks like this:

The continue after the error check skips the value check when the error already mismatched, so a case expecting an error doesn't get noise from also reporting a zero-vs-zero comparison. And errors.Is is suited for comparing errors, not ==, because it walks wrapped error chains.

Quiz

Table-Driven Tests Quiz

10 quizzes