Table-Driven Tests in Go: The Idiomatic Way
Reviewer’s note: Table-driven tests are how experienced Go developers test almost anything with more than one interesting input. This tutorial shows you the canonical pattern, then how to scale it: subtests, parallel cases, error tables, map keys, and golden files. Every example is compile-tested on Go 1.24 with real
go testoutput.
Works with Go 1.22+ (all examples tested on Go 1.24.7). The Go 1.22 loop-variable change matters here, and this article shows exactly why.
Why the table is the default way Go developers test
Most functions worth testing behave differently across a handful of inputs: a happy path, an empty input, a boundary, a malformed value. The naive approach is one test function per case. That produces a wall of near-identical code where only two literals change per function, and a bug in your assertion logic has to be fixed in every copy.
A table-driven test inverts that. You describe the cases as data (a list of input plus expected output), then run one small loop that applies the same assertion to every row. The test logic exists once. Adding a case is one line. This is idiomatic enough that Go’s own standard library uses it everywhere, and the Go Wiki entry on table-driven tests treats it as the recommended default. Once you internalize the shape, you will reach for it before you reach for a plain assertion.
If you are new to Go’s testing tool at all, start with the Go testing pillar and the unit testing guide first, then come back here to scale up.
The problem: copy-pasted test bodies
Here is a Slugify function that turns a title into a URL slug. It lowercases, converts spaces to hyphens, strips punctuation, and trims stray hyphens.
package slug
import (
"strings"
"unicode"
)
// Slugify turns a title into a URL-safe slug:
// lowercase, spaces to hyphens, punctuation stripped, no leading/trailing hyphen.
func Slugify(title string) string {
var b strings.Builder
lastHyphen := false
for _, r := range strings.ToLower(title) {
switch {
case unicode.IsLetter(r) || unicode.IsDigit(r):
b.WriteRune(r)
lastHyphen = false
case r == ' ' || r == '-' || r == '_':
if b.Len() > 0 && !lastHyphen {
b.WriteRune('-')
lastHyphen = true
}
}
}
return strings.Trim(b.String(), "-")
}
The copy-paste way to test it looks like this:
func TestSlugifyBasic(t *testing.T) {
got := Slugify("Hello World")
if got != "hello-world" {
t.Errorf("Slugify(%q) = %q, want %q", "Hello World", got, "hello-world")
}
}
func TestSlugifyPunctuation(t *testing.T) {
got := Slugify("Go 1.24: What's New!")
if got != "go-124-whats-new" {
t.Errorf("Slugify(%q) = %q, want %q", "Go 1.24: What's New!", got, "go-124-whats-new")
}
}
func TestSlugifyTrim(t *testing.T) {
got := Slugify(" spaced out ")
if got != "spaced-out" {
t.Errorf("Slugify(%q) = %q, want %q", " spaced out ", got, "spaced-out")
}
}
Three functions, and the only thing that differs between them is two string literals. Want to test what happens with an empty string, or repeated separators? Copy a fourth function. The assertion (if got != want) is duplicated three times, so if you decide the message should include the case name, you edit it three times. This does not scale past a few cases.
The canonical pattern: a slice of structs with name, input, and want
The idiomatic version defines the cases as a slice of anonymous structs, then loops:
package slug
import "testing"
func TestSlugify(t *testing.T) {
tests := []struct {
name string
title string
want string
}{
{"basic", "Hello World", "hello-world"},
{"strips punctuation", "Go 1.24: What's New!", "go-124-whats-new"},
{"trims surrounding space", " spaced out ", "spaced-out"},
{"collapses repeats", "a -- b", "a-b"},
{"empty string", "", ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := Slugify(tc.title)
if got != tc.want {
t.Errorf("Slugify(%q) = %q, want %q", tc.title, got, tc.want)
}
})
}
}
Three fields per row: name (a human label), the input(s), and want (the expected result). The struct is anonymous because it exists only for this test; there is no reason to give it a package-level name. tc is the conventional loop variable, short for “test case.”
Run it with -v and every case shows as its own line:
=== RUN TestSlugify
=== RUN TestSlugify/basic
=== RUN TestSlugify/strips_punctuation
=== RUN TestSlugify/trims_surrounding_space
=== RUN TestSlugify/collapses_repeats
=== RUN TestSlugify/empty_string
--- PASS: TestSlugify (0.00s)
--- PASS: TestSlugify/basic (0.00s)
--- PASS: TestSlugify/strips_punctuation (0.00s)
--- PASS: TestSlugify/trims_surrounding_space (0.00s)
--- PASS: TestSlugify/collapses_repeats (0.00s)
--- PASS: TestSlugify/empty_string (0.00s)
PASS
ok example.com/slug 0.002s
Adding a case is now a single line in the slice. The assertion lives in exactly one place.
Running cases with t.Run so failures point at the right row
The t.Run(tc.name, func(t *testing.T) { ... }) call is doing the real work here, and it is what separates a good table from a bad one. It creates a subtest: a named, isolated child test. Two things fall out of that.
First, failures name the case. Suppose the “strips punctuation” row expected the wrong value. The output is:
=== RUN TestSlugify/strips_punctuation
slug_test.go:22: Slugify("Go 1.24: What's New!") = "go-124-whats-new", want "go-1.24-whats-new"
=== RUN TestSlugify/trims_surrounding_space
...
--- FAIL: TestSlugify (0.00s)
--- PASS: TestSlugify/basic (0.00s)
--- FAIL: TestSlugify/strips_punctuation (0.00s)
--- PASS: TestSlugify/trims_surrounding_space (0.00s)
--- PASS: TestSlugify/collapses_repeats (0.00s)
--- PASS: TestSlugify/empty_string (0.00s)
Every other case still ran and still reported its own result. One broken row does not hide the rest, and the per-case PASS/FAIL summary tells you exactly which input broke.
Second, subtests are individually addressable. The -run flag takes a slash-separated path, so you can execute a single row while debugging:
$ go test -run 'TestSlugify/empty_string' -v
=== RUN TestSlugify
=== RUN TestSlugify/empty_string
--- PASS: TestSlugify/empty_string (0.00s)
PASS
ok example.com/slug 0.002s
The argument to -run is a regular expression matched against the subtest name, so -run 'TestSlugify/strips' would match by prefix. Note that go test replaces spaces in subtest names with underscores, which is why you target empty_string, not empty string.
Naming cases so the failure reads like a bug report
The name field is not decoration. It is the label you will read at 2 a.m. when CI is red. Name each case for the behavior it pins down, not the mechanics of the input.
- Good:
"empty string","trims surrounding space","rejects uppercase". - Weak:
"test1","case 2","input3". - Also weak:
"Hello World"(the raw input as the name tells you nothing the assertion message does not).
A good name lets --- FAIL: TestSlugify/trims_surrounding_space stand on its own. You should be able to guess the bug from the subtest path before you read the diff. Keep names short, lowercase, and behavior-focused; they become part of the -run path, so avoid slashes inside them.
Running cases in parallel with t.Parallel, and the loop-variable note
Independent cases can run concurrently. Call t.Parallel() as the first line inside the subtest:
func TestSlugifyParallel(t *testing.T) {
tests := []struct {
name string
title string
want string
}{
{"basic", "Hello World", "hello-world"},
{"strips punctuation", "Go 1.24: What's New!", "go-124-whats-new"},
{"trims surrounding space", " spaced out ", "spaced-out"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := Slugify(tc.title)
if got != tc.want {
t.Errorf("Slugify(%q) = %q, want %q", tc.title, got, tc.want)
}
})
}
}
The runner pauses each parallel subtest until the loop finishes, then runs them together:
=== RUN TestSlugifyParallel/basic
=== PAUSE TestSlugifyParallel/basic
=== RUN TestSlugifyParallel/strips_punctuation
=== PAUSE TestSlugifyParallel/strips_punctuation
=== CONT TestSlugifyParallel/basic
=== CONT TestSlugifyParallel/trims_surrounding_space
=== CONT TestSlugifyParallel/strips_punctuation
--- PASS: TestSlugifyParallel (0.00s)
Parallel is worth it when cases do real work (network, disk, heavy computation) and share nothing mutable. For pure functions like Slugify it rarely pays for itself, and it makes the loop-variable trap below possible, so do not add it reflexively.
The Go 1.22 loop-variable fix, and why old tutorials say tc := tc
Before Go 1.22, the loop variable tc was a single variable reused across every iteration. A parallel subtest closes over tc and does not run until after the loop has finished advancing it, so every parallel case saw the last value. Here is that bug reproduced under go 1.21 semantics, printing the captured case name:
for _, name := range cases {
t.Run(name, func(t *testing.T) {
t.Parallel()
t.Logf("running case: %s", name)
})
}
loop_test.go:10: running case: charlie
loop_test.go:10: running case: charlie
loop_test.go:10: running case: charlie
All three cases captured charlie, the final value. Under go 1.22 or later in go.mod, the exact same code gives each iteration its own variable:
loop_test.go:10: running case: alpha
loop_test.go:10: running case: charlie
loop_test.go:10: running case: bravo
Each case now logs its own name (in nondeterministic order, because they run in parallel). The Go 1.22 loop-variable change makes each iteration’s variables per-iteration, which fixes this class of bug across the language, not just in tests.
This is why older articles start every parallel table with tc := tc inside the loop: that line manually shadowed the shared variable with a fresh copy. On Go 1.22+ it is a no-op and you can delete it. If your module declares go 1.22 or higher in go.mod, you never need it. If you maintain code pinned to an older language version, keep it.
Testing error cases in the same table
Do not build a separate mechanism for failure cases. Put them in the same table with an expected-error field. Two styles are common.
The blunt one is a wantErr bool: did it error or not? That is fine when you only care whether something failed. But it cannot tell a “too short” error apart from an “invalid character” error, so a bug that returns the wrong error still passes.
The better style for anything with distinct failure modes is a sentinel error compared with errors.Is. Here is a username validator that returns typed sentinel errors:
var (
ErrEmpty = errors.New("username is empty")
ErrTooShort = errors.New("username too short")
ErrTooLong = errors.New("username too long")
ErrBadStart = errors.New("username must start with a letter")
ErrBadChar = errors.New("username has an invalid character")
)
// ValidateUsername enforces: 3-20 chars, starts with a lowercase letter,
// and contains only lowercase letters, digits, or underscores.
func ValidateUsername(name string) error {
if name == "" {
return ErrEmpty
}
if len(name) < 3 {
return fmt.Errorf("%q: %w", name, ErrTooShort)
}
if len(name) > 20 {
return fmt.Errorf("%q: %w", name, ErrTooLong)
}
for i, r := range name {
if i == 0 && !unicode.IsLetter(r) {
return fmt.Errorf("%q: %w", name, ErrBadStart)
}
ok := unicode.IsLower(r) && unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_'
if !ok {
return fmt.Errorf("%q: %w", name, ErrBadChar)
}
}
return nil
}
The table carries a wantErr error field. nil means the input should be valid; a sentinel means it should fail with that specific error:
func TestValidateUsername(t *testing.T) {
tests := []struct {
name string
input string
wantErr error // nil means the name is valid
}{
{"valid simple", "gopher", nil},
{"valid with digits", "gopher_99", nil},
{"empty", "", ErrEmpty},
{"too short", "go", ErrTooShort},
{"too long", "this_name_is_far_too_long", ErrTooLong},
{"starts with digit", "9lives", ErrBadStart},
{"uppercase rejected", "Gopher", ErrBadChar},
{"space rejected", "go pher", ErrBadChar},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := ValidateUsername(tc.input)
if !errors.Is(err, tc.wantErr) {
t.Errorf("ValidateUsername(%q) error = %v, want %v", tc.input, err, tc.wantErr)
}
})
}
}
--- PASS: TestValidateUsername (0.00s)
--- PASS: TestValidateUsername/empty (0.00s)
--- PASS: TestValidateUsername/too_short (0.00s)
--- PASS: TestValidateUsername/starts_with_digit (0.00s)
--- PASS: TestValidateUsername/uppercase_rejected (0.00s)
... (8 cases, all pass)
errors.Is walks the wrapped-error chain, so it still matches even though the validator wraps each sentinel with fmt.Errorf("%q: %w", ...). One errors.Is(err, nil) returns true when err is nil, so the valid rows work with the same check as the failing ones. If you need to assert on a struct error’s fields rather than identity, swap in errors.As. For the full mechanics, see error handling in Go.
When a map keyed by name beats a slice
A slice preserves order. Sometimes you do not want order to matter, and a map keyed by the case name makes that explicit:
func TestSlugifyMap(t *testing.T) {
tests := map[string]struct {
title string
want string
}{
"basic": {"Hello World", "hello-world"},
"strips punctuation": {"Go 1.24: What's New!", "go-124-whats-new"},
"trims surrounding space": {" spaced out ", "spaced-out"},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
if got := Slugify(tc.title); got != tc.want {
t.Errorf("Slugify(%q) = %q, want %q", tc.title, got, tc.want)
}
})
}
}
The key doubles as the subtest name, so there is no separate name field. The trade-offs are real: Go randomizes map iteration order, which is a feature here because it surfaces any accidental dependence between cases; if your table only passes in a fixed order, a shared-state bug is hiding in it. The map also makes the compiler reject a duplicate case name as a duplicate key, which a slice will happily accept. The cost is that you lose deliberate ordering, so if cases must run in a specific sequence (they usually should not), keep the slice.
Reach for the map when order genuinely does not matter and you want duplicate names caught for free. Reach for the slice, the more common default, when you want cases to read top-to-bottom in a chosen order.
Golden files for large expected output
When want is a paragraph of text, a formatted table, or serialized JSON, embedding it as a string literal makes the table unreadable and every change a fiddly hand-edit. The idiomatic fix is a golden file: store the expected output on disk under testdata/ and compare against it, with a -update flag that regenerates it.
var update = flag.Bool("update", false, "update golden files")
func TestRenderInvoice(t *testing.T) {
items := []LineItem{
{"Widget", 3, 1299},
{"Gizmo", 1, 4500},
{"Sprocket bearing", 12, 75},
}
got := RenderInvoice(items)
golden := filepath.Join("testdata", "invoice.golden")
if *update {
if err := os.WriteFile(golden, []byte(got), 0o644); err != nil {
t.Fatalf("write golden: %v", err)
}
}
want, err := os.ReadFile(golden)
if err != nil {
t.Fatalf("read golden: %v (run with -update to create it)", err)
}
if got != string(want) {
t.Errorf("RenderInvoice mismatch:\ngot:\n%s\nwant:\n%s", got, want)
}
}
Run once with the flag to create the file, then normally to check against it:
$ go test -run TestRenderInvoice -update
ok example.com/report 0.004s
$ go test -run TestRenderInvoice
ok example.com/report 0.002s
The generated testdata/invoice.golden holds the real expected output:
ITEM QTY TOTAL
-------------------------------------
Widget 3 38.97
Gizmo 1 45.00
Sprocket bearing 12 9.00
-------------------------------------
TOTAL 92.97
The testdata directory name is special: go test runs in the package directory and the Go tool ignores testdata when building, so it is the standard home for fixtures. Commit the golden file to version control. When you intend to change the output, run -update, then review the diff in code review exactly as you would review source. The danger is rubber-stamping a golden update that encodes a real regression, so read those diffs. Golden files pair naturally with a table: one row per input, each with its own golden path.
Refactoring a real validator from repetitive tests to one table
Here is the payoff on realistic code. The ValidateUsername validator above often arrives with tests written one function per rule:
func TestValidUsername(t *testing.T) {
if err := ValidateUsername("gopher"); err != nil {
t.Errorf("ValidateUsername(gopher) = %v, want nil", err)
}
}
func TestEmptyUsername(t *testing.T) {
if err := ValidateUsername(""); !errors.Is(err, ErrEmpty) {
t.Errorf("ValidateUsername(empty) = %v, want ErrEmpty", err)
}
}
func TestShortUsername(t *testing.T) {
if err := ValidateUsername("go"); !errors.Is(err, ErrTooShort) {
t.Errorf("ValidateUsername(go) = %v, want ErrTooShort", err)
}
}
func TestUppercaseUsername(t *testing.T) {
if err := ValidateUsername("Gopher"); !errors.Is(err, ErrBadChar) {
t.Errorf("ValidateUsername(Gopher) = %v, want ErrBadChar", err)
}
}
Four functions and it only covers four of the validator’s rules. Each new rule is another function, another copied assertion. The TestValidateUsername table earlier in this article is the “after”: the same coverage plus four more cases, in one function, where the assertion exists once. To add a rule, you add a row. That is the whole reason the table is idiomatic; the before-and-after difference grows with every case you add.
When NOT to use a table
The table is a default, not a law. Skip it when it makes the test worse:
- A single case. If a function has one meaningful input, one plain
if got != wantis clearer than a one-row table. Do not build scaffolding for a single value. - Cases that cannot share a body. If case A needs a database and case B needs a mocked clock and case C asserts on three different fields, forcing them through one loop produces a struct full of half-populated fields and a body full of
if tc.needsDB { ... }. When the setup and assertions genuinely differ, write separate tests. The table earns its place only when every row runs the same logic with different data. - Order-dependent sequences. A table implies the rows are independent. If step 2 depends on step 1’s side effect, that is a scenario test, not a table; write it as explicit sequential steps.
Common mistakes
No subtests, so the first failure hides the rest. If you loop without t.Run and assert with t.Fatalf, the first failing case stops the entire test and you never see the others:
for _, tc := range tests {
got := Slugify(tc.title)
if got != tc.want {
t.Fatalf("Slugify(%q) = %q, want %q", tc.title, got, tc.want)
}
}
=== RUN TestSlugifyNoSubtests
nosub_test.go:18: Slugify("Go 1.24") = "go-124", want "go-999"
--- FAIL: TestSlugifyNoSubtests (0.00s)
FAIL
Two cases were wrong; you only see one. Wrap each case in t.Run and use t.Errorf (which records the failure and continues) instead of t.Fatalf (which stops the current test). Reserve t.Fatalf for setup that must succeed before the assertion can even run.
Shared mutable state across cases. If the cases mutate a value declared outside the loop, earlier rows leak into later ones:
func TestAddItemShared(t *testing.T) {
cart := map[string]int{} // shared across every case: the bug
tests := []struct {
name string
item string
qty int
want int
}{
{"first add", "apple", 2, 2},
{"second product", "pear", 3, 3},
{"re-add apple", "apple", 1, 1}, // expects a fresh cart, gets 3
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
AddItem(cart, tc.item, tc.qty)
if got := cart[tc.item]; got != tc.want {
t.Errorf("cart[%q] = %d, want %d", tc.item, got, tc.want)
}
})
}
}
cart_test.go:28: cart["apple"] = 3, want 1
--- FAIL: TestAddItemShared/re-add_apple (0.00s)
The third case sees the apple added by the first. The fix is to construct fresh state inside the subtest so each row starts clean:
t.Run(tc.name, func(t *testing.T) {
cart := map[string]int{} // fresh state for every case
AddItem(cart, tc.item, tc.qty)
// ...
})
This bug is invisible until you add t.Parallel() or the cases run in a different order, at which point the test becomes flaky. Keep everything a case needs inside its own subtest closure.
A giant unreadable table. When a row grows to a dozen fields, the table stops being readable data and becomes a puzzle. Split it: one table per behavior group (all the valid cases, all the boundary cases), or pull complex setup into a small helper the body calls with tc. If most rows leave most fields zero, that is a signal the cases are too different to share a body, which loops back to “when not to use a table.”
Capturing the loop variable on old Go. Covered above: on Go before 1.22, parallel subtests all captured the final loop value. On Go 1.22+ this is fixed and you can delete any tc := tc line. If you see that line in a modern codebase, it is dead weight from an older idiom.
What next
You now have the pattern Go developers reach for by default, and the judgment to know when to skip it. To go deeper:
- Go testing: the complete guide covers the
testingpackage end to end, fromgo testflags to coverage. - Unit testing in Go drills into assertions, helpers with
t.Helper(), and structuring a test file. - Benchmarking in Go applies the same table shape to
testing.Bfor performance work. - Mocking in Go shows how to feed table cases through interfaces when the code under test has dependencies.
- Error handling in Go explains the sentinel and
errors.Is/errors.Asmachinery your error tables depend on.
If any of the syntax here felt unfamiliar, the complete Go tutorial is the pillar that connects it all, from structs to interfaces to the testing tool.
