Table Driven Tests
Problem
Writing separate test functions for each input case leads to repetitive code and poor maintainability.
Solution
1. Basic Table-Driven Test
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{"positive numbers", 2, 3, 5},
{"negative numbers", -2, -3, -5},
{"mixed", 2, -3, -1},
{"zeros", 0, 0, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := Add(tt.a, tt.b)
if result != tt.expected {
t.Errorf("Add(%d, %d) = %d; want %d",
tt.a, tt.b, result, tt.expected)
}
})
}
}2. Complex Test Cases
func TestUserValidation(t *testing.T) {
tests := []struct {
name string
user User
wantErr bool
errMsg string
}{
{
name: "valid user",
user: User{Username: "john", Email: "john@example.com", Age: 25},
wantErr: false,
},
{
name: "empty username",
user: User{Username: "", Email: "john@example.com", Age: 25},
wantErr: true,
errMsg: "username required",
},
{
name: "invalid email",
user: User{Username: "john", Email: "invalid", Age: 25},
wantErr: true,
errMsg: "invalid email",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateUser(tt.user)
if tt.wantErr {
if err == nil {
t.Error("expected error but got nil")
} else if !strings.Contains(err.Error(), tt.errMsg) {
t.Errorf("error = %v; want %v", err, tt.errMsg)
}
} else {
if err != nil {
t.Errorf("unexpected error: %v", err)
}
}
})
}
}3. Test Helpers
func assertEqual(t *testing.T, got, want interface{}) {
t.Helper()
if got != want {
t.Errorf("got %v; want %v", got, want)
}
}
func TestWithHelpers(t *testing.T) {
tests := []struct {
name string
input int
want int
}{
{"double 2", 2, 4},
{"double 5", 5, 10},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Double(tt.input)
assertEqual(t, got, tt.want)
})
}
}How It Works
Test Table Structure
Table-driven tests use a slice of structs defining test cases:
tests := []struct {
name string // Test case name
input InputType // Input data
expected OutputType // Expected result
wantErr bool // Should error occur
}{
{"case 1", input1, output1, false},
{"case 2", input2, output2, true},
}Each struct field defines:
- name: Descriptive test case identifier (appears in output)
- input: Function arguments or test data
- expected: Anticipated function output
- wantErr: Whether error expected (for error testing)
Subtest Execution
t.Run() creates isolated subtests:
- Iteration: Loop through test table
- Subtest Creation:
t.Run(name, func)spawns independent test - Isolation: Each subtest runs independently (one failure doesn’t stop others)
- Reporting: Each subtest reports separately in output
- Parallelization: Subtests can run in parallel with
t.Parallel()
Output format:
=== RUN TestAdd
=== RUN TestAdd/positive_numbers
=== RUN TestAdd/negative_numberst.Helper() Mechanism
t.Helper() marks helper functions to improve error reporting:
func assertEqual(t *testing.T, got, want int) {
t.Helper() // Marks this as helper
if got != want {
t.Errorf("got %d; want %d", got, want)
}
}Without t.Helper():
helper.go:3: got 5; want 10 // Points to helper functionWith t.Helper():
test.go:15: got 5; want 10 // Points to actual test lineTest Coverage Tracking
Go’s test coverage tracks executed code:
go test -cover # Show coverage percentage
go test -coverprofile=c.out # Generate coverage data
go tool cover -html=c.out # View coverage in browserTable-driven tests naturally improve coverage by testing multiple scenarios in single function.
Parallel Test Execution
Mark subtests for parallel execution:
for _, tt := range tests {
tt := tt // Capture range variable
t.Run(tt.name, func(t *testing.T) {
t.Parallel() // Run this subtest in parallel
// Test logic...
})
}Benefits: Faster test execution
Requirement: Must capture loop variable (tt := tt) to avoid race conditions
Variations
1. Anonymous Struct Test Tables
Inline test table definition:
func TestParse(t *testing.T) {
for _, tt := range []struct {
input string
want int
}{
{"123", 123},
{"456", 456},
{"0", 0},
} {
t.Run(tt.input, func(t *testing.T) {
got := Parse(tt.input)
if got != tt.want {
t.Errorf("Parse(%q) = %d; want %d", tt.input, got, tt.want)
}
})
}
}Trade-offs: More concise but less readable for complex cases.
2. Golden Files Testing
Compare output against saved “golden” files:
func TestRender(t *testing.T) {
tests := []struct {
name string
input Template
goldFile string
}{
{"simple", simpleTemplate, "testdata/simple.golden"},
{"complex", complexTemplate, "testdata/complex.golden"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Render(tt.input)
if *update {
os.WriteFile(tt.goldFile, []byte(got), 0644)
}
want, _ := os.ReadFile(tt.goldFile)
if got != string(want) {
t.Errorf("mismatch:\ngot:\n%s\nwant:\n%s", got, want)
}
})
}
}
// Run with: go test -update to regenerate golden filesTrade-offs: Great for large outputs but requires file management.
3. Benchmark Table Tests
Apply table-driven pattern to benchmarks:
func BenchmarkSort(b *testing.B) {
tests := []struct {
name string
size int
}{
{"small", 100},
{"medium", 1000},
{"large", 10000},
}
for _, tt := range tests {
b.Run(tt.name, func(b *testing.B) {
data := makeData(tt.size)
b.ResetTimer()
for i := 0; i < b.N; i++ {
Sort(data)
}
})
}
}Trade-offs: Consistent benchmarking across scenarios but adds complexity.
4. Setup and Teardown per Case
Include setup/teardown in test table:
func TestDatabase(t *testing.T) {
tests := []struct {
name string
setup func(*sql.DB)
test func(*sql.DB) error
cleanup func(*sql.DB)
}{
{
name: "insert",
setup: func(db *sql.DB) {
db.Exec("CREATE TABLE users (...)")
},
test: func(db *sql.DB) error {
return insertUser(db, "john")
},
cleanup: func(db *sql.DB) {
db.Exec("DROP TABLE users")
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
db := setupDB()
defer tt.cleanup(db)
tt.setup(db)
if err := tt.test(db); err != nil {
t.Errorf("test failed: %v", err)
}
})
}
}Trade-offs: Flexible per-case setup but verbose.
5. Testify Library Integration
Use testify for assertions:
import "github.com/stretchr/testify/assert"
func TestWithTestify(t *testing.T) {
tests := []struct {
name string
a, b int
want int
}{
{"add positive", 2, 3, 5},
{"add negative", -2, -3, -5},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Add(tt.a, tt.b)
assert.Equal(t, tt.want, got)
})
}
}Trade-offs: Cleaner assertions but external dependency.
Common Pitfalls
1. Not Using t.Run()
Problem: Test failures don’t indicate which case failed:
// Bad: All cases in one test
func TestAdd(t *testing.T) {
tests := []struct{a, b, want int}{
{2, 3, 5},
{-2, -3, -5},
}
for _, tt := range tests {
got := Add(tt.a, tt.b)
if got != tt.want {
t.Errorf("failed") // Which case failed?
}
}
}Solution: Use t.Run() with descriptive names:
// Good: Each case identified
func TestAdd(t *testing.T) {
tests := []struct{
name string
a, b, want int
}{
{"positive", 2, 3, 5},
{"negative", -2, -3, -5},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Add(tt.a, tt.b)
if got != tt.want {
t.Errorf("Add(%d, %d) = %d; want %d", tt.a, tt.b, got, tt.want)
}
})
}
}2. Forgetting Variable Capture in Parallel Tests
Problem: Loop variable reused causes race condition:
// Bad: Race condition
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
result := Compute(tt.input) // tt changes during iteration!
})
}Solution: Capture loop variable:
// Good: Capture variable
for _, tt := range tests {
tt := tt // Capture!
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
result := Compute(tt.input) // Safe - tt is local copy
})
}3. Missing t.Helper() in Test Helpers
Problem: Error messages point to helper function instead of test:
// Bad: No t.Helper()
func checkEqual(t *testing.T, got, want int) {
if got != want {
t.Errorf("got %d; want %d", got, want)
// Error shows this line, not calling test
}
}Solution: Always use t.Helper() in helpers:
// Good: t.Helper() marks as helper
func checkEqual(t *testing.T, got, want int) {
t.Helper() // Error points to calling test
if got != want {
t.Errorf("got %d; want %d", got, want)
}
}4. Overly Complex Test Tables
Problem: Test table becomes harder to read than separate tests:
// Bad: Too complex
tests := []struct{
name string
setupDB func() *sql.DB
setupCache func() *Cache
setupHTTP func() *http.Server
input ComplexInput
mockResponses map[string]string
validateOutput func(interface{}) bool
wantErr bool
errContains string
}{
// Each case is 20+ lines...
}Solution: Keep test tables simple, use separate tests for complex scenarios:
// Good: Simple table for simple cases
func TestSimpleCases(t *testing.T) {
tests := []struct{name string; input int; want int}{
{"case1", 1, 2},
{"case2", 2, 4},
}
// ...
}
// Good: Separate test for complex scenario
func TestComplexScenario(t *testing.T) {
db := setupDB()
cache := setupCache()
// Complex test logic without table
}5. Not Testing Error Messages
Problem: Only checking if error occurred, not what error:
// Bad: Any error passes
if tt.wantErr && err != nil {
continue // Got an error, good enough?
}Solution: Verify error content:
// Good: Check error message
if tt.wantErr {
if err == nil {
t.Error("expected error, got nil")
} else if !strings.Contains(err.Error(), tt.errMsg) {
t.Errorf("error = %q; want substring %q", err.Error(), tt.errMsg)
}
} else if err != nil {
t.Errorf("unexpected error: %v", err)
}6. Mutable Test Data
Problem: Tests modify shared test data causing interdependencies:
// Bad: Tests modify shared slice
var testData = []int{1, 2, 3}
func TestModify(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Modify(testData) // Modifies shared data!
})
}
}Solution: Create fresh data per test:
// Good: Fresh data each test
func TestModify(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
data := []int{1, 2, 3} // Fresh copy
Modify(data)
})
}
}Related Patterns
Related Tutorial: See Intermediate Tutorial - Testing for testing fundamentals.
Related How-To: See Write Effective Tests for general testing practices, Optimize Performance for benchmarking.
Related Cookbook: See Cookbook recipes “Table-Driven Tests”, “Test Helpers”, “Golden Files” for ready-to-use patterns.