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."