Ch15 Pro Tips — Completing Test Strategy
Testing in production Go code is not just about hitting coverage numbers. Learn advanced strategies for building fast and reliable test suites.
Test Pyramid Strategy
/\
/E2E\ ← Few, slow, expensive
/──────\
/Integration\ ← Medium
/────────────\
/ Unit Tests \ ← Many, fast, cheap
/────────────────\
Practice in Go
// Unit tests: pure functions, logic verification — fast and plentiful
func TestCalculateDiscount(t *testing.T) { ... }
// Integration tests: includes DB/external APIs — slower and fewer
func TestUserRepository_Integration(t *testing.T) {
if testing.Short() {
t.Skip("integration test skipped with -short")
}
// ... testcontainers or test database
}
// E2E tests: entire scenario — slowest and fewest
func TestUserFlow_E2E(t *testing.T) {
if os.Getenv("E2E_TEST") == "" {
t.Skip("E2E test skipped without E2E_TEST env var")
}
// ... real server + real DB + real HTTP client
}
# Unit tests only (fast)
go test ./...
# Skip integration tests
go test -short ./...
# Include E2E (slow)
E2E_TEST=1 go test ./...
Coverage Strategy
Meaningful Coverage
// ✅ Test behavior — coverage is a byproduct
func TestTransfer(t *testing.T) {
tests := []struct {
name string
from int64
to int64
amount float64
wantErr bool
}{
{"normal transfer", 1, 2, 100.0, false},
{"insufficient balance", 1, 2, 99999.0, true},
{"same account", 1, 1, 100.0, true},
{"negative amount", 1, 2, -10.0, true},
}
// ...
}
// ❌ Test for coverage — meaningless
func TestGetterSetter(t *testing.T) {
u := User{}
u.SetName("test")
assert.Equal(t, "test", u.GetName()) // coverage for getter/setter only
}
Analyzing Coverage Reports
# Measure coverage
go test -coverprofile=coverage.out ./...
# View coverage by function
go tool cover -func=coverage.out | tail -20
# HTML visualization
go tool cover -html=coverage.out -o coverage.html
# Validate minimum coverage in CI
go test -coverprofile=coverage.out ./... && \
go tool cover -func=coverage.out | \
awk '/total:/{if ($3+0 < 80) {print "coverage below 80%: " $3; exit 1}}'
Test Isolation Pattern
Why Each Test Must Be Independent
// ❌ Shared state creates test dependencies
var sharedDB *sql.DB
func TestA(t *testing.T) {
sharedDB.Exec("INSERT INTO users VALUES (1, 'A')")
// ...
}
func TestB(t *testing.T) {
// TestA must run first for this to pass — order dependency!
row := sharedDB.QueryRow("SELECT name FROM users WHERE id = 1")
// ...
}
// ✅ Each test creates its own state
func TestA(t *testing.T) {
db := setupTestDB(t) // auto-cleanup with t.Cleanup
db.Exec("INSERT INTO users VALUES (1, 'A')")
// ...
}
func TestB(t *testing.T) {
db := setupTestDB(t)
// ...
}
Automate Cleanup with t.Cleanup
func setupTestServer(t *testing.T) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
// register handlers...
ts := httptest.NewServer(mux)
t.Cleanup(func() {
ts.Close()
})
return ts
}
func setupTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite3", ":memory:")
require.NoError(t, err)
t.Cleanup(func() {
db.Close()
})
runMigrations(t, db)
return db
}
Test Helper Libraries
Advanced testify Usage
import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
// Custom assert messages
assert.Equal(t, expected, actual, "user ID must match: user=%v", user)
// Verify error type
var notFoundErr *NotFoundError
assert.ErrorAs(t, err, ¬FoundErr)
assert.Equal(t, int64(42), notFoundErr.ID)
// Check error message content
assert.ErrorContains(t, err, "invalid email")
// Eventually — verify async state
assert.Eventually(t, func() bool {
return cache.Has("key")
}, 5*time.Second, 100*time.Millisecond)
// Never — condition must be false for duration
assert.Never(t, func() bool {
return len(errors) > 0
}, 2*time.Second, 50*time.Millisecond)
Suite Pattern — Shared Setup/Teardown
type UserServiceSuite struct {
suite.Suite
db *sql.DB
service *UserService
mock *MockEmailService
}
func (s *UserServiceSuite) SetupSuite() {
// Run once for entire suite
s.db = setupTestDB(s.T())
}
func (s *UserServiceSuite) SetupTest() {
// Run before each test
s.mock = new(MockEmailService)
repo := NewUserRepository(s.db)
s.service = NewUserService(repo, s.mock)
// Initialize DB
s.db.Exec("DELETE FROM users")
}
func (s *UserServiceSuite) TearDownTest() {
// Run after each test
s.mock.AssertExpectations(s.T())
}
func (s *UserServiceSuite) TestRegister_Success() {
user, err := s.service.Register(context.Background(), "Kim Golang", "go@example.com", "pass")
s.Require().NoError(err)
s.Equal("Kim Golang", user.Name)
}
func (s *UserServiceSuite) TestRegister_DuplicateEmail() {
// ...
}
// Suite entry point
func TestUserServiceSuite(t *testing.T) {
suite.Run(t, new(UserServiceSuite))
}
Golden File Testing
Store complex outputs (HTML, JSON reports, etc.) in files and compare.
func TestGenerateReport(t *testing.T) {
report := GenerateReport(testData)
goldenFile := "testdata/report.golden"
// Use -update flag to regenerate golden file
if *update {
os.WriteFile(goldenFile, []byte(report), 0644)
return
}
expected, err := os.ReadFile(goldenFile)
require.NoError(t, err)
assert.Equal(t, string(expected), report)
}
var update = flag.Bool("update", false, "regenerate golden file")
# Generate or update golden file
go test -run TestGenerateReport -update
# Subsequent regression tests
go test -run TestGenerateReport
CI/CD Test Optimization
Makefile Test Targets
.PHONY: test test-unit test-integration test-e2e test-cover
# Fast unit tests (for PRs)
test-unit:
go test -short -race ./...
# Integration tests included
test-integration:
go test -race ./...
# E2E tests
test-e2e:
E2E_TEST=1 go test -race -timeout=10m ./...
# Coverage report
test-cover:
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html
@go tool cover -func=coverage.out | grep total
# All tests
test: test-unit test-integration
GitHub Actions Integration
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.22'
cache: true
- name: Run unit tests
run: go test -short -race -coverprofile=coverage.out ./...
- name: Check coverage
run: |
COVERAGE=$(go tool cover -func=coverage.out | tail -1 | awk '{print $3}' | tr -d '%')
echo "Coverage: ${COVERAGE}%"
if (( $(echo "$COVERAGE < 80" | bc -l) )); then
echo "coverage below 80%"
exit 1
fi
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
files: coverage.out
-race Flag — Detect Race Conditions
# Always use in CI
go test -race ./...
// ❌ Race condition: goroutines access shared variable
func TestRace(t *testing.T) {
count := 0
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
count++ // DATA RACE!
}()
}
wg.Wait()
}
// ✅ Protect with mutex
func TestNoRace(t *testing.T) {
var mu sync.Mutex
count := 0
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
count++
mu.Unlock()
}()
}
wg.Wait()
assert.Equal(t, 100, count)
}
Key Summary
| Strategy | Principle |
|---|---|
| Test Pyramid | Unit > Integration > E2E (maintain ratio) |
| Isolation | Each test independent state — use t.Cleanup |
| Coverage | 80% target, prioritize meaningful cases |
| Race Detection | Always use -race in CI |
| Speed | Separate slow tests with -short |
| Parallelization | Accelerate independent tests with t.Parallel() |
Test Quality Checklist:
- Unit/Integration/E2E layers separated
-
t.Cleanupautomates resource cleanup -
-raceflag verifies race conditions - Coverage 80%+ maintained
- Golden files validate complex output
- CI runs
go vet+-racetogether