Table-Driven Tests in Golang

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 -v

Output:

=== 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.html

Generating 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.out

External Resources:

Related Tutorials:

Last updated on