Table-Driven Tests in Go: Patterns and Best Practices
Table-driven tests are the dominant Go testing pattern for a reason: they separate test data from test logic, make adding cases trivial, and produce readable failure messages. The Go standard library uses them extensively. This guide covers the full pattern — from basic structs to parallel execution, error formatting, and gotchas to avoid.
The Core Pattern
Instead of repeating the same test logic for each input, define a slice of test cases and loop over them:
func TestAdd(t *testing.T) {
cases := []struct {
a, b int
want int
}{
{2, 3, 5},
{-1, -2, -3},
{0, 0, 0},
{100, -100, 0},
}
for _, tc := range cases {
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)
}
}
}This works, but has a problem: when a case fails, you can't immediately tell which one without reading the error values carefully. Adding names fixes this.
Named Test Cases with t.Run
Wrap each case in t.Run to get named subtests:
func TestAdd(t *testing.T) {
cases := []struct {
name string
a, b int
want int
}{
{"positive", 2, 3, 5},
{"negative", -1, -2, -3},
{"zeros", 0, 0, 0},
{"cancel out", 100, -100, 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := Add(tc.a, tc.b)
if got != tc.want {
t.Errorf("got %d; want %d", got, tc.want)
}
})
}
}Output on failure:
--- FAIL: TestAdd/cancel_out (0.00s)
math_test.go:22: got 1; want 0You know exactly which case failed. Run it in isolation:
go test -run TestAdd/cancel_out ./...Struct Design: What to Include
A well-designed test case struct makes intent explicit:
func TestParseDate(t *testing.T) {
cases := []struct {
name string
input string
want time.Time
wantErr bool
}{
{
name: "valid ISO date",
input: "2024-01-15",
want: time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC),
},
{
name: "empty string",
input: "",
wantErr: true,
},
{
name: "invalid format",
input: "15/01/2024",
wantErr: true,
},
{
name: "leap day",
input: "2024-02-29",
want: time.Date(2024, 2, 29, 0, 0, 0, 0, time.UTC),
},
{
name: "invalid leap day",
input: "2023-02-29",
wantErr: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := ParseDate(tc.input)
if tc.wantErr {
if err == nil {
t.Errorf("ParseDate(%q): expected error, got nil", tc.input)
}
return
}
if err != nil {
t.Fatalf("ParseDate(%q): unexpected error: %v", tc.input, err)
}
if !got.Equal(tc.want) {
t.Errorf("ParseDate(%q) = %v; want %v", tc.input, got, tc.want)
}
})
}
}Key decisions in struct design:
wantErr boolfor cases that should error (usewantErrMsg stringif you need to assert the message)- Input fields that show exactly what's being tested
- Separate
wantandwantErr— don't use zero values to signal "expect error"
Error Message Formatting
Good error messages answer: what input caused this, what did we get, what did we expect?
// Bad — no context
t.Errorf("got %d; want %d", got, tc.want)
// Good — includes input
t.Errorf("Add(%d, %d) = %d; want %d", tc.a, tc.b, got, tc.want)
// For string inputs, quote them
t.Errorf("Parse(%q) = %q; want %q", tc.input, got, tc.want)
// For complex types, use %+v or %#v
t.Errorf("Process(%+v) = %+v; want %+v", tc.input, got, tc.want)When the output is complex (a struct, slice, or map), use fmt.Sprintf with %+v and compare strings, or use reflect.DeepEqual:
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("ProcessItems(%v):\ngot: %+v\nwant: %+v", tc.input, got, tc.want)
}Multi-line format (\n) makes diffs readable when the values are long.
Parallel Table Tests
Mark the outer test and each subtest as parallel for faster execution:
func TestAdd(t *testing.T) {
t.Parallel()
cases := []struct {
name string
a, b int
want int
}{
{"positive", 2, 3, 5},
{"negative", -1, -2, -3},
}
for _, tc := range cases {
tc := tc // capture range variable (required before Go 1.22)
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
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)
}
})
}
}The tc := tc capture is the most common bug with parallel table tests before Go 1.22. Without it, all goroutines share the same tc variable, which will have the last value from the loop by the time they run. Go 1.22 fixed this at the language level.
Testing Error Types and Values
When you need to assert the specific error, not just that one occurred:
var ErrNotFound = errors.New("not found")
var ErrPermission = errors.New("permission denied")
func TestGet(t *testing.T) {
cases := []struct {
name string
key string
want string
wantErr error
}{
{"exists", "key1", "value1", nil},
{"missing", "key2", "", ErrNotFound},
{"forbidden", "secret", "", ErrPermission},
}
store := setupTestStore(t)
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := store.Get(tc.key)
if !errors.Is(err, tc.wantErr) {
t.Errorf("Get(%q) error = %v; want %v", tc.key, err, tc.wantErr)
}
if err == nil && got != tc.want {
t.Errorf("Get(%q) = %q; want %q", tc.key, got, tc.want)
}
})
}
}Use errors.Is instead of == for error comparison — it handles wrapped errors correctly.
Naming Conventions
Test case names should describe the scenario, not the expected result:
// Bad names — describe what happens, not what's being tested
{"returns 5", 2, 3, 5},
{"returns error", "", true},
// Good names — describe the scenario
{"two positive integers", 2, 3, 5},
{"empty input returns error", "", true},
{"overflow wraps to negative", math.MaxInt, 1, math.MinInt},For HTTP handler tests, name by HTTP context:
cases := []struct {
name string
method string
path string
body string
wantStatus int
wantBodyContains string
}{
{"GET returns user", "GET", "/users/1", "", 200, `"id":1`},
{"POST creates user", "POST", "/users", `{"name":"alice"}`, 201, `"name":"alice"`},
{"DELETE unknown user", "DELETE", "/users/999", "", 404, "not found"},
{"invalid method", "PATCH", "/users/1", "", 405, ""},
}Sharing Setup Across Cases
When test cases need a shared fixture (database connection, HTTP server), set it up once before the loop:
func TestUserRepository(t *testing.T) {
db := setupTestDB(t) // t.Cleanup registered inside
repo := NewUserRepository(db)
cases := []struct {
name string
userID int
want *User
wantErr bool
}{
{"existing user", 1, &User{ID: 1, Name: "Alice"}, false},
{"missing user", 999, nil, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := repo.FindByID(tc.userID)
if tc.wantErr {
if err == nil {
t.Error("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("FindByID(%d):\ngot: %+v\nwant: %+v", tc.userID, got, tc.want)
}
})
}
}If each case needs isolated state (e.g., separate DB transactions), set up and tear down inside the loop:
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
tx, err := db.Begin()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { tx.Rollback() })
// use tx for this case
})
}When to Not Use Table-Driven Tests
Table-driven tests work best when multiple inputs share the exact same test logic. They're a poor fit when:
- Each case has substantially different setup or assertions
- There are only 1-2 cases — a simple test function is cleaner
- Cases have complex interdependencies or ordering requirements
Don't force table structure onto tests that aren't naturally tabular. The goal is readability, not uniformity.
Quick Reference
// Basic structure
cases := []struct {
name string
input InputType
want OutputType
wantErr bool
}{
{"descriptive name", input, expected, false},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel() // if safe
got, err := FunctionUnderTest(tc.input)
// assertions
})
}Table-driven tests make test suites easy to extend — adding a new edge case is a one-liner in the cases slice, not a new function. That low friction is why the Go community landed on this as the default approach.
HelpMeTest pairs well with Go table tests by continuously running end-to-end scenarios against your deployed service, catching integration regressions that unit-level tables can't cover.