Go Testing Patterns
Comprehensive Go testing patterns for writing reliable, maintainable tests following TDD methodology.
When to Activate
- Writing new Go functions or methods
- Adding test coverage to existing code
- Creating benchmarks for performance-critical code
- Implementing fuzz tests for input validation
- Following TDD workflow in Go projects
TDD Workflow for Go
The RED-GREEN-REFACTOR Cycle
RED → Write a failing test first
GREEN → Write minimal code to pass the test
REFACTOR → Improve code while keeping tests green
REPEAT → Continue with next requirement
Step-by-Step TDD in Go
go1// Step 1: Define the interface/signature 2// calculator.go 3package calculator 4 5func Add(a, b int) int { 6 panic("not implemented") // Placeholder 7} 8 9// Step 2: Write failing test (RED) 10// calculator_test.go 11package calculator 12 13import "testing" 14 15func TestAdd(t *testing.T) { 16 got := Add(2, 3) 17 want := 5 18 if got != want { 19 t.Errorf("Add(2, 3) = %d; want %d", got, want) 20 } 21} 22 23// Step 3: Run test - verify FAIL 24// $ go test 25// --- FAIL: TestAdd (0.00s) 26// panic: not implemented 27 28// Step 4: Implement minimal code (GREEN) 29func Add(a, b int) int { 30 return a + b 31} 32 33// Step 5: Run test - verify PASS 34// $ go test 35// PASS 36 37// Step 6: Refactor if needed, verify tests still pass
Table-Driven Tests
The standard pattern for Go tests. Enables comprehensive coverage with minimal code.
go1func TestAdd(t *testing.T) { 2 tests := []struct { 3 name string 4 a, b int 5 expected int 6 }{ 7 {"positive numbers", 2, 3, 5}, 8 {"negative numbers", -1, -2, -3}, 9 {"zero values", 0, 0, 0}, 10 {"mixed signs", -1, 1, 0}, 11 {"large numbers", 1000000, 2000000, 3000000}, 12 } 13 14 for _, tt := range tests { 15 t.Run(tt.name, func(t *testing.T) { 16 got := Add(tt.a, tt.b) 17 if got != tt.expected { 18 t.Errorf("Add(%d, %d) = %d; want %d", 19 tt.a, tt.b, got, tt.expected) 20 } 21 }) 22 } 23}
Table-Driven Tests with Error Cases
go1func TestParseConfig(t *testing.T) { 2 tests := []struct { 3 name string 4 input string 5 want *Config 6 wantErr bool 7 }{ 8 { 9 name: "valid config", 10 input: `{"host": "localhost", "port": 8080}`, 11 want: &Config{Host: "localhost", Port: 8080}, 12 }, 13 { 14 name: "invalid JSON", 15 input: `{invalid}`, 16 wantErr: true, 17 }, 18 { 19 name: "empty input", 20 input: "", 21 wantErr: true, 22 }, 23 { 24 name: "minimal config", 25 input: `{}`, 26 want: &Config{}, // Zero value config 27 }, 28 } 29 30 for _, tt := range tests { 31 t.Run(tt.name, func(t *testing.T) { 32 got, err := ParseConfig(tt.input) 33 34 if tt.wantErr { 35 if err == nil { 36 t.Error("expected error, got nil") 37 } 38 return 39 } 40 41 if err != nil { 42 t.Fatalf("unexpected error: %v", err) 43 } 44 45 if !reflect.DeepEqual(got, tt.want) { 46 t.Errorf("got %+v; want %+v", got, tt.want) 47 } 48 }) 49 } 50}
Subtests and Sub-benchmarks
Organizing Related Tests
go1func TestUser(t *testing.T) { 2 // Setup shared by all subtests 3 db := setupTestDB(t) 4 5 t.Run("Create", func(t *testing.T) { 6 user := &User{Name: "Alice"} 7 err := db.CreateUser(user) 8 if err != nil { 9 t.Fatalf("CreateUser failed: %v", err) 10 } 11 if user.ID == "" { 12 t.Error("expected user ID to be set") 13 } 14 }) 15 16 t.Run("Get", func(t *testing.T) { 17 user, err := db.GetUser("alice-id") 18 if err != nil { 19 t.Fatalf("GetUser failed: %v", err) 20 } 21 if user.Name != "Alice" { 22 t.Errorf("got name %q; want %q", user.Name, "Alice") 23 } 24 }) 25 26 t.Run("Update", func(t *testing.T) { 27 // ... 28 }) 29 30 t.Run("Delete", func(t *testing.T) { 31 // ... 32 }) 33}
Parallel Subtests
go1func TestParallel(t *testing.T) { 2 tests := []struct { 3 name string 4 input string 5 }{ 6 {"case1", "input1"}, 7 {"case2", "input2"}, 8 {"case3", "input3"}, 9 } 10 11 for _, tt := range tests { 12 tt := tt // Capture range variable 13 t.Run(tt.name, func(t *testing.T) { 14 t.Parallel() // Run subtests in parallel 15 result := Process(tt.input) 16 // assertions... 17 _ = result 18 }) 19 } 20}
Test Helpers
Helper Functions
go1func setupTestDB(t *testing.T) *sql.DB { 2 t.Helper() // Marks this as a helper function 3 4 db, err := sql.Open("sqlite3", ":memory:") 5 if err != nil { 6 t.Fatalf("failed to open database: %v", err) 7 } 8 9 // Cleanup when test finishes 10 t.Cleanup(func() { 11 db.Close() 12 }) 13 14 // Run migrations 15 if _, err := db.Exec(schema); err != nil { 16 t.Fatalf("failed to create schema: %v", err) 17 } 18 19 return db 20} 21 22func assertNoError(t *testing.T, err error) { 23 t.Helper() 24 if err != nil { 25 t.Fatalf("unexpected error: %v", err) 26 } 27} 28 29func assertEqual[T comparable](t *testing.T, got, want T) { 30 t.Helper() 31 if got != want { 32 t.Errorf("got %v; want %v", got, want) 33 } 34}
Temporary Files and Directories
go1func TestFileProcessing(t *testing.T) { 2 // Create temp directory - automatically cleaned up 3 tmpDir := t.TempDir() 4 5 // Create test file 6 testFile := filepath.Join(tmpDir, "test.txt") 7 err := os.WriteFile(testFile, []byte("test content"), 0644) 8 if err != nil { 9 t.Fatalf("failed to create test file: %v", err) 10 } 11 12 // Run test 13 result, err := ProcessFile(testFile) 14 if err != nil { 15 t.Fatalf("ProcessFile failed: %v", err) 16 } 17 18 // Assert... 19 _ = result 20}
Golden Files
Testing against expected output files stored in testdata/.
go1var update = flag.Bool("update", false, "update golden files") 2 3func TestRender(t *testing.T) { 4 tests := []struct { 5 name string 6 input Template 7 }{ 8 {"simple", Template{Name: "test"}}, 9 {"complex", Template{Name: "test", Items: []string{"a", "b"}}}, 10 } 11 12 for _, tt := range tests { 13 t.Run(tt.name, func(t *testing.T) { 14 got := Render(tt.input) 15 16 golden := filepath.Join("testdata", tt.name+".golden") 17 18 if *update { 19 // Update golden file: go test -update 20 err := os.WriteFile(golden, got, 0644) 21 if err != nil { 22 t.Fatalf("failed to update golden file: %v", err) 23 } 24 } 25 26 want, err := os.ReadFile(golden) 27 if err != nil { 28 t.Fatalf("failed to read golden file: %v", err) 29 } 30 31 if !bytes.Equal(got, want) { 32 t.Errorf("output mismatch:\ngot:\n%s\nwant:\n%s", got, want) 33 } 34 }) 35 } 36}
Mocking with Interfaces
Interface-Based Mocking
go1// Define interface for dependencies 2type UserRepository interface { 3 GetUser(id string) (*User, error) 4 SaveUser(user *User) error 5} 6 7// Production implementation 8type PostgresUserRepository struct { 9 db *sql.DB 10} 11 12func (r *PostgresUserRepository) GetUser(id string) (*User, error) { 13 // Real database query 14} 15 16// Mock implementation for tests 17type MockUserRepository struct { 18 GetUserFunc func(id string) (*User, error) 19 SaveUserFunc func(user *User) error 20} 21 22func (m *MockUserRepository) GetUser(id string) (*User, error) { 23 return m.GetUserFunc(id) 24} 25 26func (m *MockUserRepository) SaveUser(user *User) error { 27 return m.SaveUserFunc(user) 28} 29 30// Test using mock 31func TestUserService(t *testing.T) { 32 mock := &MockUserRepository{ 33 GetUserFunc: func(id string) (*User, error) { 34 if id == "123" { 35 return &User{ID: "123", Name: "Alice"}, nil 36 } 37 return nil, ErrNotFound 38 }, 39 } 40 41 service := NewUserService(mock) 42 43 user, err := service.GetUserProfile("123") 44 if err != nil { 45 t.Fatalf("unexpected error: %v", err) 46 } 47 if user.Name != "Alice" { 48 t.Errorf("got name %q; want %q", user.Name, "Alice") 49 } 50}
Benchmarks
Basic Benchmarks
go1func BenchmarkProcess(b *testing.B) { 2 data := generateTestData(1000) 3 b.ResetTimer() // Don't count setup time 4 5 for i := 0; i < b.N; i++ { 6 Process(data) 7 } 8} 9 10// Run: go test -bench=BenchmarkProcess -benchmem 11// Output: BenchmarkProcess-8 10000 105234 ns/op 4096 B/op 10 allocs/op
Benchmark with Different Sizes
go1func BenchmarkSort(b *testing.B) { 2 sizes := []int{100, 1000, 10000, 100000} 3 4 for _, size := range sizes { 5 b.Run(fmt.Sprintf("size=%d", size), func(b *testing.B) { 6 data := generateRandomSlice(size) 7 b.ResetTimer() 8 9 for i := 0; i < b.N; i++ { 10 // Make a copy to avoid sorting already sorted data 11 tmp := make([]int, len(data)) 12 copy(tmp, data) 13 sort.Ints(tmp) 14 } 15 }) 16 } 17}
Memory Allocation Benchmarks
go1func BenchmarkStringConcat(b *testing.B) { 2 parts := []string{"hello", "world", "foo", "bar", "baz"} 3 4 b.Run("plus", func(b *testing.B) { 5 for i := 0; i < b.N; i++ { 6 var s string 7 for _, p := range parts { 8 s += p 9 } 10 _ = s 11 } 12 }) 13 14 b.Run("builder", func(b *testing.B) { 15 for i := 0; i < b.N; i++ { 16 var sb strings.Builder 17 for _, p := range parts { 18 sb.WriteString(p) 19 } 20 _ = sb.String() 21 } 22 }) 23 24 b.Run("join", func(b *testing.B) { 25 for i := 0; i < b.N; i++ { 26 _ = strings.Join(parts, "") 27 } 28 }) 29}
Fuzzing (Go 1.18+)
Basic Fuzz Test
go1func FuzzParseJSON(f *testing.F) { 2 // Add seed corpus 3 f.Add(`{"name": "test"}`) 4 f.Add(`{"count": 123}`) 5 f.Add(`[]`) 6 f.Add(`""`) 7 8 f.Fuzz(func(t *testing.T, input string) { 9 var result map[string]interface{} 10 err := json.Unmarshal([]byte(input), &result) 11 12 if err != nil { 13 // Invalid JSON is expected for random input 14 return 15 } 16 17 // If parsing succeeded, re-encoding should work 18 _, err = json.Marshal(result) 19 if err != nil { 20 t.Errorf("Marshal failed after successful Unmarshal: %v", err) 21 } 22 }) 23} 24 25// Run: go test -fuzz=FuzzParseJSON -fuzztime=30s
Fuzz Test with Multiple Inputs
go1func FuzzCompare(f *testing.F) { 2 f.Add("hello", "world") 3 f.Add("", "") 4 f.Add("abc", "abc") 5 6 f.Fuzz(func(t *testing.T, a, b string) { 7 result := Compare(a, b) 8 9 // Property: Compare(a, a) should always equal 0 10 if a == b && result != 0 { 11 t.Errorf("Compare(%q, %q) = %d; want 0", a, b, result) 12 } 13 14 // Property: Compare(a, b) and Compare(b, a) should have opposite signs 15 reverse := Compare(b, a) 16 if (result > 0 && reverse >= 0) || (result < 0 && reverse <= 0) { 17 if result != 0 || reverse != 0 { 18 t.Errorf("Compare(%q, %q) = %d, Compare(%q, %q) = %d; inconsistent", 19 a, b, result, b, a, reverse) 20 } 21 } 22 }) 23}
Test Coverage
Running Coverage
bash1# Basic coverage 2go test -cover ./... 3 4# Generate coverage profile 5go test -coverprofile=coverage.out ./... 6 7# View coverage in browser 8go tool cover -html=coverage.out 9 10# View coverage by function 11go tool cover -func=coverage.out 12 13# Coverage with race detection 14go test -race -coverprofile=coverage.out ./...
Coverage Targets
| Code Type | Target |
|---|---|
| Critical business logic | 100% |
| Public APIs | 90%+ |
| General code | 80%+ |
| Generated code | Exclude |
Excluding Generated Code from Coverage
go1//go:generate mockgen -source=interface.go -destination=mock_interface.go 2 3// In coverage profile, exclude with build tags: 4// go test -cover -tags=!generate ./...
HTTP Handler Testing
go1func TestHealthHandler(t *testing.T) { 2 // Create request 3 req := httptest.NewRequest(http.MethodGet, "/health", nil) 4 w := httptest.NewRecorder() 5 6 // Call handler 7 HealthHandler(w, req) 8 9 // Check response 10 resp := w.Result() 11 defer resp.Body.Close() 12 13 if resp.StatusCode != http.StatusOK { 14 t.Errorf("got status %d; want %d", resp.StatusCode, http.StatusOK) 15 } 16 17 body, _ := io.ReadAll(resp.Body) 18 if string(body) != "OK" { 19 t.Errorf("got body %q; want %q", body, "OK") 20 } 21} 22 23func TestAPIHandler(t *testing.T) { 24 tests := []struct { 25 name string 26 method string 27 path string 28 body string 29 wantStatus int 30 wantBody string 31 }{ 32 { 33 name: "get user", 34 method: http.MethodGet, 35 path: "/users/123", 36 wantStatus: http.StatusOK, 37 wantBody: `{"id":"123","name":"Alice"}`, 38 }, 39 { 40 name: "not found", 41 method: http.MethodGet, 42 path: "/users/999", 43 wantStatus: http.StatusNotFound, 44 }, 45 { 46 name: "create user", 47 method: http.MethodPost, 48 path: "/users", 49 body: `{"name":"Bob"}`, 50 wantStatus: http.StatusCreated, 51 }, 52 } 53 54 handler := NewAPIHandler() 55 56 for _, tt := range tests { 57 t.Run(tt.name, func(t *testing.T) { 58 var body io.Reader 59 if tt.body != "" { 60 body = strings.NewReader(tt.body) 61 } 62 63 req := httptest.NewRequest(tt.method, tt.path, body) 64 req.Header.Set("Content-Type", "application/json") 65 w := httptest.NewRecorder() 66 67 handler.ServeHTTP(w, req) 68 69 if w.Code != tt.wantStatus { 70 t.Errorf("got status %d; want %d", w.Code, tt.wantStatus) 71 } 72 73 if tt.wantBody != "" && w.Body.String() != tt.wantBody { 74 t.Errorf("got body %q; want %q", w.Body.String(), tt.wantBody) 75 } 76 }) 77 } 78}
Testing Commands
bash1# Run all tests 2go test ./... 3 4# Run tests with verbose output 5go test -v ./... 6 7# Run specific test 8go test -run TestAdd ./... 9 10# Run tests matching pattern 11go test -run "TestUser/Create" ./... 12 13# Run tests with race detector 14go test -race ./... 15 16# Run tests with coverage 17go test -cover -coverprofile=coverage.out ./... 18 19# Run short tests only 20go test -short ./... 21 22# Run tests with timeout 23go test -timeout 30s ./... 24 25# Run benchmarks 26go test -bench=. -benchmem ./... 27 28# Run fuzzing 29go test -fuzz=FuzzParse -fuzztime=30s ./... 30 31# Count test runs (for flaky test detection) 32go test -count=10 ./...
Best Practices
DO:
- Write tests FIRST (TDD)
- Use table-driven tests for comprehensive coverage
- Test behavior, not implementation
- Use
t.Helper()in helper functions - Use
t.Parallel()for independent tests - Clean up resources with
t.Cleanup() - Use meaningful test names that describe the scenario
DON'T:
- Test private functions directly (test through public API)
- Use
time.Sleep()in tests (use channels or conditions) - Ignore flaky tests (fix or remove them)
- Mock everything (prefer integration tests when possible)
- Skip error path testing
Integration with CI/CD
yaml1# GitHub Actions example 2test: 3 runs-on: ubuntu-latest 4 steps: 5 - uses: actions/checkout@v4 6 - uses: actions/setup-go@v5 7 with: 8 go-version: '1.22' 9 10 - name: Run tests 11 run: go test -race -coverprofile=coverage.out ./... 12 13 - name: Check coverage 14 run: | 15 go tool cover -func=coverage.out | grep total | awk '{print $3}' | \ 16 awk -F'%' '{if ($1 < 80) exit 1}'
Remember: Tests are documentation. They show how your code is meant to be used. Write them clearly and keep them up to date.