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

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