Testing Strategies
Unit tests, integration tests, test fixtures, mocking, and testcontainers — testing that catches real bugs, not just checkbox coverage.
testingintegration testsmockingtestcontainersfixturesgolden files
The Testing Pyramid in Go
/ E2E \ Few — slow, brittle, but validates everything
/----------\
/ Integration \ Some — tests with real DB, Redis, APIs
/----------------\
/ Unit Tests \ Many — fast, isolated, test logic
/______________________\ Real-World Analogy
Unit tests = checking each ingredient tastes right. Integration tests = cooking a dish and tasting it. E2E tests = having a customer order, eat, and pay — testing the full restaurant experience. You need all three, but most of your tests should be unit tests (fast and cheap).
Unit Testing with Interfaces
The key to testable Go code: depend on interfaces, not implementations.
// Define what you need
type UserRepository interface {
GetByID(ctx context.Context, id int) (*User, error)
Create(ctx context.Context, user *User) error
}
type EmailSender interface {
Send(ctx context.Context, to, subject, body string) error
}
// Service depends on interfaces
type UserService struct {
repo UserRepository
email EmailSender
}
// In tests — use mocks
type mockRepo struct {
users map[int]*User
}
func (m *mockRepo) GetByID(ctx context.Context, id int) (*User, error) {
user, ok := m.users[id]
if !ok {
return nil, ErrNotFound
}
return user, nil
}
func (m *mockRepo) Create(ctx context.Context, user *User) error {
m.users[user.ID] = user
return nil
}
type mockEmailSender struct {
sent []sentEmail
}
type sentEmail struct {
To, Subject, Body string
}
func (m *mockEmailSender) Send(ctx context.Context, to, subject, body string) error {
m.sent = append(m.sent, sentEmail{to, subject, body})
return nil
}
func TestUserService_Register(t *testing.T) {
repo := &mockRepo{users: make(map[int]*User)}
emailer := &mockEmailSender{}
service := NewUserService(repo, emailer)
user, err := service.Register(context.Background(), RegisterInput{
Email: "alice@example.com",
Name: "Alice",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if user.Email != "alice@example.com" {
t.Errorf("email = %q, want %q", user.Email, "alice@example.com")
}
// Verify welcome email was sent
if len(emailer.sent) != 1 {
t.Fatalf("expected 1 email sent, got %d", len(emailer.sent))
}
if emailer.sent[0].To != "alice@example.com" {
t.Errorf("email to = %q, want %q", emailer.sent[0].To, "alice@example.com")
}
} Integration Tests with Testcontainers
Test against real databases, not mocks:
import (
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/postgres"
)
func setupTestDB(t *testing.T) *sql.DB {
t.Helper()
ctx := context.Background()
pgContainer, err := postgres.Run(ctx,
"postgres:16-alpine",
postgres.WithDatabase("testdb"),
postgres.WithUsername("test"),
postgres.WithPassword("test"),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
WithStartupTimeout(5*time.Second),
),
)
if err != nil {
t.Fatalf("starting postgres container: %v", err)
}
t.Cleanup(func() {
pgContainer.Terminate(ctx)
})
connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
if err != nil {
t.Fatalf("getting connection string: %v", err)
}
db, err := sql.Open("postgres", connStr)
if err != nil {
t.Fatalf("connecting to database: %v", err)
}
// Run migrations
runMigrations(db)
return db
}
func TestUserRepository_Integration(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
db := setupTestDB(t)
repo := NewUserRepository(db)
ctx := context.Background()
// Test Create
user := &User{Email: "test@example.com", Name: "Test User"}
err := repo.Create(ctx, user)
if err != nil {
t.Fatalf("create user: %v", err)
}
if user.ID == 0 {
t.Error("expected user ID to be set")
}
// Test GetByID
found, err := repo.GetByID(ctx, user.ID)
if err != nil {
t.Fatalf("get user: %v", err)
}
if found.Email != "test@example.com" {
t.Errorf("email = %q, want %q", found.Email, "test@example.com")
}
} # Run only unit tests (fast)
go test -short ./...
# Run all tests including integration
go test ./... HTTP Handler Testing
func TestBookHandler_Create(t *testing.T) {
tests := []struct {
name string
body string
wantStatus int
wantError string
}{
{
name: "valid book",
body: `{"title":"Go in Action","author":"William Kennedy","price":29.99}`,
wantStatus: http.StatusCreated,
},
{
name: "missing title",
body: `{"author":"William Kennedy","price":29.99}`,
wantStatus: http.StatusUnprocessableEntity,
wantError: "title is required",
},
{
name: "invalid JSON",
body: `{invalid`,
wantStatus: http.StatusBadRequest,
wantError: "invalid JSON",
},
{
name: "negative price",
body: `{"title":"Go","author":"Author","price":-5}`,
wantStatus: http.StatusUnprocessableEntity,
wantError: "price must be non-negative",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Setup
service := newMockBookService()
handler := NewBookHandler(service)
req := httptest.NewRequest("POST", "/api/books", strings.NewReader(tt.body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
// Execute
handler.Create(rec, req)
// Assert status
if rec.Code != tt.wantStatus {
t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus)
}
// Assert error message if expected
if tt.wantError != "" {
body := rec.Body.String()
if !strings.Contains(body, tt.wantError) {
t.Errorf("body = %s, want to contain %q", body, tt.wantError)
}
}
})
}
} Golden File Testing
Compare output against saved “golden” files — great for complex output:
var update = flag.Bool("update", false, "update golden files")
func TestGenerateReport(t *testing.T) {
report := generateReport(testData)
output, _ := json.MarshalIndent(report, "", " ")
goldenFile := filepath.Join("testdata", t.Name()+".golden")
if *update {
os.MkdirAll("testdata", 0755)
os.WriteFile(goldenFile, output, 0644)
return
}
expected, err := os.ReadFile(goldenFile)
if err != nil {
t.Fatalf("reading golden file: %v (run with -update to create)", err)
}
if !bytes.Equal(output, expected) {
t.Errorf("output doesn't match golden file.\nGot:\n%s\nWant:\n%s", output, expected)
}
} # Generate/update golden files
go test -run TestGenerateReport -update ./...
# Verify against golden files
go test -run TestGenerateReport ./... Test Fixtures
Reusable test data:
// internal/testutil/fixtures.go
package testutil
func NewTestUser(overrides ...func(*User)) *User {
user := &User{
ID: 1,
Email: "test@example.com",
Name: "Test User",
Role: "user",
CreatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
}
for _, fn := range overrides {
fn(user)
}
return user
}
// Usage in tests
user := testutil.NewTestUser()
admin := testutil.NewTestUser(func(u *User) {
u.Role = "admin"
u.Email = "admin@example.com"
}) Testing Concurrent Code
func TestSafeCache_Concurrent(t *testing.T) {
cache := NewSafeCache()
var wg sync.WaitGroup
// 100 goroutines writing
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
cache.Set(fmt.Sprintf("key-%d", i), fmt.Sprintf("val-%d", i))
}()
}
// 100 goroutines reading
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
cache.Get(fmt.Sprintf("key-%d", i))
}()
}
wg.Wait()
// If we get here without panic or race detector warning, the cache is safe
} # Always run with race detector
go test -race ./... Test Organization
internal/
├── service/
│ ├── user.go
│ ├── user_test.go # Unit tests (same package)
│ └── user_integration_test.go # Integration tests
├── handler/
│ ├── user.go
│ └── user_test.go
└── testutil/ # Shared test helpers
├── fixtures.go
├── db.go # setupTestDB()
└── assert.go # Custom assertions Key Takeaways
- Depend on interfaces — mock implementations are trivial to write
- Table-driven tests for comprehensive coverage with minimal code
- Testcontainers for integration tests against real databases — no mocking the DB
testing.Short()to skip slow integration tests in fast feedback loops- Golden files for complex output comparison — update with
-updateflag - Always run
-race— data races are bugs, not warnings - Test fixtures with functional options —
NewTestUser(func(u *User) { u.Role = "admin" })