Mock 数据库

不是所有测试都需要真实数据库。Mock 数据库可以加快测试速度,隔离外部依赖。这一章介绍几种 Mock 数据库的方法。

什么时候用 Mock

适合 Mock 的场景:

  • 单元测试,关注业务逻辑
  • 数据库不可用时的测试
  • 测试异常情况(数据库错误、超时)
  • 快速反馈的开发阶段

不适合 Mock 的场景:

  • 测试真实 SQL 执行
  • 测试事务行为
  • 测试数据库特有功能

接口 Mock

最常见的方式是把数据库操作抽象成接口:

type QueryExecutor interface {
    Find(dest interface{}, conds ...interface{}) *gorm.DB
    First(dest interface{}, conds ...interface{}) *gorm.DB
    Create(value interface{}) *gorm.DB
    Update(column string, value interface{}) *gorm.DB
    Delete(value interface{}, conds ...interface{}) *gorm.DB
}

用 testify/mock 实现:

type MockDB struct {
    mock.Mock
}

func (m *MockDB) Find(dest interface{}, conds ...interface{}) *gorm.DB {
    args := m.Called(dest, conds)
    return args.Get(0).(*gorm.DB)
}

func (m *MockDB) First(dest interface{}, conds ...interface{}) *gorm.DB {
    args := m.Called(dest, conds)
    return args.Get(0).(*gorm.DB)
}

func (m *MockDB) Create(value interface{}) *gorm.DB {
    args := m.Called(value)
    return args.Get(0).(*gorm.DB)
}

测试用例:

func TestGetUser(t *testing.T) {
    mockDB := new(MockDB)
    
    user := &User{ID: 1, Name: "张三"}
    mockDB.On("First", mock.Anything, []interface{}{uint(1)}).
        Return(&gorm.DB{Error: nil}).
        Run(func(args mock.Arguments) {
            dest := args.Get(0).(*User)
            *dest = *user
        })
    
    repo := &UserRepository{db: mockDB}
    result, err := repo.GetByID(1)
    
    assert.NoError(t, err)
    assert.Equal(t, user, result)
}

sqlmock

直接 Mock 底层的 SQL 连接:

import "github.com/DATA-DOG/go-sqlmock"

func setupMockDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock) {
    sqlDB, mock, err := sqlmock.New()
    if err != nil {
        t.Fatalf("failed to create mock: %v", err)
    }
    
    gdb, err := gorm.Open(mysql.New(mysql.Config{
        Conn: sqlDB,
        SkipInitializeWithVersion: true,
    }), &gorm.Config{})
    if err != nil {
        t.Fatalf("failed to open gorm: %v", err)
    }
    
    return gdb, mock
}

func TestFindUsers(t *testing.T) {
    db, mock := setupMockDB(t)
    
    // 设置期望
    rows := sqlmock.NewRows([]string{"id", "name", "email"}).
        AddRow(1, "张三", "zhangsan@example.com").
        AddRow(2, "李四", "lisi@example.com")
    
    mock.ExpectQuery("SELECT \\* FROM `users`").
        WillReturnRows(rows)
    
    // 执行查询
    var users []User
    db.Find(&users)
    
    assert.Len(t, users, 2)
    assert.Equal(t, "张三", users[0].Name)
    
    // 验证期望是否满足
    assert.NoError(t, mock.ExpectationsWereMet())
}

sqlmock 可以精确控制返回的数据和错误:

// 模拟错误
mock.ExpectQuery("SELECT \\* FROM `users`").
    WillReturnError(errors.New("connection lost"))

// 模拟插入
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO `users`").
    WithArgs("张三", "zhangsan@example.com").
    WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()

// 模拟事务
mock.ExpectBegin()
mock.ExpectExec("UPDATE `users`").
    WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectRollback()

GORM 的 DryRun 模式

GORM 有个 DryRun 模式,只生成 SQL 不执行:

func TestGeneratedSQL(t *testing.T) {
    db, _ := gorm.Open(mysql.Open("root:@/test"), &gorm.Config{
        DryRun: true,
    })
    
    stmt := db.Where("name = ?", "张三").First(&User{}).Statement
    
    sql := stmt.SQL.String()
    assert.Contains(t, sql, "SELECT * FROM `users` WHERE name = ?")
}

这个模式适合验证生成的 SQL 是否正确。

Repository 模式

更推荐的做法是用 Repository 模式:

type UserRepository interface {
    GetByID(ctx context.Context, id uint) (*User, error)
    GetByEmail(ctx context.Context, email string) (*User, error)
    Create(ctx context.Context, user *User) error
    Update(ctx context.Context, user *User) error
    Delete(ctx context.Context, id uint) error
}

// 真实实现
type gormUserRepository struct {
    db *gorm.DB
}

func (r *gormUserRepository) GetByID(ctx context.Context, id uint) (*User, error) {
    var user User
    err := r.db.WithContext(ctx).First(&user, id).Error
    return &user, err
}

// Mock 实现
type mockUserRepository struct {
    users map[uint]*User
}

func (r *mockUserRepository) GetByID(ctx context.Context, id uint) (*User, error) {
    user, ok := r.users[id]
    if !ok {
        return nil, gorm.ErrRecordNotFound
    }
    return user, nil
}

func (r *mockUserRepository) Create(ctx context.Context, user *User) error {
    user.ID = uint(len(r.users) + 1)
    r.users[user.ID] = user
    return nil
}

业务代码只依赖接口:

type UserService struct {
    userRepo UserRepository
}

func (s *UserService) GetUser(ctx context.Context, id uint) (*User, error) {
    return s.userRepo.GetByID(ctx, id)
}

测试:

func TestUserService_GetUser(t *testing.T) {
    mockRepo := &mockUserRepository{
        users: map[uint]*User{
            1: {ID: 1, Name: "张三"},
        },
    }
    
    service := &UserService{userRepo: mockRepo}
    
    user, err := service.GetUser(context.Background(), 1)
    assert.NoError(t, err)
    assert.Equal(t, "张三", user.Name)
    
    _, err = service.GetUser(context.Background(), 999)
    assert.Equal(t, gorm.ErrRecordNotFound, err)
}

Mock 的注意事项

不要过度 Mock

Mock 太多会让测试变得脆弱,代码重构时测试也要改。只 Mock 必要的依赖。

Mock 行为要真实

Mock 的行为要尽量接近真实数据库:

// 不好:Mock 返回了真实数据库不会返回的数据
mockRepo.On("GetByID", 1).Return(&User{ID: 1, Name: ""})  // 名字不应该为空

// 好:Mock 返回合理的数据
mockRepo.On("GetByID", 1).Return(&User{ID: 1, Name: "测试用户"})

测试边界情况

Mock 可以轻松模拟各种异常:

// 数据库连接错误
mockRepo.On("GetByID", 1).Return(nil, errors.New("connection refused"))

// 超时
mockRepo.On("GetByID", 1).Return(nil, context.DeadlineExceeded)

// 重复键
mockRepo.On("Create", user).Return(errors.New("Duplicate entry"))

小结

Mock 数据库的方法:

  1. 接口抽象 + testify/mock:灵活但需要维护接口
  2. sqlmock:底层 Mock,精确控制 SQL 行为
  3. Repository 模式 + 手写 Mock:简单直接
  4. DryRun:验证 SQL 生成

选择哪种方式取决于测试目标。业务逻辑测试用接口 Mock,SQL 行为测试用 sqlmock 或集成测试。