| name | test |
| description | Guide for writing effective tests using best practices: test pyramid, Arrange-Act-Assert pattern, mocking strategies, and coverage guidelines. Use when the user asks to write tests, improve test coverage, refactor tests, or understand testing best practices. Do NOT use when the user wants to run existing tests without modification, or when the focus is on test infrastructure setup (CI/CD, test frameworks installation).
|
Testing Best Practices
Write reliable, maintainable tests that give confidence in code correctness.
When to Use
- Writing new tests for features or bug fixes
- Improving test coverage in under-tested areas
- Refactoring flaky or brittle tests
- Reviewing test quality during code review
- Adding regression tests for discovered bugs
When NOT to Use
- Running tests without modifications
- Setting up test infrastructure (CI/CD, framework installation)
- Performance/load testing (use dedicated load testing tools)
Test Pyramid
Structure your test suite using the test pyramid model:
/\
/ \ Unit Tests (Fast, many)
/ \
/------\
/ \ Integration Tests (Medium, fewer)
/----------\
/ \ E2E Tests (Slow, few)
/--------------\
Unit Tests (Base Layer)
- Purpose: Test individual functions/methods in isolation
- Speed: Milliseconds
- Quantity: Most tests should be unit tests
- Isolation: No external dependencies (network, database, filesystem)
func TestCalculateTotal(t *testing.T) {
items := []Item{
{Price: 10.00, Qty: 2},
{Price: 5.00, Qty: 1},
}
got := CalculateTotal(items)
want := 25.00
if got != want {
t.Errorf("CalculateTotal() = %v, want %v", got, want)
}
}
Integration Tests (Middle Layer)
- Purpose: Test component interactions and external integrations
- Speed: Seconds
- Quantity: Moderate
- Isolation: May use test databases, mock external APIs
func TestUserRepository_Create(t *testing.T) {
db := setupTestDB(t)
defer cleanupTestDB(t, db)
repo := NewUserRepository(db)
user := &User{Name: "Alice", Email: "alice@example.com"}
err := repo.Create(user)
if err != nil {
t.Fatalf("Create() error = %v", err)
}
if user.ID == 0 {
t.Error("expected user ID to be set")
}
}
End-to-End Tests (Top Layer)
- Purpose: Test complete user workflows through the full stack
- Speed: Seconds to minutes
- Quantity: Few, focused on critical paths
- Isolation: Full environment required
func TestLoginEndpoint(t *testing.T) {
server := startTestServer(t)
defer server.Close()
resp, err := http.Post(
server.URL+"/api/login",
"application/json",
strings.NewReader(`{"email":"alice@example.com","password":"secret"}`),
)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusOK)
}
}
Arrange-Act-Assert Pattern
Structure every test with three clear phases:
func TestDiscountCalculation(t *testing.T) {
product := &Product{Price: 100.00}
discount := 0.20
result := CalculateDiscount(product.Price, discount)
want := 80.00
if result != want {
t.Errorf("CalculateDiscount() = %v, want %v", result, want)
}
}
Why AAA?
- Readability: Clear separation of concerns
- Maintainability: Easy to identify which phase breaks
- Documentation: Test intent is obvious
AAA Variants
func TestLoginValidation(t *testing.T) {
credentials := Credentials{Email: "alice@example.com", Password: "valid123"}
result, err := AuthService.Login(credentials)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if result.Token == "" {
t.Error("expected token to be set")
}
}
func TestFileProcessing(t *testing.T) {
tmpFile := createTempFile(t, "test data")
defer os.Remove(tmpFile)
result, err := ProcessFile(tmpFile)
if err != nil {
t.Fatalf("ProcessFile() error = %v", err)
}
if result.Processed != true {
t.Error("expected file to be marked as processed")
}
}
Mocking and Stubbing
When to Mock
- External services (APIs, databases, file systems)
- Time-dependent code
- Random/non-deterministic values
- Slow operations
Interface-Based Mocking (Go)
Define interfaces for dependencies:
type UserStore interface {
GetByID(id int64) (*User, error)
Save(user *User) error
}
type MockUserStore struct {
Users map[int64]*User
SaveFn func(user *User) error
}
func (m *MockUserStore) GetByID(id int64) (*User, error) {
user, ok := m.Users[id]
if !ok {
return nil, ErrNotFound
}
return user, nil
}
func (m *MockUserStore) Save(user *User) error {
if m.SaveFn != nil {
return m.SaveFn(user)
}
m.Users[user.ID] = user
return nil
}
Using Mocks in Tests
func TestGetUserProfile(t *testing.T) {
mockStore := &MockUserStore{
Users: map[int64]*User{
1: {ID: 1, Name: "Alice", Email: "alice@example.com"},
},
}
service := NewUserService(mockStore)
profile, err := service.GetProfile(1)
if err != nil {
t.Fatalf("GetProfile() error = %v", err)
}
if profile.Name != "Alice" {
t.Errorf("Name = %q, want %q", profile.Name, "Alice")
}
}
func TestSaveUser_RejectsInvalidEmail(t *testing.T) {
mockStore := &MockUserStore{
Users: make(map[int64]*User),
}
service := NewUserService(mockStore)
user := &User{Name: "Bob", Email: "invalid"}
err := service.Save(user)
if err == nil {
t.Error("expected error for invalid email, got nil")
}
}
Table-Driven Tests
For testing multiple cases efficiently:
func TestValidateEmail(t *testing.T) {
tests := []struct {
name string
email string
wantErr bool
}{
{name: "valid email", email: "alice@example.com", wantErr: false},
{name: "missing @", email: "aliceexample.com", wantErr: true},
{name: "missing domain", email: "alice@", wantErr: true},
{name: "empty string", email: "", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateEmail(tt.email)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateEmail(%q) error = %v, wantErr %v",
tt.email, err, tt.wantErr)
}
})
}
}
Coverage Guidelines
Coverage Targets
| Component Type | Minimum Coverage | Rationale |
|---|
| Business logic | 80-90% | Core correctness |
| Utilities/helpers | 90%+ | Reused everywhere |
| API handlers | 70-80% | HTTP concerns |
| UI components | 50-70% | Harder to test, visual |
| Configuration | 30-50% | Often trivial |
What to Test
Always test:
- Happy paths (expected inputs)
- Error cases (invalid inputs, edge cases)
- Boundary conditions (empty, nil, max values)
- State transitions
Don't obsess over:
- Getters/setters
- Framework boilerplate
- Third-party library internals
Coverage in Go
go test -coverprofile=coverage.out ./...
go tool cover -func=coverage.out
go tool cover -html=coverage.out -o coverage.html
Reading Coverage Output
ok github.com/example/pkg 0.5s coverage: 85.2% of statements
Focus on increasing coverage for files with lowest percentages:
go tool cover -func=coverage.out | sort -k3 -n
Test Organization
File Structure
src/
├── user.go # Production code
├── user_test.go # Unit tests (same package)
├── user_integration_test.go # Integration tests
└── testdata/
└── fixtures/ # Test data files
Naming Conventions
func TestCalculateDiscount(t *testing.T) { ... }
func TestCalculateDiscount_ZeroDiscount(t *testing.T) { ... }
func TestCalculateDiscount_NegativeAmount(t *testing.T) { ... }
func BenchmarkCalculateDiscount(b *testing.B) { ... }
func ExampleCalculateDiscount() { ... }
Edge Cases to Cover
Nil/Empty Inputs
func TestProcessItems_EmptySlice(t *testing.T) {
result := ProcessItems([]Item{})
if len(result) != 0 {
t.Error("expected empty result for empty input")
}
}
func TestProcessItems_NilSlice(t *testing.T) {
result := ProcessItems(nil)
if result != nil {
t.Error("expected nil result for nil input")
}
}
Boundary Values
func TestAgeValidation(t *testing.T) {
tests := []struct {
age int
wantErr bool
}{
{age: 0, wantErr: true},
{age: 1, wantErr: false},
{age: 17, wantErr: false},
{age: 18, wantErr: false},
{age: 150, wantErr: false},
{age: 151, wantErr: true},
}
}
Concurrent Access
func TestConcurrentAccess(t *testing.T) {
cache := NewCache()
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
cache.Set(fmt.Sprintf("key%d", n), n)
_ = cache.Get(fmt.Sprintf("key%d", n))
}(i)
}
wg.Wait()
}
Common Anti-Patterns
Avoid
- Tests that depend on execution order — each test should be independent
- Mocking everything — prefer real implementations when practical
- Testing implementation details — test behavior, not internals
- Sleep-based synchronization — use channels or callbacks
- Ignored errors — always check error returns
Good vs Bad Test
func TestUserRepository_InternalCache(t *testing.T) {
repo := NewUserRepository(db)
repo.cache = map[int64]*User{}
user := repo.GetUser(1)
if _, exists := repo.cache[1]; !exists {
t.Error("expected cache to be populated")
}
}
func TestUserRepository_GetUser_CachesResult(t *testing.T) {
db := &MockDB{QueryCount: 0}
repo := NewUserRepository(db)
_, _ = repo.GetUser(1)
if db.QueryCount != 1 {
t.Errorf("expected 1 query, got %d", db.QueryCount)
}
_, _ = repo.GetUser(1)
if db.QueryCount != 1 {
t.Errorf("expected still 1 query, got %d", db.QueryCount)
}
}
Quick Reference
Test Command
go test ./...
go test -v ./...
go test -run TestFunctionName
go test -count=3 ./...
go test -race ./...
Test Flags
go test -cover
go test -coverprofile=out.out
go test -bench=.
go test -short
go test -timeout 30s
Best Practices
- One assertion per concept — each test should verify one behavior
- Descriptive names — test names should explain what's being tested
- Independent tests — no test should depend on another test's state
- Fast feedback — keep unit tests under 100ms each
- Test behavior, not implementation — verify outcomes, not methods called
- Keep tests readable — a test is documentation
- Refactor tests — extract helpers for repeated setup
- Delete dead tests — if code is removed, remove its tests too