Parallel Table-Driven Tests in Go

Speed up Go tests with parallel execution

Page content

Table-driven tests are the idiomatic Go approach for testing multiple scenarios efficiently. When combined with parallel execution using t.Parallel(), you can dramatically reduce test suite runtime, especially for I/O-bound operations.

However, parallel testing introduces unique challenges around race conditions and test isolation that require careful attention.

Parallel Table-Driven Tests in Go - golang racing conditions

Understanding Parallel Test Execution

Go’s testing package provides built-in support for parallel test execution through the t.Parallel() method. When a test calls t.Parallel(), it signals to the test runner that this test can safely run concurrently with other parallel tests. This is particularly powerful when combined with table-driven tests, where you have many independent test cases that can execute simultaneously.

The default parallelism is controlled by GOMAXPROCS, which typically equals the number of CPU cores on your machine. You can adjust this with the -parallel flag: go test -parallel 4 limits concurrent tests to 4, regardless of your CPU count. This is useful for controlling resource usage or when tests have specific concurrency requirements.

For developers new to Go testing, understanding the fundamentals is crucial. Our guide on Go unit testing best practices covers table-driven tests, subtests, and the testing package basics that form the foundation for parallel execution.

Basic Parallel Table-Driven Test Pattern

Here’s the correct pattern for parallel table-driven tests:

func TestCalculate(t *testing.T) {
    tests := []struct {
        name     string
        a, b     int
        op       string
        expected int
        wantErr  bool
    }{
        {"addition", 2, 3, "+", 5, false},
        {"subtraction", 5, 3, "-", 2, false},
        {"multiplication", 4, 3, "*", 12, false},
        {"division", 10, 2, "/", 5, false},
        {"division by zero", 10, 0, "/", 0, true},
    }

    for _, tt := range tests {
        tt := tt // Capture loop variable
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel() // Enable parallel execution
            result, err := Calculate(tt.a, tt.b, tt.op)
            
            if (err != nil) != tt.wantErr {
                t.Errorf("Calculate() error = %v, wantErr %v", err, tt.wantErr)
                return
            }
            
            if result != tt.expected {
                t.Errorf("Calculate(%d, %d, %q) = %d; want %d", 
                    tt.a, tt.b, tt.op, result, tt.expected)
            }
        })
    }
}

The critical line is tt := tt before t.Run(). This captures the current value of the loop variable, ensuring each parallel subtest operates on its own copy of the test case data.

The Loop Variable Capture Problem

This is one of the most common pitfalls when using t.Parallel() with table-driven tests. In Go, the loop variable tt is shared across all iterations. When subtests run in parallel, they may all reference the same tt variable, which gets overwritten as the loop continues. This leads to race conditions and unpredictable test failures.

Incorrect (race condition):

for _, tt := range tests {
    t.Run(tt.name, func(t *testing.T) {
        t.Parallel()
        // All subtests may see the same tt value!
        result := Calculate(tt.a, tt.b, tt.op)
    })
}

Correct (captured variable):

for _, tt := range tests {
    tt := tt // Capture the loop variable
    t.Run(tt.name, func(t *testing.T) {
        t.Parallel()
        // Each subtest has its own copy of tt
        result := Calculate(tt.a, tt.b, tt.op)
    })
}

The tt := tt assignment creates a new variable scoped to the loop iteration, ensuring each goroutine has its own copy of the test case data.

Ensuring Test Independence

For parallel tests to work correctly, each test must be completely independent. They should not:

  • Share global state or variables
  • Modify shared resources without synchronization
  • Depend on execution order
  • Access the same files, databases, or network resources without coordination

Example of independent parallel tests:

func TestValidateEmail(t *testing.T) {
    tests := []struct {
        name    string
        email   string
        wantErr bool
    }{
        {"valid email", "user@example.com", false},
        {"invalid format", "not-an-email", true},
        {"missing domain", "user@", true},
    }

    for _, tt := range tests {
        tt := tt
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()
            err := ValidateEmail(tt.email)
            if (err != nil) != tt.wantErr {
                t.Errorf("ValidateEmail(%q) error = %v, wantErr %v", 
                    tt.email, err, tt.wantErr)
            }
        })
    }
}

Each test case operates on its own input data with no shared state, making it safe for parallel execution.

Detecting Race Conditions

Go provides a powerful race detector to catch data races in parallel tests. Always run your parallel tests with the -race flag during development:

go test -race ./...

The race detector will report any concurrent access to shared memory without proper synchronization. This is essential for catching subtle bugs that might only appear under specific timing conditions.

Example race condition:

var counter int // Shared state - DANGEROUS!

func TestIncrement(t *testing.T) {
    tests := []struct {
        name string
        want int
    }{
        {"test1", 1},
        {"test2", 2},
    }

    for _, tt := range tests {
        tt := tt
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()
            counter++ // RACE CONDITION!
            if counter != tt.want {
                t.Errorf("counter = %d, want %d", counter, tt.want)
            }
        })
    }
}

Running this with -race will detect the concurrent modification of counter. The fix is to make each test independent by using local variables instead of shared state.

Performance Benefits

Parallel execution can significantly reduce test suite runtime. The speedup depends on:

  • Number of CPU cores: More cores allow more tests to run simultaneously
  • Test characteristics: I/O-bound tests benefit more than CPU-bound tests
  • Test count: Larger test suites see greater absolute time savings

Measuring performance:

# Sequential execution
go test -parallel 1 ./...

# Parallel execution (default)
go test ./...

# Custom parallelism
go test -parallel 8 ./...

For test suites with many I/O operations (database queries, HTTP requests, file operations), you can often achieve 2-4x speedup on modern multi-core systems. CPU-bound tests may see less benefit due to contention for CPU resources.

Controlling Parallelism

You have several options for controlling parallel test execution:

1. Limit maximum parallel tests:

go test -parallel 4 ./...

2. Set GOMAXPROCS:

GOMAXPROCS=2 go test ./...

3. Selective parallel execution:

Only mark specific tests with t.Parallel(). Tests without this call run sequentially, which is useful when some tests must run in order or share resources.

4. Conditional parallel execution:

func TestExpensive(t *testing.T) {
    if testing.Short() {
        t.Skip("skipping expensive test in short mode")
    }
    t.Parallel()
    // Expensive test logic
}

Common Patterns and Best Practices

Pattern 1: Setup Before Parallel Execution

If you need setup that’s shared across all test cases, do it before the loop:

func TestWithSetup(t *testing.T) {
    // Setup code runs once, before parallel execution
    db := setupTestDatabase(t)
    defer db.Close()

    tests := []struct {
        name string
        id   int
    }{
        {"user1", 1},
        {"user2", 2},
    }

    for _, tt := range tests {
        tt := tt
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()
            // Each test uses db independently
            user := db.GetUser(tt.id)
            // Test logic...
        })
    }
}

Pattern 2: Per-Test Setup

For tests that need isolated setup, do it inside each subtest:

func TestWithPerTestSetup(t *testing.T) {
    tests := []struct {
        name string
        data string
    }{
        {"test1", "data1"},
        {"test2", "data2"},
    }

    for _, tt := range tests {
        tt := tt
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()
            // Each test gets its own setup
            tempFile := createTempFile(t, tt.data)
            defer os.Remove(tempFile)
            // Test logic...
        })
    }
}

Pattern 3: Mixed Sequential and Parallel

You can mix sequential and parallel tests in the same file:

func TestSequential(t *testing.T) {
    // No t.Parallel() - runs sequentially
    // Good for tests that must run in order
}

func TestParallel(t *testing.T) {
    tests := []struct{ name string }{{"test1"}, {"test2"}}
    for _, tt := range tests {
        tt := tt
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel() // These run in parallel
        })
    }
}

When NOT to Use Parallel Execution

Parallel execution isn’t always appropriate. Avoid it when:

  1. Tests share state: Global variables, singletons, or shared resources
  2. Tests modify shared files: Temporary files, test databases, or config files
  3. Tests depend on execution order: Some tests must run before others
  4. Tests are already fast: Overhead of parallelization may exceed benefits
  5. Resource constraints: Tests consume too much memory or CPU when parallelized

For database-related tests, consider using transaction rollbacks or separate test databases per test. Our guide on multi-tenant database patterns in Go covers isolation strategies that work well with parallel testing.

Advanced: Testing Concurrent Code

When testing concurrent code itself (not just running tests in parallel), you need additional techniques:

func TestConcurrentOperation(t *testing.T) {
    tests := []struct {
        name      string
        goroutines int
    }{
        {"2 goroutines", 2},
        {"10 goroutines", 10},
        {"100 goroutines", 100},
    }

    for _, tt := range tests {
        tt := tt
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()
            
            var wg sync.WaitGroup
            results := make(chan int, tt.goroutines)
            
            for i := 0; i < tt.goroutines; i++ {
                wg.Add(1)
                go func() {
                    defer wg.Done()
                    results <- performOperation()
                }()
            }
            
            wg.Wait()
            close(results)
            
            // Verify results
            count := 0
            for range results {
                count++
            }
            if count != tt.goroutines {
                t.Errorf("expected %d results, got %d", tt.goroutines, count)
            }
        })
    }
}

Always run such tests with -race to detect data races in the code under test.

Integration with CI/CD

Parallel tests integrate seamlessly with CI/CD pipelines. Most CI systems provide multiple CPU cores, making parallel execution highly beneficial:

# Example GitHub Actions
- name: Run tests
  run: |
    go test -race -coverprofile=coverage.out -parallel 4 ./...
    go tool cover -html=coverage.out -o coverage.html    

The -race flag is especially important in CI to catch concurrency bugs that might not appear in local development.

Debugging Parallel Test Failures

When parallel tests fail intermittently, debugging can be challenging:

  1. Run with -race: Identify data races
  2. Reduce parallelism: go test -parallel 1 to see if failures disappear
  3. Run specific tests: go test -run TestName to isolate the problem
  4. Add logging: Use t.Log() to trace execution order
  5. Check for shared state: Look for global variables, singletons, or shared resources

If tests pass sequentially but fail in parallel, you likely have a race condition or shared state issue.

Real-World Example: Testing HTTP Handlers

Here’s a practical example testing HTTP handlers in parallel:

func TestHTTPHandlers(t *testing.T) {
    router := setupRouter()
    
    tests := []struct {
        name       string
        method     string
        path       string
        statusCode int
    }{
        {"GET users", "GET", "/users", 200},
        {"GET user by ID", "GET", "/users/1", 200},
        {"POST user", "POST", "/users", 201},
        {"DELETE user", "DELETE", "/users/1", 204},
    }

    for _, tt := range tests {
        tt := tt
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()
            
            req := httptest.NewRequest(tt.method, tt.path, nil)
            w := httptest.NewRecorder()
            
            router.ServeHTTP(w, req)
            
            if w.Code != tt.statusCode {
                t.Errorf("expected status %d, got %d", tt.statusCode, w.Code)
            }
        })
    }
}

Each test uses httptest.NewRecorder(), which creates an isolated response recorder, making these tests safe for parallel execution.

Conclusion

Parallel execution of table-driven tests is a powerful technique for reducing test suite runtime in Go. The key to success is understanding the loop variable capture requirement, ensuring test independence, and using the race detector to catch concurrency issues early.

Remember:

  • Always capture loop variables: tt := tt before t.Parallel()
  • Ensure tests are independent with no shared state
  • Run tests with -race during development
  • Control parallelism with the -parallel flag when needed
  • Avoid parallel execution for tests that share resources

By following these practices, you can safely leverage parallel execution to speed up your test suites while maintaining reliability. For more Go testing patterns, see our comprehensive Go unit testing guide, which covers table-driven tests, mocking, and other essential testing techniques.

When building larger Go applications, these testing practices apply across different domains. For example, when building CLI applications with Cobra & Viper, you’ll use similar parallel testing patterns for testing command handlers and configuration parsing.

External Resources