Table-Driven Tests in Go: Subtests, Parallelism, and Keeping the Table Readable
A table with thirty cases and t.Errorf("failed") as its failure message is worse than thirty copy-pasted test functions. The copies at least tell you which one broke.
The Go wiki page on table-driven tests puts it well: table-driven testing isn’t a tool or a package, but a way to write cleaner tests. Each entry is a complete test case with inputs, expected results, and a name. The body gets written once and amortized across every case, which is the whole reason it’s worth spending real effort on the error messages. You pay for a good message once and collect on it every time the table grows.
So: how to structure the table, the subtests, and the assertions so a red build tells you what happened without opening the file.
Start with a named struct slice and t.Run
The classic shape, adapted from the Go wiki’s example based on the fmt package tests:
var flagtests = []struct {
in string
out string
}{
{"%a", "[%a]"},
{"%-a", "[%-a]"},
{"%+a", "[%+a]"},
{"%1.2a", "[%1.2a]"},
}
func TestFlagParser(t *testing.T) {
var flagprinter flagPrinter
for _, tt := range flagtests {
t.Run(tt.in, func(t *testing.T) {
s := Sprintf(tt.in, &flagprinter)
if s != tt.out {
t.Errorf("got %q, want %q", s, tt.out)
}
})
}
}
Two details carry this. The t.Run name is the input itself, so the failure output identifies the case. And t.Errorf prints got and want with %q, which makes trailing whitespace and empty strings visible instead of invisible.
t.Run also buys you filtering. Subtest names have spaces replaced with underscores, and -run accepts a slash-separated regular expression for each level. Here, go test -run '^TestFlagParser$/^%-a$' runs exactly one case. With a hundred entries in the table, that’s the difference between re-running the case you’re debugging and re-running everything else too.
One thing worth internalising: t.Errorf is not an assertion. The test keeps going after it logs. That’s deliberate, and it’s useful, because knowing whether a function fails for every input or only the odd ones is half the diagnosis. Reach for t.Fatalf when continuing would panic or produce nonsense. Note that inside a subtest, t.Fatalf stops that subtest only. The rest of the table still runs.
Use a map when order shouldn’t matter
A map[string]struct{...} gives you the case name for free as the key:
func TestReverse(t *testing.T) {
tests := map[string]struct {
input string
result string
}{
"empty string": {input: "", result: ""},
"one character": {input: "x", result: "x"},
"one multi byte glyph": {input: "🎉", result: "🎉"},
"string with multiple multi-byte glyphs": {input: "🥳🎉🐶", result: "🐶🎉🥳"},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
t.Parallel()
if got := reverse(test.input); got != test.result {
t.Fatalf("reverse(%q) returned %q; expected %q", test.input, got, test.result)
}
})
}
}
Map iteration order in Go is unspecified and is not guaranteed to repeat. That variation can expose cases that quietly depend on the one that ran before them. If your table only passes in slice order, you have shared state you didn’t know about, and you’d rather find out now.
The cost is that you give up ordering entirely, and gofmt won’t keep the literal in any sensible sequence. For a table where cases are meant to build on each other, like a state machine walked through a fixed set of transitions, a slice is the honest representation.
Parallel subtests and the loop variable
t.Parallel() in a subtest declares that it can run concurrently with other parallel subtests. A call in the parent test controls a different scope:
func TestTLog(t *testing.T) {
t.Parallel() // this test can run alongside other parallel top-level tests
tests := []struct {
name string
}{
{"test 1"},
{"test 2"},
{"test 3"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel() // each case can run alongside the others
t.Log(test.name)
})
}
}
The mechanism is worth knowing, because many parallel-test bugs follow from it. When a subtest calls t.Parallel(), it pauses and hands control back to the parent. The parent finishes its loop and returns. Only then do the paused subtests resume, so code after t.Parallel() runs after the loop is over.
Before Go 1.22, the loop variable test was one variable reused across iterations, so every parallel subtest read the final value. Hence the test := test shadowing line still scattered through older code. Go 1.22 changed loop variables to be per-iteration and the line is now dead weight, provided your go.mod declares go 1.22 or later. That version directive controls the behaviour per module, which is why an old module built with a shiny new toolchain still gets the old semantics.
A related trap is deferring shared teardown in the parent. This breaks:
func TestWithServer(t *testing.T) {
srv := newTestServer(t)
defer srv.Close() // runs before any parallel subtest resumes
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// srv is already closed here
})
}
}
Use t.Cleanup, for example t.Cleanup(srv.Close). Cleanup functions registered on the parent run after all its subtests have finished, parallel ones included. Testing HTTP handlers hits this constantly, which is why httptest.NewServer and t.Cleanup belong together; there’s more on that in testing HTTP handlers with httptest.
Assert on errors with errors.Is, not string matching
A wantErr string field compared with strings.Contains is brittle. Somebody rewords an error message, your test goes red, and nothing is broken. Use sentinel errors and errors.Is:
var ErrEmptyInput = errors.New("empty input")
func TestParse(t *testing.T) {
tests := map[string]struct {
input string
want Config
wantErr error
}{
"valid": {input: "port=8080", want: Config{Port: 8080}},
"empty": {input: "", wantErr: ErrEmptyInput},
"garbage": {input: "port=abc", wantErr: strconv.ErrSyntax},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
got, err := Parse(tc.input)
if !errors.Is(err, tc.wantErr) {
t.Fatalf("Parse(%q) error = %v, want %v", tc.input, err, tc.wantErr)
}
if tc.wantErr != nil {
return // nothing more to check on the error path
}
if got != tc.want {
t.Errorf("Parse(%q) = %+v, want %+v", tc.input, got, tc.want)
}
})
}
}
errors.Is(nil, nil) returns true, so the happy path and the error path share a single check and the table stays flat. It also survives wrapping, which string comparison does not. Go error chains covers the wrapping rules.
Push repeated setup into helpers that call t.Helper
Once the test body runs past a dozen lines, move the noisy parts out. The call that earns its keep is t.Helper():
func newTestStore(t *testing.T) *Store {
t.Helper()
dir := t.TempDir() // removed automatically when the test ends
s, err := Open(dir)
if err != nil {
t.Fatalf("Open(%q): %v", dir, err)
}
t.Cleanup(func() { s.Close() })
return s
}
t.Helper() marks the function so failure line numbers point at the caller, meaning the subtest, rather than at line 8 of the helper. Skip it and every failure across forty cases reports the same file and line. At that point the table has told you nothing.
Golden files for large expected outputs
When the expected value is a rendered template, formatted JSON, or anything past a couple of lines, inlining it wrecks the table. The struct literal stops being readable data and becomes a wall. Put it in testdata/ instead. The go tool excludes directories with that name from normal package discovery, while tests can still read the files inside them.
var update = flag.Bool("update", false, "update golden files")
func assertGolden(t *testing.T, name string, got []byte) {
t.Helper()
path := filepath.Join("testdata", name+".golden")
if *update {
if err := os.WriteFile(path, got, 0o644); err != nil {
t.Fatalf("writing golden file %s: %v", path, err)
}
return
}
want, err := os.ReadFile(path)
if err != nil {
t.Fatalf("reading golden file %s: %v (run with -update to create)", path, err)
}
if !bytes.Equal(got, want) {
t.Errorf("output mismatch for %s\n--- got ---\n%s\n--- want ---\n%s", path, got, want)
}
}
Run go test -update to regenerate, then review the diff in git before accepting the new output. The flag is registered at package level in a _test.go file, and the generated test binary parses it before running the tests. Derive the filename from the subtest name and every case gets its own golden file.
Golden files do rot. The -update flag makes it cheap to paper over a real regression if nobody reads the diff, so treat a golden update in a pull request as something that needs a second pair of eyes. For structured comparisons rather than raw bytes, google/go-cmp produces much more readable diffs than reflect.DeepEqual, and cmp.Diff output drops straight into a t.Errorf.
When a separate test function is clearer
Tables aren’t free, and there’s a point where they cost more than they save. Watch for boolean fields that gate behaviour: skipSetup bool, useMock bool, expectPanic bool. Each one adds a branch to the body, and once the body has more if tc.X lines than assertions you’re maintaining a small interpreter with a testing framework bolted to it.
Fields used by exactly one case are the same smell in miniature. If timeout time.Duration is set in one of fifteen entries and ignored in the other fourteen, that entry wants its own function.
Assertions of different shapes are the clearest signal. One case checks a returned value, the next checks a log line was written, a third checks a goroutine exited. Force those into one table and every case has to carry the union of all their fields, most of them zero. Concurrency and timing tests usually land here too, since coordinating goroutines doesn’t reduce to input-output pairs. Write them out longhand.
The rule I’d hold to: the table describes data, the body describes one procedure. When the body starts describing several procedures, split it.
If your tables are in good shape and the thing keeping you up is the inputs you never thought to write down, fuzzing picks up where this leaves off. Fuzz testing in Go shows how existing table entries become the seed corpus, which means the work here isn’t thrown away.