Skip to content

🧪 Testing & Benchmarks

"Untested code is broken code."
Go's testing toolchain là một trong những điểm mạnh nhất của ngôn ngữ.

📋 Testing Fundamentals

Test File Convention

mypackage/
├── user.go           # Implementation
├── user_test.go      # Unit tests
├── user_bench_test.go # Benchmarks (optional separate file)
└── testdata/         # Test fixtures
    └── users.json

Basic Test Structure

go
// user_test.go
package user

import "testing"

func TestAdd(t *testing.T) {
    result := Add(2, 3)
    if result != 5 {
        t.Errorf("Add(2, 3) = %d; want 5", result)
    }
}

// Parallel test
func TestAddParallel(t *testing.T) {
    t.Parallel()  // Mark as parallel-safe
    
    result := Add(2, 3)
    if result != 5 {
        t.Errorf("Add(2, 3) = %d; want 5", result)
    }
}

📊 Table-Driven Tests (The Go Way)

Standard Pattern

go
func TestCalculate(t *testing.T) {
    tests := []struct {
        name     string
        input    Input
        expected Output
        wantErr  bool
    }{
        {
            name:     "positive numbers",
            input:    Input{A: 5, B: 3},
            expected: Output{Sum: 8, Product: 15},
            wantErr:  false,
        },
        {
            name:     "zero values",
            input:    Input{A: 0, B: 0},
            expected: Output{Sum: 0, Product: 0},
            wantErr:  false,
        },
        {
            name:     "overflow",
            input:    Input{A: math.MaxInt64, B: 1},
            expected: Output{},
            wantErr:  true,
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got, err := Calculate(tt.input)
            
            if (err != nil) != tt.wantErr {
                t.Errorf("error = %v, wantErr %v", err, tt.wantErr)
                return
            }
            
            if !tt.wantErr && got != tt.expected {
                t.Errorf("got %v, want %v", got, tt.expected)
            }
        })
    }
}

Parallel Table Tests

go
func TestUserService_Create(t *testing.T) {
    tests := []struct {
        name    string
        user    *User
        wantErr bool
    }{
        {"valid user", &User{Name: "Alice", Email: "alice@example.com"}, false},
        {"empty name", &User{Name: "", Email: "bob@example.com"}, true},
        {"invalid email", &User{Name: "Charlie", Email: "invalid"}, true},
    }
    
    for _, tt := range tests {
        tt := tt  // Capture range variable (pre-Go 1.22)
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()  // Run subtests in parallel
            
            svc := NewUserService(NewMockRepo())
            err := svc.Create(context.Background(), tt.user)
            
            if (err != nil) != tt.wantErr {
                t.Errorf("error = %v, wantErr %v", err, tt.wantErr)
            }
        })
    }
}

🎭 Mocking Strategies

⚔️ Tradeoff: Mocking Approaches

ApproachProsConsWhen to Use
Hand-rolledSimple, explicitManual effortSmall interfaces
gomockGenerated, type-safeLearning curveLarge interfaces
mockeryAuto-generatesDependencytestify users
testify/mockFlexible assertionsRuntime type checkingQuick prototyping
go
// Interface in service package
type UserRepository interface {
    GetByID(ctx context.Context, id int64) (*User, error)
    Create(ctx context.Context, user *User) error
}

// Mock in test file
type mockUserRepo struct {
    getUserFn func(ctx context.Context, id int64) (*User, error)
    createFn  func(ctx context.Context, user *User) error
}

func (m *mockUserRepo) GetByID(ctx context.Context, id int64) (*User, error) {
    return m.getUserFn(ctx, id)
}

func (m *mockUserRepo) Create(ctx context.Context, user *User) error {
    return m.createFn(ctx, user)
}

// Usage in test
func TestUserService_GetUser(t *testing.T) {
    mock := &mockUserRepo{
        getUserFn: func(ctx context.Context, id int64) (*User, error) {
            if id == 1 {
                return &User{ID: 1, Name: "Alice"}, nil
            }
            return nil, ErrNotFound
        },
    }
    
    svc := NewUserService(mock)
    
    user, err := svc.GetUser(context.Background(), 1)
    if err != nil {
        t.Fatal(err)
    }
    
    if user.Name != "Alice" {
        t.Errorf("got %s, want Alice", user.Name)
    }
}

gomock Generated Mocks

go
//go:generate mockgen -destination=mock_repo_test.go -package=service . UserRepository

func TestWithGoMock(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()
    
    repo := NewMockUserRepository(ctrl)
    
    // Set expectations
    repo.EXPECT().
        GetByID(gomock.Any(), int64(1)).
        Return(&User{ID: 1, Name: "Alice"}, nil)
    
    svc := NewUserService(repo)
    user, _ := svc.GetUser(context.Background(), 1)
    
    if user.Name != "Alice" {
        t.Error("unexpected name")
    }
}

🐳 Integration Testing with Testcontainers

go
// user_integration_test.go
//go:build integration

package repository_test

import (
    "context"
    "testing"
    
    "github.com/testcontainers/testcontainers-go"
    "github.com/testcontainers/testcontainers-go/wait"
)

func TestUserRepository_Integration(t *testing.T) {
    if testing.Short() {
        t.Skip("skipping integration test")
    }
    
    ctx := context.Background()
    
    // Start PostgreSQL container
    pgContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
        ContainerRequest: testcontainers.ContainerRequest{
            Image:        "postgres:15-alpine",
            ExposedPorts: []string{"5432/tcp"},
            Env: map[string]string{
                "POSTGRES_USER":     "test",
                "POSTGRES_PASSWORD": "test",
                "POSTGRES_DB":       "testdb",
            },
            WaitingFor: wait.ForListeningPort("5432/tcp"),
        },
        Started: true,
    })
    if err != nil {
        t.Fatal(err)
    }
    defer pgContainer.Terminate(ctx)
    
    // Get connection string
    host, _ := pgContainer.Host(ctx)
    port, _ := pgContainer.MappedPort(ctx, "5432")
    
    dsn := fmt.Sprintf("postgres://test:test@%s:%s/testdb?sslmode=disable", host, port.Port())
    
    // Connect and run migrations
    db, err := pgx.Connect(ctx, dsn)
    if err != nil {
        t.Fatal(err)
    }
    defer db.Close(ctx)
    
    // Run your tests
    repo := NewUserRepository(db)
    
    t.Run("create and get user", func(t *testing.T) {
        user := &User{Name: "Test", Email: "test@example.com"}
        
        if err := repo.Create(ctx, user); err != nil {
            t.Fatal(err)
        }
        
        got, err := repo.GetByID(ctx, user.ID)
        if err != nil {
            t.Fatal(err)
        }
        
        if got.Name != user.Name {
            t.Errorf("got %s, want %s", got.Name, user.Name)
        }
    })
}
)}

📁 Golden File Testing

go
func TestMarshalUser_Golden(t *testing.T) {
    user := &User{
        ID:    1,
        Name:  "Alice",
        Email: "alice@example.com",
    }
    
    got, err := json.MarshalIndent(user, "", "  ")
    if err != nil {
        t.Fatal(err)
    }
    
    goldenFile := "testdata/user.golden"
    
    if *update {  // -update flag
        os.WriteFile(goldenFile, got, 0644)
        return
    }
    
    want, err := os.ReadFile(goldenFile)
    if err != nil {
        t.Fatal(err)
    }
    
    if !bytes.Equal(got, want) {
        t.Errorf("mismatch:\ngot:\n%s\nwant:\n%s", got, want)
    }
}

var update = flag.Bool("update", false, "update golden files")

🎲 Fuzzing (Go 1.18+)

go
func FuzzParseJSON(f *testing.F) {
    // Seed corpus
    f.Add([]byte(`{"name": "Alice"}`))
    f.Add([]byte(`{"name": ""}`))
    f.Add([]byte(`{}`))
    
    f.Fuzz(func(t *testing.T, data []byte) {
        var user User
        err := json.Unmarshal(data, &user)
        
        if err != nil {
            return  // Invalid JSON is expected
        }
        
        // Re-marshal should not panic
        _, err = json.Marshal(&user)
        if err != nil {
            t.Errorf("marshal failed after successful unmarshal: %v", err)
        }
    })
}

// Run: go test -fuzz=FuzzParseJSON -fuzztime=30s

Benchmarking

Basic Benchmark

go
func BenchmarkStringConcat(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = "Hello, " + "World!"
    }
}

// Run: go test -bench=. -benchmem
// Output:
// BenchmarkStringConcat-8   1000000000   0.25 ns/op   0 B/op   0 allocs/op

Comparing Implementations

go
func BenchmarkConcat_Plus(b *testing.B) {
    strs := []string{"hello", "world", "foo", "bar"}
    b.ResetTimer()
    
    for i := 0; i < b.N; i++ {
        result := ""
        for _, s := range strs {
            result += s
        }
        _ = result
    }
}

func BenchmarkConcat_Builder(b *testing.B) {
    strs := []string{"hello", "world", "foo", "bar"}
    b.ResetTimer()
    
    for i := 0; i < b.N; i++ {
        var sb strings.Builder
        for _, s := range strs {
            sb.WriteString(s)
        }
        _ = sb.String()
    }
}

func BenchmarkConcat_Join(b *testing.B) {
    strs := []string{"hello", "world", "foo", "bar"}
    b.ResetTimer()
    
    for i := 0; i < b.N; i++ {
        _ = strings.Join(strs, "")
    }
}

/*
Results:
BenchmarkConcat_Plus-8      5000000   380 ns/op   80 B/op   4 allocs/op
BenchmarkConcat_Builder-8  20000000    62 ns/op   64 B/op   1 allocs/op
BenchmarkConcat_Join-8     30000000    55 ns/op   32 B/op   1 allocs/op
*/

Sub-benchmarks with Parameters

go
func BenchmarkSort(b *testing.B) {
    sizes := []int{100, 1000, 10000, 100000}
    
    for _, size := range sizes {
        b.Run(fmt.Sprintf("size-%d", size), func(b *testing.B) {
            data := make([]int, size)
            for i := range data {
                data[i] = rand.Intn(size)
            }
            
            b.ResetTimer()
            for i := 0; i < b.N; i++ {
                b.StopTimer()
                clone := make([]int, len(data))
                copy(clone, data)
                b.StartTimer()
                
                sort.Ints(clone)
            }
        })
    }
}

💻 Engineering Example: CI-Ready Test Suite

Test Helper Package

go
// internal/testutil/testutil.go
package testutil

import (
    "context"
    "testing"
    "time"
)

// TestContext returns a context with deadline for tests
func TestContext(t *testing.T) context.Context {
    t.Helper()
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    t.Cleanup(cancel)
    return ctx
}

// AssertError checks for expected error
func AssertError(t *testing.T, got, want error) {
    t.Helper()
    if !errors.Is(got, want) {
        t.Errorf("error = %v, want %v", got, want)
    }
}

// AssertEqual uses reflect.DeepEqual
func AssertEqual[T any](t *testing.T, got, want T) {
    t.Helper()
    if !reflect.DeepEqual(got, want) {
        t.Errorf("got %v, want %v", got, want)
    }
}

Makefile Test Targets

makefile
.PHONY: test test-unit test-integration test-coverage test-race

# All tests
test: test-unit test-integration

# Unit tests only
test-unit:
	go test -v -short ./...

# Integration tests (requires Docker)
test-integration:
	go test -v -tags=integration ./...

# Coverage report
test-coverage:
	go test -coverprofile=coverage.out ./...
	go tool cover -html=coverage.out -o coverage.html
	@echo "Coverage report: coverage.html"

# Race detector
test-race:
	go test -race ./...

# Benchmarks
bench:
	go test -bench=. -benchmem ./...

# Fuzz (30 seconds)
fuzz:
	go test -fuzz=. -fuzztime=30s ./...

Ship-to-Prod Checklist

Test Quality

  • [ ] Coverage > 80% cho business logic
  • [ ] Table-driven tests cho edge cases
  • [ ] Parallel tests where safe (t.Parallel())
  • [ ] Integration tests cho data layer

Test Infrastructure

  • [ ] CI runs go test -race ./...
  • [ ] Integration tests với testcontainers
  • [ ] Coverage reported và tracked
  • [ ] Golden files cho snapshot testing

Benchmarks

  • [ ] Benchmark critical paths
  • [ ] Track -benchmem allocations
  • [ ] Compare before/after changes
  • [ ] Run in CI for regression detection

Anti-patterns to Avoid

  • [ ] No time.Sleep() in tests (use channels/waitgroups)
  • [ ] No hardcoded ports (use dynamic allocation)
  • [ ] No dependency on test order
  • [ ] No shared mutable state between tests

📊 Summary

TopicCommand/Pattern
Run testsgo test ./...
Verbosego test -v ./...
Race detectorgo test -race ./...
Coveragego test -coverprofile=c.out ./...
Benchmarksgo test -bench=. -benchmem ./...
Fuzzinggo test -fuzz=Fuzz -fuzztime=30s
Short modego test -short ./...

➡️ Tiếp theo

Testing nắm vững rồi! Tiếp theo: HTTP Services - Building production HTTP APIs.