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.
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:
- Tests share state: Global variables, singletons, or shared resources
- Tests modify shared files: Temporary files, test databases, or config files
- Tests depend on execution order: Some tests must run before others
- Tests are already fast: Overhead of parallelization may exceed benefits
- 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:
-
Run with
-race: Identify data races -
Reduce parallelism:
go test -parallel 1to see if failures disappear -
Run specific tests:
go test -run TestNameto isolate the problem -
Add logging: Use
t.Log()to trace execution order - 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 := ttbeforet.Parallel() - Ensure tests are independent with no shared state
- Run tests with
-raceduring development - Control parallelism with the
-parallelflag 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.
Useful Links
- Go Cheatsheet
- Go Unit Testing: Structure & Best Practices
- Building CLI Applications in Go with Cobra & Viper
- Multi-Tenancy Database Patterns with examples in Go
- Go ORMs for PostgreSQL: GORM vs Ent vs Bun vs sqlc
Top comments (0)