8.6 KiB
Testing Patterns
Patterns for table-driven tests, parallel tests, benchmarks, and test helpers.
CRITICAL: Don't Call t.Fatal from Goroutines
Calling t.Fatal(), t.FailNow(), or t.Skip() from goroutines causes immediate panic and corrupted test state. These functions must only be called from the goroutine running the test function.
CRITICAL BUG - causes panic:
func TestConcurrent(t *testing.T) {
go func() {
result, err := fetchData()
if err != nil {
t.Fatal(err) // PANIC! Called from wrong goroutine
}
}()
}
Correct - use t.Error and coordinate with main goroutine:
func TestConcurrent(t *testing.T) {
errCh := make(chan error, 1)
go func() {
result, err := fetchData()
if err != nil {
errCh <- err // Send error to main goroutine
return
}
errCh <- nil
}()
if err := <-errCh; err != nil {
t.Fatalf("fetchData failed: %v", err) // Called from test goroutine
}
}
Alternative - use t.Error from goroutine:
func TestConcurrent(t *testing.T) {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
result, err := fetchData()
if err != nil {
t.Error(err) // Safe - doesn't terminate immediately
return
}
}()
wg.Wait()
}
Why: t.Fatal() calls runtime.Goexit(), which is only safe from the test's main goroutine. From other goroutines, it causes panics and prevents proper test cleanup.
Table-Driven Tests
Use when testing against multiple input/output conditions.
Example:
func TestParseURL(t *testing.T) {
tests := []struct{
name string
give string
wantHost string
wantErr bool
}{
{
name: "simple",
give: "http://example.com",
wantHost: "example.com",
},
{
name: "invalid",
give: "://invalid",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
u, err := ParseURL(tt.give)
if tt.wantErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.wantHost, u.Host)
})
}
}
Benefits:
- Reduces redundancy
- Easy to add new cases
- Clear test data structure
Avoid Test Complexity
Split table tests with excessive conditionals into separate test functions.
Bad:
tests := []struct{
give string
shouldErr bool
shouldCall1 bool
shouldCall2 bool
check1 func()
check2 func()
}{
// Complex logic in table
}
Good:
func TestSuccess(t *testing.T) {
// Simple, focused test
}
func TestError(t *testing.T) {
// Simple, focused test
}
Parallel Tests
Go 1.22+ automatically scopes loop variables per-iteration, eliminating the need for manual capture.
Example:
tests := []struct{ give string }{{give: "A"}, {give: "B"}}
for _, tt := range tests {
t.Run(tt.give, func(t *testing.T) {
t.Parallel()
// tt is automatically per-iteration in Go 1.22+
})
}
Context in Tests
Use t.Context() (Go 1.24+) to obtain a context that is automatically canceled when the test completes.
Bad:
func TestService(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // Manual cleanup
result, err := service.Run(ctx)
// ...
}
Good:
func TestService(t *testing.T) {
ctx := t.Context() // Auto-canceled on cleanup
result, err := service.Run(ctx)
// ...
}
Testing Time
Use testing/synctest (Go 1.25+) for fast, deterministic testing of time-dependent code.
Problem: Tests using time.Sleep or time.After are slow and can be flaky.
Old approach:
func TestTimeout(t *testing.T) {
done := make(chan bool)
go func() {
time.Sleep(5 * time.Second) // Slow!
done <- true
}()
<-done
}
Modern approach with synctest:
import "testing/synctest"
func TestTimeout(t *testing.T) {
synctest.Run(func() {
done := make(chan bool)
go func() {
time.Sleep(5 * time.Second) // Executes instantly
done <- true
}()
synctest.Wait() // Wait for goroutines to block
<-done // Completes instantly
})
}
Benefits:
- Tests run instantly (no actual sleeping)
- Deterministic timing behavior
- No modifications to production code
- Detects deadlocks and timing bugs
When to use: Any test involving time.Sleep, time.After, time.NewTimer, or time.NewTicker.
Benchmark Loop Pattern
Use b.Loop() (Go 1.24+) for cleaner benchmark code.
Old pattern:
func BenchmarkOperation(b *testing.B) {
// Expensive setup
data := setupData()
b.ResetTimer() // Easy to forget!
for i := 0; i < b.N; i++ {
operation(data)
}
b.StopTimer() // Also easy to forget
// Cleanup
}
Modern pattern:
func BenchmarkOperation(b *testing.B) {
// Expensive setup - timer not running yet
data := setupData()
for b.Loop() {
operation(data) // Automatically measured
}
// Cleanup - timer already stopped
}
Benefits:
- Eliminates forgotten
ResetTimer/StopTimercalls - Prevents dead-code elimination issues
- Cleaner, less error-prone API
- Setup/cleanup automatically excluded from timing
Test Helper Patterns
Test helpers should call t.Helper() to improve failure line reporting. Helpers take testing.T as a parameter, allowing them to report failures directly.
Pattern:
func setupUser(t *testing.T, name string) *User {
t.Helper() // Failure reports point to caller, not this line
user, err := createUser(name)
if err != nil {
t.Fatalf("failed to setup user: %v", err)
}
return user
}
func TestUserWorkflow(t *testing.T) {
user := setupUser(t, "alice") // Failure points here, not inside helper
// ... test logic
}
Why: t.Helper() marks the function as a test helper, causing failure messages to report the caller's location instead of the line inside the helper.
Benefits:
- Clear failure locations in test output
- Helpers can fail tests directly
- Simplified test code
Test Failure Messages
Format test failure messages to include function name, inputs, actual value, and expected value.
Pattern: FunctionName(inputs) = actual, want expected
Good:
func TestParseInt(t *testing.T) {
got, err := ParseInt("invalid")
if err == nil {
t.Errorf("ParseInt(%q) succeeded, want error", "invalid")
}
got, err = ParseInt("42")
want := 42
if got != want {
t.Errorf("ParseInt(%q) = %d, want %d", "42", got, want)
}
}
Conventions:
- Include function name
- Include inputs if short
- Show actual value BEFORE expected value
- Use "got" for actual, "want" for expected
- Be specific about what failed
Bad:
t.Errorf("wrong value") // What value? What was it? What was expected?
t.Errorf("expected %d but got %d", want, got) // Backwards (expected first)
Why: Consistent, informative failure messages make test output easier to parse and debug.
t.Error vs t.Fatal Choice
Choose between t.Error and t.Fatal based on whether subsequent checks are meaningful.
Prefer t.Error to reveal all failures in one run:
func TestValidation(t *testing.T) {
result := Validate(input)
if result.Name == "" {
t.Error("Name should not be empty") // Continue checking
}
if result.Email == "" {
t.Error("Email should not be empty") // Shows both failures
}
}
Use t.Fatal when subsequent checks would panic or be meaningless:
func TestDatabase(t *testing.T) {
db, err := OpenDB()
if err != nil {
t.Fatalf("OpenDB failed: %v", err) // Can't continue without DB
}
defer db.Close()
// These would panic if db is nil
result := db.Query("SELECT * FROM users")
}
In table-driven tests:
- Use
t.Fatal()in subtests (per-entry failures) - Use
t.Error()+continuein non-subtest loops
Why: t.Error reveals multiple issues; t.Fatal prevents cascading failures.
Test Assertions
Simple rule: If the codebase already uses an assertion library (testify, etc.), continue using it for consistency. For new projects, use standard library testing patterns unless an assertion library is explicitly requested.
Standard library pattern:
func TestAdd(t *testing.T) {
got := Add(2, 3)
want := 5
if got != want {
t.Errorf("Add(2, 3) = %d, want %d", got, want)
}
}
With assertion library (if already in codebase):
func TestAdd(t *testing.T) {
got := Add(2, 3)
assert.Equal(t, 5, got)
}
Why: Consistency within a project matters more than the specific assertion style. Avoid adding dependencies to new projects without explicit need.