Skip to main content

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, &notFoundErr)
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

StrategyPrinciple
Test PyramidUnit > Integration > E2E (maintain ratio)
IsolationEach test independent state — use t.Cleanup
Coverage80% target, prioritize meaningful cases
Race DetectionAlways use -race in CI
SpeedSeparate slow tests with -short
ParallelizationAccelerate independent tests with t.Parallel()

Test Quality Checklist:

  • Unit/Integration/E2E layers separated
  • t.Cleanup automates resource cleanup
  • -race flag verifies race conditions
  • Coverage 80%+ maintained
  • Golden files validate complex output
  • CI runs go vet + -race together