Table-Driven Tests in Go: The Idiomatic Pattern
Table-driven tests are Go's idiomatic approach to testing multiple input/output combinations. Instead of writing one test function per case, you define all cases as a slice of structs and loop through them. The Go standard library itself uses this pattern extensively.
The Basic Pattern
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
want int
}{
{"two positives", 2, 3, 5},
{"with zero", 0, 5, 5},
{"two negatives", -2, -3, -5},
{"opposite signs", 5, -3, 2},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := Add(tc.a, tc.b)
if got != tc.want {
t.Errorf("Add(%d, %d) = %d; want %d", tc.a, tc.b, got, tc.want)
}
})
}
}Each case has a name that becomes the subtest name in output. When a test fails, you see exactly which case:
--- FAIL: TestAdd (0.00s)
--- FAIL: TestAdd/two_negatives (0.00s)
add_test.go:18: Add(-2, -3) = -6; want -5Why Table-Driven?
Before: One test function per case.
func TestAdd_TwoPositives(t *testing.T) {
if Add(2, 3) != 5 {
t.Error("wrong")
}
}
func TestAdd_WithZero(t *testing.T) {
if Add(0, 5) != 5 {
t.Error("wrong")
}
}
func TestAdd_TwoNegatives(t *testing.T) {
if Add(-2, -3) != -5 {
t.Error("wrong")
}
}After: One table, one loop.
tests := []struct{ name string; a, b, want int }{
{"two positives", 2, 3, 5},
{"with zero", 0, 5, 5},
{"two negatives", -2, -3, -5},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := Add(tc.a, tc.b); got != tc.want {
t.Errorf("got %d, want %d", got, tc.want)
}
})
}Adding a new case is one line. The pattern scales to 50 cases without complexity creep.
Struct Design
Design your test struct for readability:
tests := []struct {
name string
input string
want int
wantErr bool
}{
{
name: "valid integer",
input: "42",
want: 42,
},
{
name: "empty string",
input: "",
wantErr: true,
},
{
name: "non-numeric",
input: "abc",
wantErr: true,
},
}Use wantErr bool for error cases, not a specific error message — unless you specifically want to assert the error content.
Testing Errors
func TestParseInt(t *testing.T) {
tests := []struct {
name string
input string
want int
wantErr bool
}{
{"valid", "42", 42, false},
{"negative", "-7", -7, false},
{"empty", "", 0, true},
{"letters", "abc", 0, true},
{"overflow", "99999999999999999999", 0, true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := ParseInt(tc.input)
if (err != nil) != tc.wantErr {
t.Errorf("ParseInt(%q) error = %v, wantErr %v", tc.input, err, tc.wantErr)
return
}
if !tc.wantErr && got != tc.want {
t.Errorf("ParseInt(%q) = %d, want %d", tc.input, got, tc.want)
}
})
}
}The (err != nil) != tc.wantErr pattern is idiomatic — it's true when you got an error but didn't want one, or got nil but did want an error.
Testing with Specific Error Types
When you want to assert a specific error:
tests := []struct {
name string
input string
wantErr error
}{
{"not found", "user:99", ErrNotFound},
{"invalid format", "bad-input", ErrInvalidFormat},
{"valid", "user:1", nil},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := Fetch(tc.input)
if !errors.Is(err, tc.wantErr) {
t.Errorf("Fetch(%q) error = %v, want %v", tc.input, err, tc.wantErr)
}
})
}Parallel Table Tests
Run subtests in parallel for speed:
for _, tc := range tests {
tc := tc // capture range variable (required before Go 1.22)
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := Process(tc.input)
if got != tc.want {
t.Errorf("got %v, want %v", got, tc.want)
}
})
}t.Parallel() inside t.Run makes subtests run concurrently. The parent test waits for all subtests to finish. This is safe when test cases don't share mutable state.
In Go 1.22+, loop variables are properly scoped per iteration, so tc := tc is no longer needed.
Setup per Test Case
Some cases need per-case setup:
tests := []struct {
name string
setup func(t *testing.T) *DB
query string
want []User
}{
{
name: "returns matching users",
setup: func(t *testing.T) *DB {
db := newTestDB(t)
db.Insert(User{Name: "Alice", Active: true})
db.Insert(User{Name: "Bob", Active: false})
return db
},
query: "active=true",
want: []User{{Name: "Alice", Active: true}},
},
{
name: "returns empty when no match",
setup: func(t *testing.T) *DB {
return newTestDB(t)
},
query: "active=true",
want: []User{},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
db := tc.setup(t)
got := db.Query(tc.query)
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("got %v, want %v", got, tc.want)
}
})
}The setup function receives *testing.T so it can register cleanup with t.Cleanup.
Golden Files
For large expected outputs, use golden files:
func TestRender(t *testing.T) {
tests := []struct {
name string
template string
data map[string]string
}{
{"simple", "Hello, {{.Name}}!", map[string]string{"Name": "Alice"}},
{"empty", "No data", nil},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := Render(tc.template, tc.data)
goldenFile := filepath.Join("testdata", tc.name+".golden")
if *updateGolden {
os.WriteFile(goldenFile, []byte(got), 0644)
return
}
want, _ := os.ReadFile(goldenFile)
if got != string(want) {
t.Errorf("output mismatch:\ngot: %q\nwant: %q", got, string(want))
}
})
}
}Add a flag: var updateGolden = flag.Bool("update", false, "update golden files")
Update golden files: go test -run TestRender -update
Naming Conventions
Good subtest names:
"valid email address"— describes input"empty input returns error"— describes input and expected behavior"authenticated user can delete"— describes precondition and action
Bad names:
"test1","case2"— meaningless"success","failure"— too vague for debugging
Go converts spaces to underscores in subtest names for filtering:
go test -run TestParse/valid_email_address ./...When Not to Use Tables
Table-driven tests work best when test cases share the same structure. Don't force them when:
- Cases require very different setup
- The test logic differs significantly between cases
- You have only 1-2 cases (just write them directly)
Forcing a table where the structure doesn't fit produces awkward code. Use t.Run subtests directly for complex, divergent cases:
func TestComplexFlow(t *testing.T) {
t.Run("creates order and sends email", func(t *testing.T) {
// complex setup unique to this case
})
t.Run("rolls back on payment failure", func(t *testing.T) {
// different complex setup
})
}Running Table Tests
# Run all tests
go <span class="hljs-built_in">test ./...
<span class="hljs-comment"># Run a specific table test
go <span class="hljs-built_in">test -run TestAdd ./calculator/...
<span class="hljs-comment"># Run a specific case within the table
go <span class="hljs-built_in">test -run TestAdd/two_positives ./calculator/...
<span class="hljs-comment"># Run with verbose output to see all case names
go <span class="hljs-built_in">test -v -run TestAdd ./calculator/...Table-driven tests are the Go way to cover edge cases systematically. Once you adopt this pattern, adding new test cases becomes a one-line change instead of a new function, and your test suite reads like a specification.