Mock 测试

单元测试的理想状态是只测试被测代码,不依赖外部系统。但实际项目中,代码往往依赖数据库、缓存、第三方 API 等。Mock 测试就是用模拟对象替代真实依赖,让测试更快速、更可控。

为什么需要 Mock

假设你有一个用户服务,依赖数据库:

type UserService struct {
    db *sql.DB
}

func (s *UserService) GetUser(id int) (*User, error) {
    var user User
    err := s.db.QueryRow("SELECT * FROM users WHERE id = ?", id).Scan(&user.ID, &user.Name)
    if err != nil {
        return nil, err
    }
    return &user, nil
}

直接测试这个方法需要真实的数据库连接,这会带来问题:

  • 测试速度慢
  • 需要维护测试数据库
  • 测试结果可能受数据状态影响
  • CI/CD 环境需要额外配置

Mock 可以解决这些问题。

接口抽象

Mock 的前提是依赖抽象而非具体实现。首先定义接口:

// 数据访问接口
type UserRepository interface {
    FindByID(id int) (*User, error)
    Create(user *User) error
    Update(user *User) error
    Delete(id int) error
}

// 用户服务
type UserService struct {
    repo UserRepository
}

func NewUserService(repo UserRepository) *UserService {
    return &UserService{repo: repo}
}

func (s *UserService) GetUser(id int) (*User, error) {
    return s.repo.FindByID(id)
}

这样,UserService 依赖的是接口,而不是具体的数据库实现。

手写 Mock

最简单的 Mock 是手写一个实现:

type MockUserRepository struct {
    users map[int]*User
}

func NewMockUserRepository() *MockUserRepository {
    return &MockUserRepository{
        users: make(map[int]*User),
    }
}

func (m *MockUserRepository) FindByID(id int) (*User, error) {
    user, ok := m.users[id]
    if !ok {
        return nil, errors.New("user not found")
    }
    return user, nil
}

func (m *MockUserRepository) Create(user *User) error {
    m.users[user.ID] = user
    return nil
}

func (m *MockUserRepository) Update(user *User) error {
    m.users[user.ID] = user
    return nil
}

func (m *MockUserRepository) Delete(id int) error {
    delete(m.users, id)
    return nil
}

使用 Mock 进行测试:

func TestUserService_GetUser(t *testing.T) {
    // 创建 Mock
    mockRepo := NewMockUserRepository()
    mockRepo.Create(&User{ID: 1, Name: "Alice"})
    
    // 创建服务
    service := NewUserService(mockRepo)
    
    // 测试
    user, err := service.GetUser(1)
    if err != nil {
        t.Errorf("Unexpected error: %v", err)
    }
    if user.Name != "Alice" {
        t.Errorf("Expected name Alice, got %s", user.Name)
    }
    
    // 测试不存在的用户
    _, err = service.GetUser(999)
    if err == nil {
        t.Error("Expected error for non-existent user")
    }
}

使用 testify/mock

手写 Mock 对于简单场景够用,但复杂场景下维护成本高。testify 提供了更强大的 Mock 功能:

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

// Mock 实现
type MockUserRepository struct {
    mock.Mock
}

func (m *MockUserRepository) FindByID(id int) (*User, error) {
    args := m.Called(id)
    if args.Get(0) == nil {
        return nil, args.Error(1)
    }
    return args.Get(0).(*User), args.Error(1)
}

func (m *MockUserRepository) Create(user *User) error {
    args := m.Called(user)
    return args.Error(0)
}

func (m *MockUserRepository) Update(user *User) error {
    args := m.Called(user)
    return args.Error(0)
}

func (m *MockUserRepository) Delete(id int) error {
    args := m.Called(id)
    return args.Error(0)
}

使用 testify/mock 的测试:

func TestUserService_GetUser(t *testing.T) {
    mockRepo := new(MockUserRepository)
    service := NewUserService(mockRepo)
    
    // 设置期望
    mockRepo.On("FindByID", 1).Return(&User{ID: 1, Name: "Alice"}, nil)
    mockRepo.On("FindByID", 999).Return(nil, errors.New("not found"))
    
    // 测试存在的用户
    user, err := service.GetUser(1)
    assert.NoError(t, err)
    assert.Equal(t, "Alice", user.Name)
    
    // 测试不存在的用户
    _, err = service.GetUser(999)
    assert.Error(t, err)
    
    // 验证所有期望都被调用
    mockRepo.AssertExpectations(t)
}

testify/mock 的优势:

  • 可以设置调用次数期望
  • 可以验证方法参数
  • 可以设置返回值序列
  • 自动验证期望是否满足

Mock 第三方服务

假设你的服务需要调用外部 API:

type PaymentClient interface {
    Charge(userID int, amount float64) (*PaymentResult, error)
}

type OrderService struct {
    payment PaymentClient
}

func (s *OrderService) CreateOrder(userID int, amount float64) error {
    result, err := s.payment.Charge(userID, amount)
    if err != nil {
        return err
    }
    // 处理订单逻辑...
    return nil
}

Mock 支付服务:

type MockPaymentClient struct {
    mock.Mock
}

func (m *MockPaymentClient) Charge(userID int, amount float64) (*PaymentResult, error) {
    args := m.Called(userID, amount)
    if args.Get(0) == nil {
        return nil, args.Error(1)
    }
    return args.Get(0).(*PaymentResult), args.Error(1)
}

func TestOrderService_CreateOrder(t *testing.T) {
    mockPayment := new(MockPaymentClient)
    service := &OrderService{payment: mockPayment}
    
    t.Run("Successful payment", func(t *testing.T) {
        mockPayment.On("Charge", 1, 100.0).Return(&PaymentResult{Success: true}, nil)
        
        err := service.CreateOrder(1, 100.0)
        assert.NoError(t, err)
        
        mockPayment.AssertExpectations(t)
    })
    
    t.Run("Failed payment", func(t *testing.T) {
        mockPayment.On("Charge", 1, 100.0).Return(nil, errors.New("insufficient funds"))
        
        err := service.CreateOrder(1, 100.0)
        assert.Error(t, err)
        
        mockPayment.AssertExpectations(t)
    })
}

在 Gin Handler 中使用 Mock

结合 Gin 的依赖注入模式:

// Handler 结构体
type UserHandler struct {
    service *UserService
}

func NewUserHandler(service *UserService) *UserHandler {
    return &UserHandler{service: service}
}

func (h *UserHandler) GetUser(c *gin.Context) {
    id, _ := strconv.Atoi(c.Param("id"))
    
    user, err := h.service.GetUser(id)
    if err != nil {
        c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
        return
    }
    
    c.JSON(http.StatusOK, user)
}

// 路由设置
func SetupRouter(handler *UserHandler) *gin.Engine {
    router := gin.New()
    router.GET("/users/:id", handler.GetUser)
    return router
}

测试 Handler:

func TestUserHandler_GetUser(t *testing.T) {
    gin.SetMode(gin.TestMode)
    
    // 创建 Mock
    mockRepo := new(MockUserRepository)
    service := NewUserService(mockRepo)
    handler := NewUserHandler(service)
    router := SetupRouter(handler)
    
    t.Run("User exists", func(t *testing.T) {
        mockRepo.On("FindByID", 1).Return(&User{ID: 1, Name: "Alice"}, nil)
        
        req := httptest.NewRequest("GET", "/users/1", nil)
        w := httptest.NewRecorder()
        router.ServeHTTP(w, req)
        
        assert.Equal(t, http.StatusOK, w.Code)
        mockRepo.AssertExpectations(t)
    })
    
    t.Run("User not found", func(t *testing.T) {
        mockRepo.On("FindByID", 999).Return(nil, errors.New("not found"))
        
        req := httptest.NewRequest("GET", "/users/999", nil)
        w := httptest.NewRecorder()
        router.ServeHTTP(w, req)
        
        assert.Equal(t, http.StatusNotFound, w.Code)
        mockRepo.AssertExpectations(t)
    })
}

使用gomock

gomock 是官方提供的 Mock 工具,配合 mockgen 可以自动生成 Mock 代码:

安装:

go install github.com/golang/mock/mockgen@latest

定义接口:

//go:generate mockgen -source=repository.go -destination=mock_repository.go -package=mocks

type UserRepository interface {
    FindByID(id int) (*User, error)
    Create(user *User) error
}

生成 Mock:

go generate ./...

使用生成的 Mock:

import (
    "testing"
    "github.com/golang/mock/gomock"
    "your-project/mocks"
)

func TestUserService_GetUser(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()
    
    mockRepo := mocks.NewMockUserRepository(ctrl)
    service := NewUserService(mockRepo)
    
    // 设置期望
    mockRepo.EXPECT().
        FindByID(1).
        Return(&User{ID: 1, Name: "Alice"}, nil)
    
    user, err := service.GetUser(1)
    assert.NoError(t, err)
    assert.Equal(t, "Alice", user.Name)
}

gomock 的优势:

  • 自动生成 Mock 代码
  • 类型安全
  • 支持复杂的调用匹配

Mock 的最佳实践

1. 只 Mock 自己拥有的依赖

不要 Mock 你无法控制的类型,比如标准库或第三方库的类型。应该封装一层接口。

2. 不要过度 Mock

不是所有测试都需要 Mock。集成测试应该使用真实依赖,只有单元测试才需要 Mock。

3. 保持 Mock 简单

Mock 的行为应该简单明了,不要在 Mock 中实现复杂逻辑。

4. 验证调用

使用 AssertExpectations 或类似机制确保 Mock 方法被正确调用。

5. 使用依赖注入

通过构造函数注入依赖,而不是在函数内部创建依赖。

小结

Mock 测试是单元测试的重要技术:

  • 通过接口抽象实现依赖解耦
  • 手写 Mock 或使用 testify/mock、gomock
  • 在 Gin Handler 中通过依赖注入使用 Mock
  • Mock 让测试更快速、更可控、更独立

Mock 的关键在于设计良好的接口。如果代码耦合度高,Mock 会变得困难。所以 Mock 测试也在倒逼我们写出更好的代码结构。