Go Table-Driven Tests
Overview
Table-driven tests are a Go testing idiom that reduces code duplication and makes tests more maintainable. Instead of writing separate test functions for each case, you define a table of test cases and iterate over it.
When to Use Table-Driven Tests
Use table-driven tests when:
-
You find yourself copying and pasting test code
-
You're testing the same function/behavior with multiple inputs
-
You want to add more test cases without writing more test functions
-
Edge cases and boundary conditions need systematic coverage
Do NOT use for: Completely unrelated test scenarios, or when each test requires substantially different setup/teardown logic.
Basic Template (Slice Pattern)
This is the most common pattern in this codebase:
func TestFunctionName(t *testing.T) { cases := []struct { name string input string want string err error }{ { name: "simple case", input: "a/b/c", want: "a,b,c", }, { name: "empty input", input: "", want: "", }, { name: "invalid input", input: "!!!", want: "", err: ErrInvalid, }, }
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
got, err := FunctionName(tt.input)
if !errors.Is(err, tt.err) {
t.Errorf("FunctionName(%q) error = %v, want %v", tt.input, err, tt.err)
}
if got != tt.want {
t.Errorf("FunctionName(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
Map Pattern (For Non-Deterministic Test Ordering)
Use a map when you want to ensure test independence:
func TestFunctionName(t *testing.T) { tests := map[string]struct { input string want string }{ "simple case": {input: "a/b/c", want: "a,b,c"}, "empty input": {input: "", want: ""}, "trailing sep": {input: "a/b/c/", want: "a,b,c"}, }
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
got := FunctionName(tc.input)
if got != tc.want {
t.Fatalf("%s: expected %q, got %q", name, tc.want, got)
}
})
}
}
Repository-Specific Conventions
Variable Naming
This codebase consistently uses these variable names:
Purpose Variable Name Example
Test cases slice cases , tt , cs
cases := []struct{...}
Loop variable tt , cs , tc
for _, tt := range cases
Input field input , in , inp
input: "test"
Expected output want , expected
want: "result"
Actual output got , output
got := Function()
Error field err , wantErr
err: ErrInvalid
Name field name
name: "descriptive name"
Struct Field Guidelines
// Always use named fields (not anonymous structs in this codebase) cases := []struct { name string // Descriptive test name (REQUIRED when using slice pattern) input Type // Input to function under test want Type // Expected output err error // Expected error (use errors.Is for comparison) wantErr bool // Alternative: true if error is expected precondition func(*testing.T) // Optional setup function }{ ... }
Error Reporting
This repository uses these patterns:
// For error checking (preferred) if !errors.Is(err, tt.err) { t.Errorf("error = %v, want %v", err, tt.err) }
// For simple comparisons if got != tt.want { t.Errorf("got %q, want %q", got, tt.want) }
// Use t.Fatalf only when continuing doesn't make sense // Use t.Errorf to see all test failures before stopping
Advanced Patterns
With Precondition Functions
When tests need specific setup:
for _, tt := range []struct { name string precondition func(*testing.T) input string err error }{ { name: "with listener", precondition: func(t *testing.T) { ln, err := net.Listen("tcp", ":8081") if err != nil { t.Fatal(err) } t.Cleanup(func() { ln.Close() }) }, input: "test", err: nil, }, } { t.Run(tt.name, func(t *testing.T) { if tt.precondition != nil { tt.precondition(t) } // test logic }) }
Parallel Tests
For independent tests that can run in parallel:
func TestFunctionName(t *testing.T) { cases := []struct { name string input string want string }{ // ... test cases }
for _, tt := range cases {
tt := tt // Capture range variable (Go < 1.22)
t.Run(tt.name, func(t *testing.T) {
t.Parallel() // Marks this subtest as parallel
got := FunctionName(tt.input)
if got != tt.want {
t.Errorf("got %q, want %q", got, tt.want)
}
})
}
}
Best Practices
-
Always use t.Run() for subtests - This is 100% consistent in this codebase
-
Use descriptive test names - The name field should clearly describe what is being tested
-
Test one thing per case - Each table entry should test one specific behavior
-
Include edge cases - Empty strings, nil values, maximum values, etc.
-
Use errors.Is for error comparison - Not == or reflect.DeepEqual
-
Prefer t.Errorf over t.Fatalf
-
See all failures before stopping
-
Keep test data inline - External files only for large golden test sets
Common Pitfalls to Avoid
-
Forgetting t.Run()
-
Without subtests, all failures appear at the same line
-
Using Fatalf immediately - You won't see other test failures
-
Not capturing range variable - In Go < 1.22, add tt := tt before t.Run
-
Anonymous structs - This codebase prefers named structs for clarity
-
Inconsistent naming - Stick to the conventions (cases , tt , want , got )
Comparison with Traditional Tests
Traditional Table-Driven
func TestFoo(t *testing.T) { ... }
func TestFoo(t *testing.T) { cases := []struct{...}{...} }
One test function per case Single function, many cases
Hard to add new cases Just add a row to the table
Verbose boilerplate Concise, DRY code
go test -run TestFoo_SpecificCase
go test -run TestFoo/name
Running Specific Tests
Run all tests in a function
go test -run TestFunctionName
Run a specific subtest
go test -run TestFunctionName/descriptive_name
Run all tests matching a pattern
go test -run TestFunctionName/.*/empty
Verbose mode (see all test output)
go test -v
Run with race detector
go test -race
References
-
Go Wiki: TableDrivenTests
-
Go Testing Package
-
Prefer Table Driven Tests - Dave Cheney