Table-Driven Tests in Golang
Table-driven tests are a powerful testing pattern in Go that allows you to test multiple scenarios with a single test function. Instead of writing separate test functions for each case, you define test cases in a table (slice of structs) and iterate through them. This approach makes tests more maintainable, readable, and comprehensive.
Basic Table-Driven Test
Simple Example
package main
import "testing"
func Add(a, b int) int {
return a + b
}
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{"positive numbers", 2, 3, 5},
{"negative numbers", -1, -2, -3},
{"mixed numbers", 5, -3, 2},
{"zero addition", 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)
}
})
}
}Running the Test
go test -vOutput:
=== RUN TestAdd
=== RUN TestAdd/positive_numbers
=== RUN TestAdd/negative_numbers
=== RUN TestAdd/mixed_numbers
=== RUN TestAdd/zero_addition
--- PASS: TestAdd (0.00s)
--- PASS: TestAdd/positive_numbers (0.00s)
--- PASS: TestAdd/mixed_numbers (0.00s)
--- PASS: TestAdd/negative_numbers (0.00s)
--- PASS: TestAdd/zero_addition (0.00s)Advanced Table-Driven Tests
Testing with Errors
package main
import (
"errors"
"testing"
)
func Divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}
func TestDivide(t *testing.T) {
tests := []struct {
name string
a, b float64
expected float64
expectError bool
}{
{"normal division", 10, 2, 5, false},
{"division by zero", 10, 0, 0, true},
{"negative division", -10, 2, -5, false},
{"fractional result", 1, 2, 0.5, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := Divide(tt.a, tt.b)
if tt.expectError {
if err == nil {
t.Errorf("Divide(%v, %v) expected error but got none", tt.a, tt.b)
}
} else {
if err != nil {
t.Errorf("Divide(%v, %v) unexpected error: %v", tt.a, tt.b, err)
}
if result != tt.expected {
t.Errorf("Divide(%v, %v) = %v; want %v", tt.a, tt.b, result, tt.expected)
}
}
})
}
}Testing Complex Data Structures
package main
import (
"reflect"
"testing"
)
type User struct {
ID int
Name string
Email string
Active bool
}
func ValidateUser(u User) []string {
var errors []string
if u.ID <= 0 {
errors = append(errors, "ID must be positive")
}
if u.Name == "" {
errors = append(errors, "Name is required")
}
if u.Email == "" {
errors = append(errors, "Email is required")
}
return errors
}
func TestValidateUser(t *testing.T) {
tests := []struct {
name string
user User
expected []string
}{
{
"valid user",
User{ID: 1, Name: "John", Email: "[email protected]", Active: true},
[]string{},
},
{
"missing name and email",
User{ID: 1, Name: "", Email: "", Active: false},
[]string{"Name is required", "Email is required"},
},
{
"invalid ID",
User{ID: -1, Name: "Jane", Email: "[email protected]", Active: true},
[]string{"ID must be positive"},
},
{
"multiple errors",
User{ID: 0, Name: "", Email: "", Active: false},
[]string{"ID must be positive", "Name is required", "Email is required"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ValidateUser(tt.user)
if !reflect.DeepEqual(result, tt.expected) {
t.Errorf("ValidateUser(%+v) = %v; want %v", tt.user, result, tt.expected)
}
})
}
}Stringer and Table-Driven Tests
When working with types that implement fmt.Stringer, you can use string representations in tests:
package main
import "fmt"
type Status int
const (
StatusPending Status = iota
StatusProcessing
StatusCompleted
StatusFailed
)
func (s Status) String() string {
switch s {
case StatusPending:
return "pending"
case StatusProcessing:
return "processing"
case StatusCompleted:
return "completed"
case StatusFailed:
return "failed"
default:
return "unknown"
}
}
func ProcessStatus(s Status) Status {
switch s {
case StatusPending:
return StatusProcessing
case StatusProcessing:
return StatusCompleted
default:
return s
}
}
func TestProcessStatus(t *testing.T) {
tests := []struct {
input Status
expected Status
}{
{StatusPending, StatusProcessing},
{StatusProcessing, StatusCompleted},
{StatusCompleted, StatusCompleted}, // No change
{StatusFailed, StatusFailed}, // No change
}
for _, tt := range tests {
t.Run(fmt.Sprintf("%s->%s", tt.input, tt.expected), func(t *testing.T) {
result := ProcessStatus(tt.input)
if result != tt.expected {
t.Errorf("ProcessStatus(%s) = %s; want %s", tt.input, result, tt.expected)
}
})
}
}Benchmarking with Tables
package main
import "testing"
func Fibonacci(n int) int {
if n <= 1 {
return n
}
return Fibonacci(n-1) + Fibonacci(n-2)
}
func TestFibonacci(t *testing.T) {
tests := []struct {
n int
expected int
}{
{0, 0},
{1, 1},
{2, 1},
{3, 2},
{4, 3},
{5, 5},
{10, 55},
}
for _, tt := range tests {
t.Run(fmt.Sprintf("Fib%d", tt.n), func(t *testing.T) {
result := Fibonacci(tt.n)
if result != tt.expected {
t.Errorf("Fibonacci(%d) = %d; want %d", tt.n, result, tt.expected)
}
})
}
}
func BenchmarkFibonacci(b *testing.B) {
benchmarks := []struct {
name string
n int
}{
{"Fib10", 10},
{"Fib20", 20},
{"Fib30", 30},
}
for _, bm := range benchmarks {
b.Run(bm.name, func(b *testing.B) {
for i := 0; i < b.N; i++ {
Fibonacci(bm.n)
}
})
}
}Fuzzing with Tables
package main
import "testing"
func IsPalindrome(s string) bool {
for i := 0; i < len(s)/2; i++ {
if s[i] != s[len(s)-1-i] {
return false
}
}
return true
}
func TestIsPalindrome(t *testing.T) {
tests := []struct {
input string
expected bool
}{
{"", true},
{"a", true},
{"aa", true},
{"aba", true},
{"abba", true},
{"abc", false},
{"abca", false},
}
for _, tt := range tests {
t.Run(fmt.Sprintf("'%s'", tt.input), func(t *testing.T) {
result := IsPalindrome(tt.input)
if result != tt.expected {
t.Errorf("IsPalindrome(%q) = %v; want %v", tt.input, result, tt.expected)
}
})
}
}
func FuzzIsPalindrome(f *testing.F) {
// Add seed corpus
testCases := []string{"", "a", "aa", "aba", "abba", "abc"}
for _, tc := range testCases {
f.Add(tc)
}
f.Fuzz(func(t *testing.T, input string) {
result := IsPalindrome(input)
// Property-based checks
if len(input) <= 1 {
if !result {
t.Errorf("Single character or empty string should be palindrome: %q", input)
}
}
// Reversing twice should give same result
reversed := reverseString(input)
if IsPalindrome(reversed) != result {
t.Errorf("Palindrome property not consistent for reverse: %q vs %q", input, reversed)
}
})
}
func reverseString(s string) string {
runes := []rune(s)
for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
runes[i], runes[j] = runes[j], runes[i]
}
return string(runes)
}Parallel Testing
package main
import "testing"
func ExpensiveOperation(n int) int {
// Simulate expensive computation
result := 0
for i := 0; i < n*100000; i++ {
result += i % 10
}
return result
}
func TestExpensiveOperation(t *testing.T) {
tests := []struct {
name string
input int
expected int
}{
{"small", 1, 450000},
{"medium", 2, 900000},
{"large", 3, 1350000},
}
for _, tt := range tests {
tt := tt // Capture loop variable
t.Run(tt.name, func(t *testing.T) {
t.Parallel() // Run tests in parallel
result := ExpensiveOperation(tt.input)
if result != tt.expected {
t.Errorf("ExpensiveOperation(%d) = %d; want %d", tt.input, result, tt.expected)
}
})
}
}Subtests and Test Helpers
Test Helpers
package main
import "testing"
func assertEqual(t *testing.T, got, want interface{}, msg string) {
t.Helper()
if got != want {
t.Errorf("%s: got %v, want %v", msg, got, want)
}
}
func TestWithHelper(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{"add positive", 1, 2, 3},
{"add negative", -1, -2, -3},
{"add zero", 0, 0, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.a + tt.b
assertEqual(t, result, tt.expected, "addition result")
})
}
}Nested Subtests
package main
import "testing"
type Calculator struct{}
func (c Calculator) Add(a, b int) int { return a + b }
func (c Calculator) Subtract(a, b int) int { return a - b }
func (c Calculator) Multiply(a, b int) int { return a * b }
func TestCalculator(t *testing.T) {
calc := Calculator{}
operations := []struct {
name string
op func(int, int) int
}{
{"Add", calc.Add},
{"Subtract", calc.Subtract},
{"Multiply", calc.Multiply},
}
testCases := []struct {
a, b int
expected map[string]int
}{
{2, 3, map[string]int{"Add": 5, "Subtract": -1, "Multiply": 6}},
{10, 5, map[string]int{"Add": 15, "Subtract": 5, "Multiply": 50}},
}
for _, tc := range testCases {
t.Run(fmt.Sprintf("%d_%d", tc.a, tc.b), func(t *testing.T) {
for _, op := range operations {
t.Run(op.name, func(t *testing.T) {
result := op.op(tc.a, tc.b)
expected := tc.expected[op.name]
if result != expected {
t.Errorf("%s(%d, %d) = %d; want %d", op.name, tc.a, tc.b, result, expected)
}
})
}
})
}
}Best Practices
1. Descriptive Test Names
// Good
t.Run("valid email address", func(t *testing.T) { ... })
t.Run("empty input returns error", func(t *testing.T) { ... })
// Avoid
t.Run("test1", func(t *testing.T) { ... })
t.Run("case2", func(t *testing.T) { ... })2. Consistent Field Names
type testCase struct {
name string // Always first
input string // Input parameters
expected string // Expected output
hasError bool // Error expectations
}3. Use t.Helper() for Helper Functions
func assertEqual(t *testing.T, got, want interface{}) {
t.Helper() // Marks this as a helper function
if got != want {
t.Errorf("got %v, want %v", got, want)
}
}4. Parallel Testing When Appropriate
func TestParallel(t *testing.T) {
tests := []struct {
name string
// ...
}{
// ...
}
for _, tt := range tests {
tt := tt // Capture loop variable
t.Run(tt.name, func(t *testing.T) {
t.Parallel() // Safe for independent tests
// Test logic
})
}
}5. Table-Driven Benchmarks
func BenchmarkAlgorithms(b *testing.B) {
algorithms := []struct {
name string
fn func([]int) []int
}{
{"BubbleSort", bubbleSort},
{"QuickSort", quickSort},
}
dataSizes := []int{100, 1000, 10000}
for _, algo := range algorithms {
for _, size := range dataSizes {
b.Run(fmt.Sprintf("%s_%d", algo.name, size), func(b *testing.B) {
data := generateRandomSlice(size)
b.ResetTimer()
for i := 0; i < b.N; i++ {
algo.fn(data)
}
})
}
}
}Advanced Patterns
Generic Test Helpers
package main
import "testing"
func TestSliceOperations(t *testing.T) {
tests := []struct {
name string
input []int
operation string
expected []int
}{
{"reverse", []int{1, 2, 3}, "reverse", []int{3, 2, 1}},
{"sort", []int{3, 1, 2}, "sort", []int{1, 2, 3}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var result []int
switch tt.operation {
case "reverse":
result = reverseSlice(tt.input)
case "sort":
result = sortSlice(tt.input)
}
if !slices.Equal(result, tt.expected) {
t.Errorf("Operation %s on %v = %v; want %v", tt.operation, tt.input, result, tt.expected)
}
})
}
}Integration with Testify
package main
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestWithTestify(t *testing.T) {
tests := []struct {
name string
input string
expected bool
}{
{"valid email", "[email protected]", true},
{"invalid email", "invalid-email", false},
{"empty string", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isValidEmail(tt.input)
assert.Equal(t, tt.expected, result, "Email validation failed")
})
}
}Common Pitfalls
1. Loop Variable Capture
// Problematic
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// tt refers to the last iteration's value
// because it's captured by closure
})
}
// Fixed
for _, tt := range tests {
tt := tt // Copy the loop variable
t.Run(tt.name, func(t *testing.T) {
// Now tt is safe to use
})
}2. Incorrect Comparison of Slices/Maps
// Wrong
if result == tt.expected {
// This compares pointers, not contents
}
// Correct
if reflect.DeepEqual(result, tt.expected) {
// Deep comparison
}3. Ignoring Test Output
// Bad: No test output on failure
if result != expected {
t.Fail()
}
// Good: Descriptive error message
if result != expected {
t.Errorf("Expected %v, got %v", expected, result)
}Integration with CI/CD
Running Table-Driven Tests in CI
# GitHub Actions
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
with:
go-version: '1.21'
- run: go test -v -race -coverprofile=coverage.out ./...
- run: go tool cover -html=coverage.out -o coverage.htmlGenerating Test Reports
# JUnit XML output
go install github.com/jstemmer/go-junit-report@latest
go test -v ./... | go-junit-report > report.xml
# Coverage reports
go test -coverprofile=coverage.out ./...
go tool cover -func=coverage.outExternal Resources:
Related Tutorials:
Last updated on