数据库相关的代码怎么测?直接连数据库太慢,不连数据库又测不到真实逻辑。这一章聊聊 GORM 应用的单元测试策略。
单元测试的核心原则是快速、隔离、可重复。数据库操作天然依赖外部状态,需要一些技巧来隔离。
常见的策略:
把数据访问层抽象成接口:
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 内存模式不需要启动服务,速度快:
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 的差异:
每个测试用例需要干净的数据:
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
单元测试的关键点:
测试写得好的项目,重构起来才有底气。