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)
// Good: Pure unit test, no dependencies
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
// Integration test with database
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
// E2E test hitting real HTTP endpoint
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) {
// ARRANGE: Set up test data and dependencies
product := &Product{Price: 100.00}
discount := 0.20 // 20% off
// ACT: Execute the function under test
result := CalculateDiscount(product.Price, discount)
// ASSERT: Verify the expected outcome
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
// Given-When-Then (BDD style)
func TestLoginValidation(t *testing.T) {
// Given: valid credentials
credentials := Credentials{Email: "alice@example.com", Password: "valid123"}
// When: user attempts login
result, err := AuthService.Login(credentials)
// Then: login succeeds
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if result.Token == "" {
t.Error("expected token to be set")
}
}
// Arrange-Act-Assert-Cleanup (for resources)
func TestFileProcessing(t *testing.T) {
// Arrange
tmpFile := createTempFile(t, "test data")
defer os.Remove(tmpFile)
// Act
result, err := ProcessFile(tmpFile)
// Assert
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:
// Production code depends on interface, not concrete type
type UserStore interface {
GetByID(id int64) (*User, error)
Save(user *User) error
}
// Mock implementation
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) {
// Arrange
mockStore := &MockUserStore{
Users: map[int64]*User{
1: {ID: 1, Name: "Alice", Email: "alice@example.com"},
},
}
service := NewUserService(mockStore)
// Act
profile, err := service.GetProfile(1)
// Assert
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) {
// Arrange
mockStore := &MockUserStore{
Users: make(map[int64]*User),
}
service := NewUserService(mockStore)
user := &User{Name: "Bob", Email: "invalid"}
// Act
err := service.Save(user)
// Assert
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
# Generate coverage report
go test -coverprofile=coverage.out ./...
# View coverage in terminal
go tool cover -func=coverage.out
# Generate HTML report for detailed view
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:
# Show coverage per function
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
// Function under test: CalculateDiscount
// Standard test (success case)
func TestCalculateDiscount(t *testing.T) { ... }
// Failure/edge case
func TestCalculateDiscount_ZeroDiscount(t *testing.T) { ... }
func TestCalculateDiscount_NegativeAmount(t *testing.T) { ... }
// Benchmark
func BenchmarkCalculateDiscount(b *testing.B) { ... }
// Example (documentation)
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}, // Minimum boundary
{age: 1, wantErr: false}, // Just above minimum
{age: 17, wantErr: false}, // Below adult age
{age: 18, wantErr: false}, // Adult threshold
{age: 150, wantErr: false}, // Reasonable maximum
{age: 151, wantErr: true}, // Above reasonable max
}
// ... test loop
}
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()
// Cache should not panic or corrupt data
}
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
// BAD: Tests implementation detail, brittle
func TestUserRepository_InternalCache(t *testing.T) {
repo := NewUserRepository(db)
repo.cache = map[int64]*User{} // Testing internal state
user := repo.GetUser(1)
if _, exists := repo.cache[1]; !exists {
t.Error("expected cache to be populated")
}
}
// GOOD: Tests observable behavior
func TestUserRepository_GetUser_CachesResult(t *testing.T) {
db := &MockDB{QueryCount: 0}
repo := NewUserRepository(db)
// First call hits database
_, _ = repo.GetUser(1)
if db.QueryCount != 1 {
t.Errorf("expected 1 query, got %d", db.QueryCount)
}
// Second call uses cache (no additional query)
_, _ = repo.GetUser(1)
if db.QueryCount != 1 {
t.Errorf("expected still 1 query, got %d", db.QueryCount)
}
}
Quick Reference
Test Command
go test ./... # Run all tests
go test -v ./... # Verbose output
go test -run TestFunctionName # Run specific test
go test -count=3 ./... # Run 3 times (find flaky tests)
go test -race ./... # Detect race conditions
Test Flags
go test -cover # Show coverage percentage
go test -coverprofile=out.out # Save coverage data
go test -bench=. # Run benchmarks
go test -short # Skip long-running tests
go test -timeout 30s # Fail if test exceeds timeout
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