Table Driven Tests in Go That Stay Readable as They Grow
Write Go table driven tests that stay readable at sixteen cases: named rows, field names, sentinel errors, subtests, running one row and clear failures.

The table-driven test is the idiom Go programmers reach for first. A slice of cases, a loop, one assertion block. It works because most functions worth testing map inputs to outputs and need many inputs to be trusted. It also goes wrong in a recognisable way: the table starts at three rows and ends at forty, with positional struct literals nobody can read, a failure message that says only got 3, want 4, and a loop body that has grown ifs to handle special cases.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
This article builds a table test for a function with enough cases to show the problems, and applies the habits that keep it readable. It continues from your first Go test, leads into subtests and parallel tests, and the same style applies to API handler tests. It ran on Go 1.25.6.
The function
ParseSize turns strings like 512, 10MB and 1.5 gb into byte counts, with units as powers of 1024. It has a handful of failure modes, each a sentinel error:
package main
import (
"errors"
"fmt"
"math"
"strconv"
"strings"
)
var (
ErrEmpty = errors.New("empty size")
ErrUnit = errors.New("unknown unit")
ErrNegative = errors.New("negative size")
ErrTooBig = errors.New("size overflows int64")
)
var units = map[string]float64{"": 1, "B": 1, "KB": 1 << 10, "MB": 1 << 20, "GB": 1 << 30}
// ParseSize parses "512", "10MB", "1.5 gb" into bytes. Units are powers of 1024.
func ParseSize(s string) (int64, error) {
s = strings.TrimSpace(s)
if s == "" {
return 0, ErrEmpty
}
i := 0
for i < len(s) && (s[i] == '-' || s[i] == '.' || (s[i] >= '0' && s[i] <= '9')) {
i++
}
num, unit := s[:i], strings.ToUpper(strings.TrimSpace(s[i:]))
f, err := strconv.ParseFloat(num, 64)
if err != nil {
return 0, fmt.Errorf("parse %q: %w", s, err)
}
mult, ok := units[unit]
if !ok {
return 0, fmt.Errorf("%w %q", ErrUnit, unit)
}
if f < 0 {
return 0, ErrNegative
}
if f*mult >= math.MaxInt64 {
return 0, ErrTooBig
}
return int64(f * mult), nil
}
func main() { fmt.Println(ParseSize("1.5MB")) }A function like this is a good candidate for a table because its behaviour is a list of examples: for this input, that output, or that error.
The table
package main
import (
"errors"
"testing"
)
// errAny marks rows where any error is acceptable.
var errAny = errors.New("any error")
func TestParseSize(t *testing.T) {
// Every row is named, uses field names, and says what it is about. A new case is one more line.
tests := []struct {
name string
input string
want int64
wantErr error // nil means success; a sentinel means "errors.Is must match"
}{
{name: "plain number is bytes", input: "512", want: 512},
{name: "explicit bytes", input: "512B", want: 512},
{name: "kilobytes", input: "2KB", want: 2048},
{name: "megabytes", input: "10MB", want: 10 << 20},
{name: "gigabytes", input: "1GB", want: 1 << 30},
{name: "fraction", input: "1.5MB", want: 1572864},
{name: "lower case unit", input: "3kb", want: 3072},
{name: "space before the unit", input: "4 MB", want: 4 << 20},
{name: "surrounding space", input: " 7KB ", want: 7168},
{name: "zero", input: "0", want: 0},
{name: "empty string", input: "", wantErr: ErrEmpty},
{name: "only spaces", input: " ", wantErr: ErrEmpty},
{name: "negative", input: "-1KB", wantErr: ErrNegative},
{name: "unknown unit", input: "5TB", wantErr: ErrUnit},
{name: "no digits", input: "MB", wantErr: errAny}, // the parse error comes from strconv, which we do not export
{name: "overflow", input: "99999999999GB", wantErr: ErrTooBig},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := ParseSize(tc.input)
if tc.wantErr != nil {
if err == nil {
t.Fatalf("ParseSize(%q) = %d, nil; want an error", tc.input, got)
}
if tc.wantErr != errAny && !errors.Is(err, tc.wantErr) {
t.Fatalf("ParseSize(%q) error = %v, want %v", tc.input, err, tc.wantErr)
}
return
}
if err != nil {
t.Fatalf("ParseSize(%q) unexpected error: %v", tc.input, err)
}
if got != tc.want {
t.Errorf("ParseSize(%q) = %d, want %d", tc.input, got, tc.want)
}
})
}
}Look at what the table does to help the reader.
Every row has a name. name: "fraction" becomes the subtest name, so the output reads like a list of behaviours: plain_number_is_bytes, lower_case_unit, overflow. If you cannot name a row, you do not yet understand what it tests. Go replaces spaces in subtest names with underscores.
Fields are named in the literals. {name: "kilobytes", input: "2KB", want: 2048} can be read without looking at the struct definition. The positional form, {"kilobytes", "2KB", 2048, nil}, becomes unreadable at four fields and dangerous at six, since two adjacent strings can be swapped without a compile error. The vet tool flags unkeyed fields only for imported structs; for your own, discipline is the check.
Expected errors are values, not bools. The first draft of tests like this has wantErr bool. It says only that an error occurred, so a function that fails for the wrong reason passes. Here wantErr is an error and the loop checks errors.Is(err, tc.wantErr), so a negative size must fail with ErrNegative specifically. The sentinel errors are part of the function's API, which is exactly why they are good to test against. Tests that compare error strings break the day someone improves a message (testing errors).
A marker for "any error". One row, "MB", fails inside strconv, whose error type the package does not export. The row uses errAny, a sentinel defined in the test file; the loop treats it specially (!= errAny). That is the only special case in the loop, and it is visible in the table, not buried in a flag.
The loop body is boring on purpose. Call the function, check the error expectation, check the value. No computation of expected values, no branches on the case's name. If the loop needs logic to decide what to check, that logic can have bugs, and then the test is untrustworthy. When the logic is unavoidable, split the table into two tests (one for successes, one for failures) before adding conditionals.
Running it
strip() { sed -E 's/[[:space:]]+[0-9.]+s$//; s/ \([0-9.]+s\)//'; }
echo '$ go test -v -run TestParseSize'
go test -count=1 -v -run TestParseSize . 2>&1 | strip
echo '--- change one expectation to see how a failing row reads'
cp size_test.go size_test.go.bak
sed -i.tmp 's/want: 1572864/want: 1500000/' size_test.go
go test -count=1 -run TestParseSize . 2>&1 | strip
echo '--- run only one row by name'
mv size_test.go.bak size_test.go; rm -f size_test.go.tmp
go test -count=1 -v -run 'TestParseSize/fraction' . 2>&1 | strip$ go test -v -run TestParseSize
=== RUN TestParseSize
=== RUN TestParseSize/plain_number_is_bytes
=== RUN TestParseSize/explicit_bytes
=== RUN TestParseSize/kilobytes
=== RUN TestParseSize/megabytes
=== RUN TestParseSize/gigabytes
=== RUN TestParseSize/fraction
=== RUN TestParseSize/lower_case_unit
=== RUN TestParseSize/space_before_the_unit
=== RUN TestParseSize/surrounding_space
=== RUN TestParseSize/zero
=== RUN TestParseSize/empty_string
=== RUN TestParseSize/only_spaces
=== RUN TestParseSize/negative
=== RUN TestParseSize/unknown_unit
=== RUN TestParseSize/no_digits
=== RUN TestParseSize/overflow
--- PASS: TestParseSize
--- PASS: TestParseSize/plain_number_is_bytes
--- PASS: TestParseSize/explicit_bytes
--- PASS: TestParseSize/kilobytes
--- PASS: TestParseSize/megabytes
--- PASS: TestParseSize/gigabytes
--- PASS: TestParseSize/fraction
--- PASS: TestParseSize/lower_case_unit
--- PASS: TestParseSize/space_before_the_unit
--- PASS: TestParseSize/surrounding_space
--- PASS: TestParseSize/zero
--- PASS: TestParseSize/empty_string
--- PASS: TestParseSize/only_spaces
--- PASS: TestParseSize/negative
--- PASS: TestParseSize/unknown_unit
--- PASS: TestParseSize/no_digits
--- PASS: TestParseSize/overflow
PASS
ok example.test/lab
--- change one expectation to see how a failing row reads
--- FAIL: TestParseSize
--- FAIL: TestParseSize/fraction
size_test.go:53: ParseSize("1.5MB") = 1572864, want 1500000
FAIL
FAIL example.test/lab
FAIL
--- run only one row by name
=== RUN TestParseSize
=== RUN TestParseSize/fraction
--- PASS: TestParseSize
--- PASS: TestParseSize/fraction
PASS
ok example.test/labThe first block lists every row as a subtest, in table order, then passes them. Note the structure of the output: the parent test, TestParseSize, and an indented child per row. With -v you see each row start (=== RUN) and finish (--- PASS).
The second block changes one expectation and runs without -v. Only the failing row is printed:
--- FAIL: TestParseSize
--- FAIL: TestParseSize/fraction
size_test.go:53: ParseSize("1.5MB") = 1572864, want 1500000The message names the subtest, the line of the assertion, the call with its input and both values. Nothing else is needed to diagnose it. That is the standard to hold every table test to: from the failure output alone you can tell which case, what it did and what was expected.
The third block runs a single row with -run 'TestParseSize/fraction'. The slash separates the test name from the subtest name, and each side is a regular expression. Being able to run one row is the reason to use t.Run rather than a bare loop: when one case fails you iterate on that case alone (running a single test).
Failure behaviour: Fatal and Error
In the loop, t.Fatalf is used for checks that make later checks meaningless (an unexpected error means got is garbage), and t.Errorf for the final value comparison. Inside t.Run, Fatalf stops only that subtest, not the whole table, so the remaining rows still run and you see every failing case in one go. Without t.Run, a Fatalf in a plain loop ends the entire test at the first failure and hides the rest.
When a table gets big
Tables that outgrow a screen need structure, not just more rows.
Group related cases with comments or with several tables for different aspects (valid input, invalid input, boundaries). Two tables of ten rows read better than one of twenty, and each gets a loop body tailored to what it checks.
Move large data out. If rows have long inputs or expected outputs, put them in files under testdata/ and have the table list file names (golden files).
Use a helper for repeated setup, marked with t.Helper() so failures point to the row and not to the helper (t.Helper).
Do not share mutable state between rows. Each subtest should build its own inputs. Shared state is how one row's failure causes another's, and it blocks running rows in parallel (parallel subtests).
Prefer a slice to a map for the table. Maps iterate in random order, so failures appear in a different order on each run, which makes diffing two runs annoying. A map can be useful for uniqueness of names, but a slice with a check for duplicate names does the same.
Avoid generating the table with code that mirrors the function. A test that computes want by the same algorithm checks nothing. Tables should state expected results as literals, taken from the specification or worked out by hand.
What a table cannot do
A table of examples checks the cases you thought of. It does not find the ones you did not. For that, use fuzzing and property-style checks (fuzzing), and add every bug you find as a new row, with a name that mentions the bug. The table then doubles as the regression history of the function. The idiom is described on the Go wiki's table-driven tests page, and the testing package documents T.Run.


