Testing: Patterns và Anti-patterns
1. Arrange-Act-Assert (AAA)
Pattern cơ bản nhất, nhưng quan trọng nhất. Mọi test tốt đều có 3 giai đoạn rõ ràng.
func TestOrderService_ApplyDiscount(t *testing.T) {
// ARRANGE — chuẩn bị data và dependencies
repo := NewInMemoryOrderRepository()
svc := NewOrderService(repo)
order := &Order{
ID: 1,
SubTotal: 100_000, // 100k VND
UserTier: "gold",
}
// ACT — thực hiện hành động cần test
discounted, err := svc.ApplyDiscount(context.Background(), order)
// ASSERT — kiểm tra kết quả
require.NoError(t, err)
assert.Equal(t, 90_000, discounted.Total) // gold tier = 10% off
assert.Equal(t, "TIER_GOLD_10PCT", discounted.DiscountCode)
}
Khoảng trống giữa 3 giai đoạn là visual separator quan trọng. Đừng xóa dòng trống đó vì muốn code "gọn".
AAA bị vi phạm — ví dụ thực tế
// BAD: AAA trộn lẫn nhau, khó đọc
func TestBadExample(t *testing.T) {
svc := NewService()
result1, _ := svc.Step1("input")
assert.Equal(t, "expected1", result1) // assert giữa chừng
result2, _ := svc.Step2(result1)
assert.Equal(t, "expected2", result2)
result3, _ := svc.Step3(result2) // act tiếp nữa
assert.NotNil(t, result3)
}
// GOOD: tách thành 2 tests riêng
func TestService_Step1(t *testing.T) {
svc := NewService()
result, err := svc.Step1("input")
require.NoError(t, err)
assert.Equal(t, "expected1", result)
}
func TestService_Step2_WithValidInput(t *testing.T) {
svc := NewService()
result, err := svc.Step2("expected1")
require.NoError(t, err)
assert.Equal(t, "expected2", result)
}
2. Test Isolation — mỗi test là một ốc đảo
Test isolation nghĩa là: thứ tự chạy test không ảnh hưởng kết quả. TestA chạy trước hay sau TestB đều cho cùng output.
Database isolation với transactions
// testutil/db.go
// Mỗi test chạy trong transaction riêng, rollback sau khi test xong
func WithTestTransaction(t *testing.T, db *sqlx.DB, fn func(*sqlx.Tx)) {
t.Helper()
tx, err := db.Beginx()
require.NoError(t, err)
t.Cleanup(func() {
tx.Rollback() // luôn rollback, kể cả test pass
})
fn(tx)
}
// Trong test
func TestUserRepository_Create(t *testing.T) {
db := testutil.SetupPostgres(t)
repo := user.NewRepository(db)
testutil.WithTestTransaction(t, db, func(tx *sqlx.Tx) {
repoTx := user.NewRepository(tx) // repository với transaction
u := &user.User{Name: "Alice", Email: "alice@example.com"}
err := repoTx.Create(ctx, u)
require.NoError(t, err)
assert.NotZero(t, u.ID)
// Sau khi test: tx.Rollback() → DB clean cho test tiếp theo
})
}
Seed và cleanup per test
func TestUserService(t *testing.T) {
db := testutil.SetupPostgres(t)
// Setup fixtures riêng cho test này
t.Run("get existing user", func(t *testing.T) {
// Seed data
_, err := db.Exec(`INSERT INTO users (id, name) VALUES (100, 'Alice')`)
require.NoError(t, err)
t.Cleanup(func() {
db.Exec(`DELETE FROM users WHERE id = 100`)
})
repo := user.NewRepository(db)
u, err := repo.FindByID(ctx, 100)
require.NoError(t, err)
assert.Equal(t, "Alice", u.Name)
})
// Test này không bị ảnh hưởng bởi data của test trên
t.Run("get non-existent user", func(t *testing.T) {
repo := user.NewRepository(db)
_, err := repo.FindByID(ctx, 99999)
assert.ErrorIs(t, err, user.ErrNotFound)
})
}
3. Test Fixtures và Factories
Hard-coding test data trực tiếp trong test dẫn đến fragile tests và duplicate.
Builder pattern cho test objects
// testutil/builders.go
package testutil
type UserBuilder struct {
user *User
}
func NewUser() *UserBuilder {
return &UserBuilder{
user: &User{
Name: "Default User",
Email: "user@example.com",
Status: "active",
CreatedAt: time.Now(),
},
}
}
func (b *UserBuilder) WithName(name string) *UserBuilder {
b.user.Name = name
return b
}
func (b *UserBuilder) WithEmail(email string) *UserBuilder {
b.user.Email = email
return b
}
func (b *UserBuilder) WithStatus(status string) *UserBuilder {
b.user.Status = status
return b
}
func (b *UserBuilder) Inactive() *UserBuilder {
b.user.Status = "inactive"
b.user.DeactivatedAt = time.Now()
return b
}
func (b *UserBuilder) Build() *User {
return b.user
}
func (b *UserBuilder) MustCreate(t *testing.T, repo UserRepository) *User {
t.Helper()
err := repo.Create(context.Background(), b.user)
require.NoError(t, err)
return b.user
}
// Tests sử dụng builder
func TestOrderService_OnlyActiveUsers(t *testing.T) {
repo := NewInMemoryUserRepository()
activeUser := testutil.NewUser().
WithName("Alice").
WithEmail("alice@example.com").
MustCreate(t, repo)
inactiveUser := testutil.NewUser().
WithName("Bob").
Inactive().
MustCreate(t, repo)
svc := NewOrderService(repo)
// Active user có thể order
_, err := svc.PlaceOrder(ctx, activeUser.ID, testOrder)
assert.NoError(t, err)
// Inactive user không thể
_, err = svc.PlaceOrder(ctx, inactiveUser.ID, testOrder)
assert.ErrorIs(t, err, ErrUserInactive)
}
4. Anti-patterns phổ biến
Anti-pattern 1: Testing implementation, không phải behavior
// BAD: test biết quá nhiều về internal implementation
func TestUserService_GetUser_Bad(t *testing.T) {
svc := NewUserService(repo)
_ = svc.GetUser(ctx, 1)
// Đây là testing implementation, không phải behavior
assert.Equal(t, 1, svc.cacheHitCount) // internal counter
assert.True(t, svc.lastQueryUsedIndex) // internal state
}
// GOOD: test chỉ quan tâm output
func TestUserService_GetUser_Good(t *testing.T) {
repo := NewInMemoryUserRepository()
repo.Seed(&User{ID: 1, Name: "Alice"})
svc := NewUserService(repo)
user, err := svc.GetUser(ctx, 1)
require.NoError(t, err)
assert.Equal(t, "Alice", user.Name) // behavior: trả về đúng user
}
Tác hại: refactor internal implementation → test break, dù behavior không đổi.
Anti-pattern 2: Flaky tests
Flaky test: khi chạy fail, khi chạy pass — không deterministic.
// BAD: phụ thuộc vào thời gian thực
func TestTokenExpiry_Flaky(t *testing.T) {
token := issueToken(expiresIn: 100*time.Millisecond)
time.Sleep(200 * time.Millisecond) // sleep có thể không đủ nếu CPU bận
assert.True(t, token.IsExpired())
}
// GOOD: inject clock, control time
func TestTokenExpiry_Deterministic(t *testing.T) {
now := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
clock := &FixedClock{t: now}
token := issueTokenWithClock(clock, expiresIn: time.Minute)
clock.t = now.Add(2 * time.Minute) // fast-forward time
assert.True(t, token.IsExpired())
}
Nguyên nhân phổ biến của flakiness:
❌ time.Sleep() và race conditions
❌ Shared global state giữa tests
❌ Depend vào network (external APIs)
❌ Depend vào file system path tuyệt đối
❌ Random data không seed cố định
❌ Test ordering (TestA phụ thuộc side effect của TestB)
Phát hiện flaky test:
# Chạy test 10 lần để detect flakiness
go test -count=10 -run TestFlaky ./...
# Race condition detector
go test -race ./...
Anti-pattern 3: Test coupling (test biết về test khác)
// BAD: TestB phụ thuộc side effect của TestA
var sharedUserID int64
func TestA_CreateUser(t *testing.T) {
u := createUser("alice@example.com")
sharedUserID = u.ID // lưu vào global var
}
func TestB_GetUser(t *testing.T) {
// Phụ thuộc TestA đã chạy trước
u := getUser(sharedUserID)
assert.NotNil(t, u)
}
// GOOD: mỗi test tự chuẩn bị data
func TestGetUser(t *testing.T) {
repo := NewInMemoryUserRepository()
created := &User{Name: "Alice"}
repo.Create(ctx, created)
fetched, err := repo.FindByID(ctx, created.ID)
require.NoError(t, err)
assert.Equal(t, "Alice", fetched.Name)
}
Anti-pattern 4: Quá nhiều mocks, ít confidence
// BAD: mock quá nhiều, test không còn ý nghĩa
func TestCreateOrder_TooManyMocks(t *testing.T) {
mockRepo := new(MockOrderRepo)
mockCache := new(MockCache)
mockQueue := new(MockQueue)
mockNotifier := new(MockNotifier)
mockAudit := new(MockAudit)
mockRepo.On("Create", mock.Anything).Return(&Order{ID: 1}, nil)
mockCache.On("Invalidate", mock.Anything).Return(nil)
mockQueue.On("Publish", mock.Anything).Return(nil)
mockNotifier.On("Notify", mock.Anything).Return(nil)
mockAudit.On("Log", mock.Anything).Return(nil)
svc := NewOrderService(mockRepo, mockCache, mockQueue, mockNotifier, mockAudit)
order, err := svc.CreateOrder(ctx, input)
// Test này chỉ verify rằng code gọi các mock đúng thứ tự
// không verify bất kỳ business logic thật nào
require.NoError(t, err)
assert.Equal(t, 1, order.ID)
}
5. Property-based Testing
Thay vì viết từng test case cụ thể, property-based testing sinh random inputs và verify invariants (tính chất luôn đúng).
go get pgregory.net/rapid
import "pgregory.net/rapid"
// Property: encode -> decode phải cho ra kết quả ban đầu
func TestJSONEncoding_RoundTrip(t *testing.T) {
rapid.Check(t, func(t *rapid.T) {
// rapid sinh User với random values
user := &User{
ID: rapid.Int64Range(1, 1_000_000).Draw(t, "id"),
Name: rapid.StringMatching(`[A-Za-z ]{1,50}`).Draw(t, "name"),
Email: rapid.StringMatching(`[a-z]{3,10}@[a-z]{3,6}\.[a-z]{2,4}`).Draw(t, "email"),
}
encoded, err := json.Marshal(user)
require.NoError(t, err)
var decoded User
require.NoError(t, json.Unmarshal(encoded, &decoded))
// Property: round-trip phải cho kết quả giống nhau
assert.Equal(t, user.ID, decoded.ID)
assert.Equal(t, user.Name, decoded.Name)
assert.Equal(t, user.Email, decoded.Email)
})
}
// Property: sorted list phải có độ dài không đổi và mỗi phần tử ≤ phần tử tiếp
func TestSort_Properties(t *testing.T) {
rapid.Check(t, func(t *rapid.T) {
input := rapid.SliceOf(rapid.Int()).Draw(t, "input")
sorted := Sort(input) // function cần test
// Property 1: length không đổi
assert.Equal(t, len(input), len(sorted))
// Property 2: ordered
for i := 1; i < len(sorted); i++ {
assert.LessOrEqual(t, sorted[i-1], sorted[i],
"element at %d should be <= element at %d", i-1, i)
}
// Property 3: cùng elements (là permutation của input)
inputCopy := make([]int, len(input))
copy(inputCopy, input)
sort.Ints(inputCopy)
assert.Equal(t, inputCopy, sorted)
})
}
Property-based test đặc biệt hữu ích cho:
- Serialization/deserialization (round-trip)
- Sorting và ordering algorithms
- Parser/formatter pairs
- Mathematical properties (commutativity, associativity)
6. Test Naming Convention
// Pattern: TestUnit_Method_Scenario
// Pattern: TestUnit_Method_WhenCondition_ShouldResult
func TestUserService_GetUser_WhenUserExists_ReturnsUser(t *testing.T) { ... }
func TestUserService_GetUser_WhenUserNotFound_ReturnsError(t *testing.T) { ... }
func TestUserService_CreateUser_WhenEmailDuplicate_ReturnsDuplicateError(t *testing.T) { ... }
// Với table-driven, tên subtest là scenario
tests := []struct{ name string }{
{name: "valid input"},
{name: "empty name"},
{name: "email already taken"},
}
Tên test tốt làm double duty: khi test fail, tên cho biết ngay cái gì đang test và điều kiện nào fail — không cần đọc code.
💡 Interview: "Test của bạn nên serve như documentation. Khi developer mới join team đọc test, họ phải hiểu được business requirements — không phải chỉ implementation details."