Nine Tests That Are the Same Test
A team writes ParseSize(input string) (int, error) to turn human-readable sizes like 4kb and 2MB into bytes. The first week, the test file has three functions:
func TestParseSize_KB(t *testing.T) {
got, err := ParseSize("4kb")
if err != nil {
t.Fatalf("ParseSize(4kb) error = %v", err)
}
if got != 4096 {
t.Errorf("ParseSize(4kb) = %d, want 4096", got)
}
}
func TestParseSize_MB(t *testing.T) {
got, err := ParseSize("2MB")
if err != nil {
t.Fatalf("ParseSize(2MB) error = %v", err)
}
if got != 2097152 {
t.Errorf("ParseSize(2MB) = %d, want 2097152", got)
}
}
Three weeks later there are nine of them. Adding case ten means copying ten lines and editing four strings — the step where people forget one and ship a test that checks the old input. Renaming a field or changing the assertion touches every copy. And reading the file gives no instant answer to a question the reviewer actually has: which inputs are covered, and which are missing? A list of nine function names hides that; a list of rows shows it.
The fix is to move the variable part — input and expectation — into data, and keep the logic in one place:
func TestParseSize(t *testing.T) {
tests := []struct {
name string
in string
want int
wantErr bool
}{
{name: "kilobytes", in: "4kb", want: 4096},
{name: "megabytes", in: "2MB", want: 2097152},
{name: "gigabytes", in: "1GB", want: 1 << 30},
{name: "plain bytes", in: "512", want: 512},
{name: "unknown unit", in: "3pb", wantErr: true},
{name: "empty input", in: "", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseSize(tt.in)
if (err != nil) != tt.wantErr {
t.Fatalf("ParseSize(%q) error = %v, wantErr %v", tt.in, err, tt.wantErr)
}
if !tt.wantErr && got != tt.want {
t.Errorf("ParseSize(%q) = %d, want %d", tt.in, got, tt.want)
}
})
}
}
The table is now the coverage document: a reviewer scans six rows and sees that pb and the empty string are handled while 1.5kb is not — and can add it in one line. The loop body is written once, so an assertion change is a one-line change instead of nine. Each row runs as a subtest through t.Run, so go test -run 'TestParseSize/unknown' executes a single case and CI failures name themselves: --- FAIL: TestParseSize/unknown unit.
One detail decides whether this works at all: the subtest closes over tt. In a module whose go directive is 1.22 or later, the loop variable is fresh per iteration and the closure captures its own copy. In an older module, every closure captures the same variable and all subtests run the final row — a table where ten rows silently test one input. If the module predates 1.22, rebind inside the loop with tt := tt before t.Run.
What Each Column Is For
The first table works, but the columns need discipline, because every column exists to make one failure message self-explanatory.
Name is the identity of the case. It is what -run matches and what CI prints. Write "negative value rejected", not "case 3" — a failure named TestParseSize/case 3 sends the reader to the source; TestParseSize/negative value rejected already tells them what broke.
In is the only input. If a case needs several inputs, give them separate fields rather than encoding a protocol in one string. Rows should be readable top to bottom as a spec.
Want is exact, and errors are a boolean first. The (err != nil) != tt.wantErr expression looks cryptic and is precise: it fails when an error appeared that should not have, or when the expected error did not appear. Split the checks so a wrong error reports before a meaningless value comparison runs:
got, err := ParseSize(tt.in)
if (err != nil) != tt.wantErr {
t.Fatalf("ParseSize(%q) error = %v, wantErr %v", tt.in, err, tt.wantErr)
}
if tt.wantErr {
return // no value to compare; comparing the zero value would add noise
}
if got != tt.want {
t.Errorf("ParseSize(%q) = %d, want %d", tt.in, got, tt.want)
}
Use Fatalf inside the subtest, not the outer loop: a Fatal in the loop body stops the whole test and hides every later row, while a Fatal inside t.Run kills only that one case. This is the main reason each row gets its own subtest.
Format inputs with verbs that preserve them. %q for strings keeps "" visible where %v would print nothing, so an empty-input failure does not look like a missing message. %v or %#v for structs, %d for integers.
Extract shared assertions and mark them t.Helper. When four rows compare different types, write one helper per type rather than a sprawling generic one — the type-parameter version arrives with generic functions:
func wantErrIs(t *testing.T, got, want error) {
t.Helper()
if !errors.Is(got, want) {
t.Errorf("error = %v, want %v", got, want)
}
}
Parallel rows only when rows are independent. t.Parallel() inside each subtest runs cases concurrently and shortens a slow table. It is safe only when rows share nothing — no package-level counters, no common fixture path. Any row that mutates shared state must run sequentially, and any row reading a file should get its own t.TempDir copy first.
The finished test reads like a spec sheet: rows are the requirements, the loop is the verification procedure, and go test -v prints one line per requirement that passed.
When the Expected Value Is Not a Value
Exact equality covers integers and strings. Three common results do not fit got != want, and each needs a different shape of expectation column.
Errors matched by identity. A row that only records wantErr: true proves an error happened, not which one — a mistyped sentinel passes. Record the sentinel instead:
tests := []struct {
name string
in string
wantErr error
}{
{name: "unknown unit", in: "3pb", wantErr: ErrUnknownUnit},
{name: "negative", in: "-5", wantErr: ErrNegative},
{name: "ok", in: "4kb", wantErr: nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := ParseSize(tt.in)
if !errors.Is(err, tt.wantErr) {
t.Errorf("ParseSize(%q) error = %v, want %v", tt.in, err, tt.wantErr)
}
})
}
errors.Is(err, nil) is true exactly when err == nil, so one assertion handles both the success and failure rows. This is the same chain-walking check the error handling topic builds; a table is where it earns its keep.
Results that are structures with sub-parts. When only three fields matter, comparing the whole struct with reflect.DeepEqual couples the test to fields nobody cares about — the row fails when an unrelated field is added. Compare the fields that are the contract:
if got.Host != tt.wantHost || got.Port != tt.wantPort {
t.Errorf("ParseAddr(%q) = %+v, want host=%q port=%d", tt.in, got, tt.wantHost, tt.wantPort)
}
Results where equality is the wrong question. Floats from parsing or geometry need a tolerance; JSON round-trips need a semantic comparison where key order does not matter. Put the tolerance in the row so the reader sees how tight it is:
tests := []struct {
name string
in []float64
want float64
tol float64
}{
{name: "two values", in: []float64{0.1, 0.2}, want: 0.3, tol: 1e-9},
}
// ...
if math.Abs(got-tt.want) > tt.tol {
t.Errorf("sum = %v, want %v (±%v)", got, tt.want, tt.tol)
}
For outputs where you would otherwise hand-roll a deep comparison of nested structs, reach for a comparison helper library rather than growing reflect logic inside the test — the assertion code is exactly the code you do not want untested.
The rule for choosing the expectation column: if the result supports == and you mean exactly that, use the value; if failure needs an identity (errors.Is), store the sentinel; if failure needs a tolerance or a predicate, store the tolerance. When the expectation is itself a procedure — build a server, issue three requests, verify a call order — you have left table territory.
The Case Against the Table You Were About to Write
Tables optimize one shape: independent cases where inputs and expectations are data. Push other shapes into that mold and the test gets harder to read than the copy-paste it replaced.
Multi-step scenarios. Verify that after a failed payment, an order stays in pending and the customer is not charged. That is a sequence — act, assert, act, assert — with prose explaining why each step matters. Encoding it as columns (setup, step1, step2, step3) produces a row nobody can read. Write a narrative test with subtests for its phases:
func TestPaymentFailure_LeavesOrderPending(t *testing.T) {
order := seedOrder(t, Order{Status: "pending"})
if _, err := Pay(order.ID, cardDeclined()); err == nil {
t.Fatal("Pay succeeded with a declined card")
}
if got := statusOf(t, order.ID); got != "pending" {
t.Errorf("status after failure = %q, want pending", got)
}
if charged := chargeLog(t, order.ID); len(charged) != 0 {
t.Errorf("charges recorded = %v, want none", charged)
}
}
The three assertions document a policy. A reviewer argues with prose; they cannot argue with a row of fields.
Cases with heavy, differing setup. A row whose setup field is a 30-line function argument has moved the body of the test into the data. If every row needs the same fixture, build it once outside the table — or in a helper with t.TempDir. If rows need different fixtures, ask whether they are really one test.
Interaction order. When what you are verifying is "the repository was called once, before the cache," you are asserting on a collaborator's call sequence. That belongs with test doubles, where a recording fake captures calls; a table of inputs cannot express order.
Named regressions worth keeping visible. A row named "bug 1842: leading zero in exponent" is fine and stays greppable. But when the fix required a three-part explanation, a standalone test carrying a comment outlives the refactor that reshapes the table.
The decision in one line: inputs and one expected output, independently checkable — table; a scenario with phases, shared mutable state, or assertions about interaction — narrative test with subtests. Most suites are mostly tables with a handful of narrative tests, and both styles run in the same go test invocation, so the only cost of choosing wrong is readability. With the suite now organized, the next question is how much of the package the rows actually execute and whether the code they exercise is fast — coverage and benchmarks.
Quiz
1. A table-driven test passes locally on a module with `go 1.21` in go.mod: ten rows exist but only the last row's input is ever tested. What happened?
2. Rows use `wantErr error` sentinels with `errors.Is(err, tt.wantErr)` instead of a `wantErr bool`. What does this buy?
3. A table row calls t.Fatalf when the first field check fails, and CI reports exactly one failing row per run instead of every failing row. Where should the Fatal live?
4. You need to verify that a failed payment leaves the order pending, records no charge, and returns a specific sentinel — across three dependent steps. What is the right test shape?
Flashcards
Question
What are the four columns a table-driven test almost always needs?
Click to reveal answer
Answer
name (the subtest identity matched by -run and printed in CI), in (the input), want (the expected result), and an error expectation — either wantErr as a sentinel for errors.Is or a bool when any error will do. Extra columns exist only when a row genuinely differs, such as a float tolerance.
Question
Why must t.Fatal inside a table loop be called inside t.Run and not before it?
Click to reveal answer
Answer
Fatal stops the function it runs in. Before t.Run it ends the whole test and every later row goes unreported; inside the subtest callback it stops only that case, so the loop continues and the run shows every failing row at once.
Question
When does a closed-over table row silently test the wrong input?
Click to reveal answer
Answer
When the module's go directive is older than 1.22: the loop variable is a single binding, so every t.Run closure captures the same tt and executes with the final row's values. Fix with `tt := tt` inside the loop or by adopting go 1.22 loop semantics.
Question
How do you assert on a parsed result where only three struct fields are part of the contract?
Click to reveal answer
Answer
Compare those fields explicitly rather than reflect.DeepEqual on the whole struct. Deep equality couples the test to fields nobody is verifying, so adding an unrelated field breaks every row and obscures which expectation actually failed.
Question
What test shape replaces a table when the verification is a sequence of dependent steps?
Click to reveal answer
Answer
A narrative test: seed state, act, assert, act again, assert — with subtests for phases if useful. Tables assume rows are independent data; ordering, shared mutable state, and interaction verification belong in prose-style tests or with recording test doubles.
Revision Notes
Key Takeaways
- 1. Tables move the input and expectation into data so adding a case is one line and coverage is visible at a glance
- 2. Each row runs as its own subtest, which gives CI a named failure path and lets -run execute a single case
- 3. Error rows should carry sentinels checked with errors.Is — a bool expectation passes when the wrong error is returned
- 4. Fatal stops only the function it runs in, so it belongs inside the subtest callback to keep later rows running
- 5. Tables fit independent data-shaped cases; sequences, shared state, and call-order checks belong in narrative tests
Interview Tips
- • "Write tests for this parser" — reach for a table immediately and say why: inputs are values, outputs are values, cases grow over time
- • "How do you test the error cases?" — show wantErr as a sentinel plus errors.Is, and explain that a bool cannot tell ErrNegative from ErrUnknownUnit
- • "What's your least favorite testing pattern?" — tables pushed into multi-step scenarios; give the payment-failure example and describe the narrative alternative
- • "Why does -run use slashes?" — each level of nesting (package test, then subtest name) is a pattern segment, so one row is addressable from the command line
Cheat Sheet
Table-Driven Tests
Shape decision:
| Verification | Shape |
|---|---|
| independent input → one expected output | table row |
| error identity matters | row with wantErr error, assert errors.Is |
| float / approximate result | row with tol, compare math.Abs(got-want) <= tol |
| several fields form the contract | compare the fields, not the whole struct |
| dependent steps, shared state, call order | narrative test or recording fake |
Code template:
func TestParseSize(t *testing.T) {
tests := []struct {
name string
in string
want int
wantErr error // nil means success
}{
{name: "kilobytes", in: "4kb", want: 4096},
{name: "unknown unit", in: "3pb", wantErr: ErrUnknownUnit},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseSize(tt.in)
if !errors.Is(err, tt.wantErr) {
t.Fatalf("ParseSize(%q) error = %v, want %v", tt.in, err, tt.wantErr)
}
if tt.wantErr == nil && got != tt.want {
t.Errorf("ParseSize(%q) = %d, want %d", tt.in, got, tt.want)
}
})
}
}
Rules: name is the -run path (go test -run 'TestParseSize/unknown'); format strings with %q so empty inputs stay visible; t.Fatal inside t.Run only; add t.Parallel() inside the subtest only when rows share no state; bind tt := tt in modules older than go 1.22.
Continue Learning
Before You Start
- No prerequisites listed.