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

Testing: Mocks, Stubs, Fakes và Spies

1. Phân biệt 4 loại test double

"Test double" là thuật ngữ chung cho bất kỳ object nào thay thế dependency thật trong test. Có 4 loại khác nhau — và hay bị nhầm lẫn.

Test Double
    ├── Dummy    — truyền vào cho đủ signature, không dùng đến
    ├── Stub     — trả về giá trị cố định, không verify behavior
    ├── Fake     — implementation thật nhưng đơn giản hóa (in-memory DB)
    ├── Mock     — verify xem dependency có được gọi đúng cách không
    └── Spy      — ghi lại calls, assert sau (variant của Mock)

Ví dụ minh họa với cùng một scenario

type EmailSender interface {
    Send(to, subject, body string) error
}

// DUMMY — truyền vào cho đủ tham số, không dùng
type DummyEmailSender struct{}
func (d *DummyEmailSender) Send(to, subject, body string) error { return nil }

// STUB — trả về giá trị cố định, không quan tâm input
type StubEmailSender struct {
    returnErr error
}
func (s *StubEmailSender) Send(to, subject, body string) error {
    return s.returnErr // luôn trả về giá trị đã set sẵn
}

// FAKE — implementation thật nhưng không phải production
type FakeEmailSender struct {
    Sent []Email // lưu lại để verify sau
}
func (f *FakeEmailSender) Send(to, subject, body string) error {
    f.Sent = append(f.Sent, Email{To: to, Subject: subject, Body: body})
    return nil
}

// SPY — ghi lại tất cả calls để assert
type SpyEmailSender struct {
    calls []struct{ to, subject, body string }
}
func (s *SpyEmailSender) Send(to, subject, body string) error {
    s.calls = append(s.calls, struct{ to, subject, body string }{to, subject, body})
    return nil
}
func (s *SpyEmailSender) CalledWith(to string) bool {
    for _, c := range s.calls {
        if c.to == to { return true }
    }
    return false
}

💡 Interview: "Khi interviewer hỏi 'bạn dùng mock như thế nào', phần lớn ý họ là test double nói chung. Nhưng nếu bạn phân biệt được Stub vs Mock vs Fake, đó là điểm cộng lớn."


2. Interface-based design — nền tảng của testability

Muốn mock được trong Go, cần design theo interface. Đây không phải over-engineering — đây là good design.

// Sai: depend on concrete type
type UserService struct {
    db *sql.DB // không thể mock được
}

// Đúng: depend on interface
type UserRepository interface {
    FindByID(ctx context.Context, id int64) (*User, error)
    Create(ctx context.Context, user *User) error
    Update(ctx context.Context, user *User) error
}

type UserService struct {
    repo UserRepository // có thể inject mock/fake/stub
}

Quy tắc: interface nên được định nghĩa ở phía consumer, không phải provider.

// payment/service.go — consumer định nghĩa interface nó cần
package payment

type NotificationSender interface {
    Send(ctx context.Context, userID int64, msg string) error
}

type Service struct {
    notifier NotificationSender
}

Kết quả: NotificationSender có thể được implement bởi email sender, SMS sender, hoặc fake trong test — payment.Service không cần biết.


3. gomock — Mock generation

gomock generate mock từ interface, cho phép expect + verify calls.

go install go.uber.org/mock/mockgen@latest
mockgen -source=internal/user/repository.go -destination=internal/user/mock_repository.go -package=user
// internal/user/mock_repository.go (generated)
// DO NOT EDIT — generated by mockgen

type MockUserRepository struct {
    ctrl     *gomock.Controller
    recorder *MockUserRepositoryMockRecorder
}

func NewMockUserRepository(ctrl *gomock.Controller) *MockUserRepository { ... }
func (m *MockUserRepository) FindByID(ctx context.Context, id int64) (*User, error) { ... }

Sử dụng trong test

func TestUserService_GetUser(t *testing.T) {
    ctrl := gomock.NewController(t)
    // ctrl.Finish() tự động gọi ở cuối test (Go 1.14+)

    mockRepo := NewMockUserRepository(ctrl)

    // Khai báo expectation
    expectedUser := &User{ID: 1, Name: "Alice", Email: "alice@example.com"}
    mockRepo.EXPECT().
        FindByID(gomock.Any(), int64(1)).
        Return(expectedUser, nil).
        Times(1) // phải được gọi đúng 1 lần

    svc := NewUserService(mockRepo)
    user, err := svc.GetUser(context.Background(), 1)

    require.NoError(t, err)
    assert.Equal(t, "Alice", user.Name)
    // ctrl.Finish() verify tất cả expectations đã được thỏa mãn
}

gomock matchers

// Exact match
mockRepo.EXPECT().FindByID(ctx, int64(42)).Return(user, nil)

// Any value
mockRepo.EXPECT().FindByID(gomock.Any(), gomock.Any()).Return(user, nil)

// Custom matcher
mockRepo.EXPECT().
    Create(gomock.Any(), gomock.AssignableToTypeOf(&User{})).
    DoAndReturn(func(ctx context.Context, u *User) error {
        u.ID = 999 // side effect
        return nil
    })

// Never called
mockRepo.EXPECT().Delete(gomock.Any(), gomock.Any()).Times(0)

// Call order
gomock.InOrder(
    mockRepo.EXPECT().FindByID(ctx, int64(1)).Return(user, nil),
    mockCache.EXPECT().Set(ctx, "user:1", user).Return(nil),
)

4. testify/mock — Alternative nhẹ hơn

testify/mock không cần code generation. Phù hợp cho projects nhỏ hoặc khi muốn viết mock nhanh.

// Tự viết mock
type MockEmailSender struct {
    mock.Mock
}

func (m *MockEmailSender) Send(to, subject, body string) error {
    args := m.Called(to, subject, body)
    return args.Error(0)
}

// Test
func TestNotificationService_SendWelcomeEmail(t *testing.T) {
    mockSender := new(MockEmailSender)

    // Setup expectation
    mockSender.On("Send",
        "alice@example.com",
        "Welcome!",
        mock.AnythingOfType("string"), // body không quan trọng exact value
    ).Return(nil)

    svc := NewNotificationService(mockSender)
    err := svc.SendWelcomeEmail(context.Background(), "alice@example.com")

    require.NoError(t, err)
    mockSender.AssertExpectations(t) // verify tất cả .On() đã được call
}

// Test error path
func TestNotificationService_SendWelcomeEmail_EmailError(t *testing.T) {
    mockSender := new(MockEmailSender)
    mockSender.On("Send", mock.Anything, mock.Anything, mock.Anything).
        Return(errors.New("SMTP connection failed"))

    svc := NewNotificationService(mockSender)
    err := svc.SendWelcomeEmail(context.Background(), "alice@example.com")

    require.Error(t, err)
    assert.Contains(t, err.Error(), "SMTP connection failed")
}

gomock vs testify/mock

                    gomock              testify/mock
Code generation     Có (mockgen)        Không
Type safety         Cao (compile-time)  Thấp (runtime)
Boilerplate         Ít hơn (generated)  Nhiều hơn (manual)
Call verification   Strict (EXPECT)     Explicit (AssertExpectations)
Learning curve      Cao hơn             Thấp hơn
Phù hợp            Large codebases     Small/medium projects

5. Fake — Implementation đơn giản nhưng thật

Fake không mock — nó là real implementation nhưng dùng storage đơn giản hơn (in-memory thay vì DB).

// Fake repository — dùng trong unit tests
type InMemoryUserRepository struct {
    mu    sync.RWMutex
    users map[int64]*User
    nextID int64
}

func NewInMemoryUserRepository() *InMemoryUserRepository {
    return &InMemoryUserRepository{
        users: make(map[int64]*User),
    }
}

func (r *InMemoryUserRepository) Create(ctx context.Context, user *User) error {
    r.mu.Lock()
    defer r.mu.Unlock()
    r.nextID++
    user.ID = r.nextID
    r.users[user.ID] = user
    return nil
}

func (r *InMemoryUserRepository) FindByID(ctx context.Context, id int64) (*User, error) {
    r.mu.RLock()
    defer r.mu.RUnlock()
    u, ok := r.users[id]
    if !ok {
        return nil, ErrNotFound
    }
    return u, nil
}

Fake tốt hơn Mock khi: logic test cần nhiều state interactions qua nhiều calls. Mock với EXPECT() cho từng call trở nên verbose. Fake tự nhiên xử lý state.

// Dùng Fake — clean hơn nhiều so với mock cho scenario phức tạp
func TestUserService_UpdateThenFetch(t *testing.T) {
    repo := NewInMemoryUserRepository()
    svc := NewUserService(repo)

    // Create
    user, err := svc.Create(ctx, CreateUserInput{Name: "Alice"})
    require.NoError(t, err)

    // Update
    err = svc.UpdateName(ctx, user.ID, "Alice Smith")
    require.NoError(t, err)

    // Fetch — verify state
    updated, err := svc.GetUser(ctx, user.ID)
    require.NoError(t, err)
    assert.Equal(t, "Alice Smith", updated.Name)
}

6. Tại sao mocking DB thường là sai lầm

Vấn đề với mock DB:

  Code:     SELECT * FROM users WHERE email = $1 AND deleted_at IS NULL
  Mock:     mockDB.On("Query", ...).Return(fakeRows)

  → Mock không kiểm tra SQL syntax
  → Mock không kiểm tra index usage
  → Mock không kiểm tra NULL handling
  → Mock không kiểm tra transaction behavior
  → Test pass nhưng production fail vì typo trong SQL

SQL logic sống trong DB, không sống trong Go code. Khi bạn mock DB, bạn đang test Go code mà không test phần quan trọng nhất: query có đúng không?

Giải pháp: dùng testcontainers cho integration tests (xem file integration-and-contract.md).

Cái gì nên mock?                    Cái gì không nên mock?

✅ External HTTP APIs                ❌ Database (dùng testcontainers)
✅ Email/SMS sender                  ❌ Redis (dùng miniredis hoặc real)
✅ Payment gateway                   ❌ File system (dùng afero hoặc tmpdir)
✅ Time (clock interface)            ❌ Internal service implementations
✅ Random number generator           ❌ Logging infrastructure

Mocking time đúng cách

// Interface cho time — không import time.Now() trực tiếp
type Clock interface {
    Now() time.Time
}

type RealClock struct{}
func (c RealClock) Now() time.Time { return time.Now() }

type FixedClock struct{ t time.Time }
func (c FixedClock) Now() time.Time { return c.t }

// Service dùng Clock interface
type TokenService struct {
    clock  Clock
    expiry time.Duration
}

func (s *TokenService) IsExpired(token *Token) bool {
    return s.clock.Now().After(token.ExpiresAt)
}

// Test với FixedClock
func TestTokenService_IsExpired(t *testing.T) {
    now := time.Date(2024, 1, 15, 12, 0, 0, 0, time.UTC)
    clock := FixedClock{t: now}
    svc := &TokenService{clock: clock, expiry: time.Hour}

    expiredToken := &Token{ExpiresAt: now.Add(-1 * time.Hour)}
    assert.True(t, svc.IsExpired(expiredToken))

    validToken := &Token{ExpiresAt: now.Add(1 * time.Hour)}
    assert.False(t, svc.IsExpired(validToken))
}

💡 Interview: "Tôi không mock DB vì SQL behavior không được test khi mock. Tôi dùng testcontainers để spin up PostgreSQL thật trong CI — chạy trong ~3 giây, nhưng confidence cao hơn nhiều."