🧪 Testing✍️ Khoa📅 19/04/2026☕ 7 phút đọc

Testing: Pyramid và Strategy

1. Testing Pyramid — Tại sao hình tam giác?

Pyramid không phải về số lượng — mà về cost vs confidence trade-off.

                        ┌─────────────┐
                       /   E2E Tests   \
                      /  (5-10 tests)   \
                     /   Slow | Fragile  \
                    /────────────────────\
                   /   Integration Tests  \
                  /     (50-100 tests)     \
                 /   Medium Speed | Real   \
                /────────────────────────────\
               /         Unit Tests           \
              /         (500+ tests)           \
             /     Fast | Isolated | Cheap      \
            /──────────────────────────────────\

Unit test: test một function/method, không I/O, không DB, không HTTP. Chạy trong microseconds.

Integration test: test một component với dependency thực (DB, Redis, HTTP server). Chạy giây.

E2E test: test toàn bộ system như user thực. Chạy phút.

💡 Interview: "70% unit, 20% integration, 10% E2E là tỷ lệ cân bằng tốt cho hầu hết services. Nếu bạn có 90% E2E tests, team bạn đang gặp vấn đề về speed và stability."


2. Unit Test trong Go — Table-driven pattern

2.1 Cấu trúc cơ bản

// user/service.go
package user

type Service struct {
    repo Repository
}

func (s *Service) ValidateAge(age int) error {
    if age < 0 {
        return errors.New("age cannot be negative")
    }
    if age > 150 {
        return errors.New("age is unrealistically high")
    }
    return nil
}
// user/service_test.go
package user_test

import (
    "testing"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

func TestService_ValidateAge(t *testing.T) {
    svc := &Service{}

    tests := []struct {
        name    string
        age     int
        wantErr bool
        errMsg  string
    }{
        {
            name:    "valid age",
            age:     25,
            wantErr: false,
        },
        {
            name:    "zero age is valid",
            age:     0,
            wantErr: false,
        },
        {
            name:    "negative age",
            age:     -1,
            wantErr: true,
            errMsg:  "age cannot be negative",
        },
        {
            name:    "unrealistic age",
            age:     200,
            wantErr: true,
            errMsg:  "age is unrealistically high",
        },
        {
            name:    "boundary: max valid",
            age:     150,
            wantErr: false,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            err := svc.ValidateAge(tt.age)
            if tt.wantErr {
                require.Error(t, err)
                assert.Contains(t, err.Error(), tt.errMsg)
            } else {
                require.NoError(t, err)
            }
        })
    }
}

Table-driven test là idiomatic Go. Lợi ích: thêm case mới chỉ cần thêm một struct literal, không cần duplicate code.

2.2 Subtests và parallel execution

func TestPaymentProcessor(t *testing.T) {
    // Setup chung cho tất cả subtests
    processor := NewPaymentProcessor(testConfig)

    t.Run("credit card", func(t *testing.T) {
        t.Parallel() // subtest này chạy song song với các subtest khác

        result, err := processor.Charge(context.Background(), Payment{
            Method: "credit_card",
            Amount: 10000,
        })
        require.NoError(t, err)
        assert.Equal(t, "success", result.Status)
    })

    t.Run("insufficient funds", func(t *testing.T) {
        t.Parallel()

        _, err := processor.Charge(context.Background(), Payment{
            Method: "credit_card",
            Amount: 999999999,
        })
        require.Error(t, err)
        assert.ErrorIs(t, err, ErrInsufficientFunds)
    })
}

Lưu ý khi dùng t.Parallel(): biến trong loop phải được capture đúng cách.

for _, tt := range tests {
    tt := tt // capture loop variable — bắt buộc trước Go 1.22
    t.Run(tt.name, func(t *testing.T) {
        t.Parallel()
        // dùng tt ở đây
    })
}

3. Coverage — 70% là đủ, 100% là sai lầm

go test -coverprofile=coverage.out ./...
go tool cover -func=coverage.out  # per-function breakdown
go tool cover -html=coverage.out  # visual HTML report

Tại sao 100% coverage không phải mục tiêu?

// Code này có 100% coverage nhưng test vô nghĩa:
func Add(a, b int) int { return a + b }

func TestAdd(t *testing.T) {
    _ = Add(1, 2) // cover line, assert nothing
}

Coverage là tool, không phải mục tiêu. Câu hỏi đúng không phải "bao nhiêu % coverage?" mà là "những path quan trọng có được test không?"

Ưu tiên coverage cho:
  ✅ Business logic phức tạp (pricing, discount, permission logic)
  ✅ Error handling paths
  ✅ Boundary conditions
  ✅ Security-sensitive code

Không cần coverage cao cho:
  ❌ Getter/setter đơn giản
  ❌ Generated code (protobuf, ORM models)
  ❌ main() function
  ❌ Logging/monitoring boilerplate

💡 Interview: "Nếu coverage drop từ 80% xuống 75% do một feature mới, đó không nhất thiết là vấn đề. Nếu coverage drop vì đường error handling không được test — đó mới là vấn đề."


4. TDD — Khi nào viết test trước?

TDD (Test-Driven Development): viết test trước, code fail, viết code cho pass, refactor.

Red → Green → Refactor

  1. Viết test (test fail — RED vì chưa có code)
  2. Viết code tối thiểu để test pass (GREEN)
  3. Refactor code, test vẫn phải pass

TDD hữu ích khi:

✅ Business logic phức tạp — test làm rõ requirement trước
✅ Bug fix — viết test reproduce bug trước, rồi fix
✅ Refactoring — test là safety net
✅ API design — viết test xác định interface trước khi implement

TDD không phải silver bullet:

❌ Exploratory code — khi bạn chưa biết shape của solution
❌ UI/UX prototyping — feedback loop từ visual quan trọng hơn
❌ Performance optimization — cần profiling, không phải unit test

Ví dụ TDD cho một rate limiter:

// BƯỚC 1: Viết test trước (code chưa tồn tại)
func TestRateLimiter_Allow(t *testing.T) {
    // 5 requests/second limit
    rl := NewRateLimiter(5, time.Second)

    // First 5 should be allowed
    for i := 0; i < 5; i++ {
        assert.True(t, rl.Allow("user:123"), "request %d should be allowed", i+1)
    }

    // 6th should be denied
    assert.False(t, rl.Allow("user:123"), "6th request should be denied")
}

// BƯỚC 2: Implement tối thiểu để pass
type RateLimiter struct {
    limit    int
    window   time.Duration
    counters map[string]*counter
    mu       sync.Mutex
}

func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
    return &RateLimiter{
        limit:    limit,
        window:   window,
        counters: make(map[string]*counter),
    }
}

func (rl *RateLimiter) Allow(key string) bool {
    rl.mu.Lock()
    defer rl.mu.Unlock()

    c, ok := rl.counters[key]
    if !ok || time.Since(c.resetAt) > rl.window {
        rl.counters[key] = &counter{count: 1, resetAt: time.Now()}
        return true
    }
    c.count++
    return c.count <= rl.limit
}

5. Integration Test — Biết ranh giới

Integration test là test code của bạn với dependency thật. Ý nghĩa: không mock DB, không mock HTTP server — chạy với instance thật.

// Đánh dấu integration test để có thể skip khi cần
func TestUserRepository_Create(t *testing.T) {
    if testing.Short() {
        t.Skip("skipping integration test in short mode")
    }

    db := setupTestDB(t) // real PostgreSQL
    repo := NewUserRepository(db)

    user := &User{Name: "Alice", Email: "alice@example.com"}
    err := repo.Create(context.Background(), user)
    require.NoError(t, err)
    assert.NotZero(t, user.ID)

    // Verify persisted
    fetched, err := repo.FindByID(context.Background(), user.ID)
    require.NoError(t, err)
    assert.Equal(t, "Alice", fetched.Name)
}

Chạy integration tests riêng:

go test ./...           # skip integration tests (default)
go test -short ./...    # explicitly skip
go test -run Integration ./...  # chỉ chạy integration tests
# hoặc dùng build tag:
go test -tags=integration ./...

6. E2E Test — Dùng httptest

Với HTTP services, Go có net/http/httptest để spin up server thật mà không cần port thật.

func TestCreateUserEndpoint(t *testing.T) {
    // Spin up real HTTP server
    router := setupRouter(testDB)
    srv := httptest.NewServer(router)
    defer srv.Close()

    // Real HTTP request
    body := `{"name": "Alice", "email": "alice@example.com"}`
    resp, err := http.Post(
        srv.URL+"/api/v1/users",
        "application/json",
        strings.NewReader(body),
    )
    require.NoError(t, err)
    defer resp.Body.Close()

    assert.Equal(t, http.StatusCreated, resp.StatusCode)

    var created User
    require.NoError(t, json.NewDecoder(resp.Body).Decode(&created))
    assert.NotZero(t, created.ID)
    assert.Equal(t, "Alice", created.Name)
}

7. Khi nào nên dùng loại test nào?

Câu hỏi để quyết định:

"Đây có phải pure business logic không?"
  → Có → Unit test

"Logic này phụ thuộc vào SQL query / Redis command?"
  → Có → Integration test với DB thật

"Đây là happy path của toàn bộ user flow?"
  → Có → E2E test (nhưng giữ số lượng ít)

"Đây là contract giữa 2 services?"
  → Contract test (xem file integration-and-contract.md)

"Đây là performance-sensitive?"
  → Benchmark test

💡 Interview: "Test trophy (nhiều integration hơn unit) có thể phù hợp hơn test pyramid với một số teams — đặc biệt khi business logic phần lớn nằm trong DB queries và API contracts quan trọng hơn internal logic."