单元测试

数据库相关的代码怎么测?直接连数据库太慢,不连数据库又测不到真实逻辑。这一章聊聊 GORM 应用的单元测试策略。

测试策略

单元测试的核心原则是快速、隔离、可重复。数据库操作天然依赖外部状态,需要一些技巧来隔离。

常见的策略:

  1. 依赖注入:把数据库操作抽象成接口,测试时用 Mock
  2. 内存数据库:用 SQLite 内存模式,快速且隔离
  3. 测试容器:用 Docker 启动真实数据库,接近生产环境

依赖注入与接口抽象

把数据访问层抽象成接口:

type UserRepository interface {
    FindByID(ctx context.Context, id uint) (*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) FindByID(ctx context.Context, id uint) (*User, error) {
    var user User
    err := r.db.WithContext(ctx).First(&user, id).Error
    return &user, err
}

业务代码依赖接口:

type UserService struct {
    repo UserRepository
}

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

测试时用 Mock:

type MockUserRepository struct {
    mock.Mock
}

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

func TestGetUser(t *testing.T) {
    mockRepo := new(MockUserRepository)
    service := &UserService{repo: mockRepo}
    
    expectedUser := &User{ID: 1, Name: "张三"}
    mockRepo.On("FindByID", mock.Anything, uint(1)).Return(expectedUser, nil)
    
    user, err := service.GetUser(context.Background(), 1)
    
    assert.NoError(t, err)
    assert.Equal(t, expectedUser, user)
    mockRepo.AssertExpectations(t)
}

这种方式的优点是测试速度快,不依赖数据库。缺点是测不到真实的 SQL 执行。

用 SQLite 内存数据库

SQLite 内存模式不需要启动服务,速度快:

func setupTestDB(t *testing.T) *gorm.DB {
    db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
    if err != nil {
        t.Fatalf("failed to connect database: %v", err)
    }
    
    // 自动迁移
    err = db.AutoMigrate(&User{}, &Order{})
    if err != nil {
        t.Fatalf("failed to migrate: %v", err)
    }
    
    return db
}

func TestUserRepository(t *testing.T) {
    db := setupTestDB(t)
    repo := &GormUserRepository{db: db}
    
    // 测试创建
    user := &User{Name: "张三", Email: "zhangsan@example.com"}
    err := repo.Create(context.Background(), user)
    assert.NoError(t, err)
    assert.NotZero(t, user.ID)
    
    // 测试查询
    found, err := repo.FindByID(context.Background(), user.ID)
    assert.NoError(t, err)
    assert.Equal(t, "张三", found.Name)
}

注意 SQLite 和 MySQL 的差异:

  • 语法不完全相同
  • 不支持某些 MySQL 特有功能
  • 事务行为可能不同

测试数据准备

每个测试用例需要干净的数据:

func cleanupDB(t *testing.T, db *gorm.DB) {
    db.Exec("DELETE FROM orders")
    db.Exec("DELETE FROM users")
}

func TestXXX(t *testing.T) {
    db := setupTestDB(t)
    defer cleanupDB(t, db)
    
    // 准备测试数据
    db.Create(&User{ID: 1, Name: "张三"})
    db.Create(&Order{ID: 1, UserID: 1, Amount: 100})
    
    // 执行测试...
}

或者用事务回滚:

func TestWithTransaction(t *testing.T) {
    db := setupTestDB(t)
    
    tx := db.Begin()
    defer tx.Rollback()
    
    // 所有操作在事务里
    tx.Create(&User{Name: "张三"})
    
    // 测试结束后自动回滚
}

测试辅助函数

封装常用的测试辅助函数:

func assertCount(t *testing.T, db *gorm.DB, model interface{}, expected int64) {
    var count int64
    db.Model(model).Count(&count)
    assert.Equal(t, expected, count)
}

func createUser(t *testing.T, db *gorm.DB, name string) *User {
    user := &User{Name: name}
    if err := db.Create(user).Error; err != nil {
        t.Fatalf("failed to create user: %v", err)
    }
    return user
}

测试钩子函数

钩子函数的测试:

func TestBeforeCreate(t *testing.T) {
    db := setupTestDB(t)
    
    user := &User{Name: "张三"}
    err := db.Create(user).Error
    
    assert.NoError(t, err)
    // 验证钩子是否执行
    assert.NotEmpty(t, user.UUID)
    assert.NotZero(t, user.CreatedAt)
}

测试软删除

func TestSoftDelete(t *testing.T) {
    db := setupTestDB(t)
    
    user := &User{Name: "张三"}
    db.Create(user)
    
    // 软删除
    db.Delete(user)
    
    // 普通查询找不到
    var found User
    err := db.First(&found, user.ID).Error
    assert.Error(t, err)
    assert.Equal(t, gorm.ErrRecordNotFound, err)
    
    // Unscoped 可以找到
    db.Unscoped().First(&found, user.ID)
    assert.Equal(t, user.ID, found.ID)
}

表驱动测试

对于多种输入场景,用表驱动测试:

func TestUserValidation(t *testing.T) {
    db := setupTestDB(t)
    
    tests := []struct {
        name    string
        user    User
        wantErr bool
    }{
        {"正常用户", User{Name: "张三", Email: "test@example.com"}, false},
        {"空名字", User{Name: "", Email: "test@example.com"}, true},
        {"无效邮箱", User{Name: "张三", Email: "invalid"}, true},
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            err := db.Create(&tt.user).Error
            if tt.wantErr {
                assert.Error(t, err)
            } else {
                assert.NoError(t, err)
            }
        })
    }
}

测试覆盖率

运行测试并查看覆盖率:

go test -cover ./...
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out

小结

单元测试的关键点:

  1. 用接口抽象数据访问层,方便 Mock
  2. SQLite 内存模式适合简单测试
  3. 每个测试用例要有独立的数据环境
  4. 封装测试辅助函数减少重复代码
  5. 表驱动测试覆盖多种场景

测试写得好的项目,重构起来才有底气。