GO单元测试&集成测试的 mock 方案
在单元测试或集成测试中,不希望依赖原始数据库或者说给原始数据库带去脏数据,我们往往使用Mock的方式进行模拟, 当然单元测试和集成测试中的侧重点同,下面会介绍 基于数据打桩、启动模拟数据库等解决方案。
我们通过下面这个案例来说明几种mock方式的优劣势和适用场景
案例: 需要mock 下面这个 数据库操作接口 TestRepo
// TestEntity 测试用实体
type TestEntity struct {
ID int64 `gorm:"primaryKey"`
Name string `gorm:"size:255;not null"`
Age int `gorm:"not null"`
CreateTime time.Time `gorm:"autoCreateTime" json:"create_time"`
UpdateTime time.Time `gorm:"autoUpdateTime" json:"update_time"`
}
// TestRepo 测试接口
type TestRepo interface
GetByID(ctx context.Context, id int64) (*TestEntity, error)
Create(ctx context.Context, data ...*TestEntity) error
Delete(ctx context.Context, id int64) error
}
SQLMock(交互模拟)
sqlmock或者 gomock+mockgen 都是需要通过打桩的方式手动维护测试数据的类型
代码案例:
// SQLMockAdapter SQLMock 测试适配器
type SQLMockAdapter struct {
db *gorm.DB
mock sqlmock.Sqlmock
}
// NewSQLMockAdapter 创建一个新的基于 SQLMock 的测试适配器
func NewSQLMockAdapter() (*SQLMockAdapter, error) {
sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
if err != nil {
return nil, fmt.Errorf("failed to create mock: %w", err)
}
dialector := mysql.New(mysql.Config{
Conn: sqlDB,
SkipInitializeWithVersion: true,
})
db, err := gorm.Open(dialector, &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
return nil, fmt.Errorf("failed to open gorm connection: %w", err)
}
return &SQLMockAdapter{
db: db,
mock: mock,
}, nil
}
func (m *SQLMockAdapter) GetByID(ctx context.Context, id int64) (*TestEntity, error) {
var result TestEntity
// 数据打桩
m.mock.ExpectQuery(regexp.QuoteMeta(
"SELECT * FROM `test_entities` WHERE `test_entities`.`id` = ? ORDER BY `test_entities`.`id` LIMIT ?")).
WithArgs(id, 1).
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "age"}).
AddRow(id, "test", 20))
err := m.db.WithContext(ctx).First(&result, id).Error
if err != nil {
return nil, err
}
return &result, nil
}
func (m *SQLMockAdapter) Create(ctx context.Context, data ...*TestEntity) error {
m.mock.ExpectBegin()
m.mock.ExpectExec(regexp.QuoteMeta("INSERT INTO `test_entities`")).
WillReturnResult(sqlmock.NewResult(1, int64(len(data))))
m.mock.ExpectCommit()
return m.db.WithContext(ctx).Create(&data).Error
}
func (m *SQLMockAdapter) Delete(ctx context.Context, id int64) error {
m.mock.ExpectBegin()
m.mock.ExpectExec(regexp.QuoteMeta("UPDATE `test_entities` SET `deleted_at`=?")).
WithArgs(sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(1, 1))
m.mock.ExpectCommit()
return m.db.WithContext(ctx).Delete(&TestEntity{}, id).Error
}
适用场景
- 单元测试中模拟数据库行为(如查询、事务)
- 避免真实数据库依赖,提升测试执行速度
核心优势
- 精准控制:可定义 SQL 执行结果或模拟异常(如超时、唯一键冲突)
- 无副作用:完全内存化,测试后无需清理数据
- 语言支持:专为 Go 设计,与
database/sql
无缝集成
局限性
- 真实性不足:无法验证真实 SQL 执行计划或性能问题
- 维护成本:需手动维护 SQL语句匹配规则
SQLite(嵌入式数据库)
代码案例:
// SQLiteMySQLAdapter SQLite MySQL 兼容模式测试适配器
type SQLiteMySQLAdapter struct {
db *gorm.DB
}
// NewSQLiteMySQLAdapter 创建一个新的基于 SQLite MySQL 兼容模式的测试适配器
func NewSQLiteMySQLAdapter() (*SQLiteMySQLAdapter, error) {
// 使用内存数据库
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
return nil, fmt.Errorf("failed to connect database: %w", err)
}
// 启用外键约束
db.Exec("PRAGMA foreign_keys = ON")
// 自动迁移表结构
if err := db.AutoMigrate(&TestEntity{}); err != nil {
return nil, fmt.Errorf("failed to migrate table: %w", err)
}
return &SQLiteMySQLAdapter{
db: db,
}, nil
}
func (m *SQLiteMySQLAdapter) GetByID(ctx context.Context, id int64) (*TestEntity, error) {
var result TestEntity
err := m.db.WithContext(ctx).First(&result, id).Error
if err != nil {
return nil, err
}
return &result, nil
}
func (m *SQLiteMySQLAdapter) Create(ctx context.Context, data ...*TestEntity) error {
return m.db.WithContext(ctx).Create(&data).Error
}
func (m *SQLiteMySQLAdapter) Delete(ctx context.Context, id int64) error {
return m.db.WithContext(ctx).Delete(&TestEntity{}, id).Error
}
适用场景
- 单机应用、移动端或 IoT 设备的本地数据存储
- 快速验证 SQL 逻辑或生成测试数据集
核心优势
- 零配置:单文件存储,无需服务端进程
- 跨平台:支持全平台,适合离线环境测试
- ACID 支持:完整事务特性,适合一致性要求高的场景
- 维护成本: 无需手动打桩
局限性
- 并发性能:写操作锁全库,高并发场景性能差
- 扩展性:无分布式支持,仅限单机使用
Docker(容器化数据库)
代码案例:
// DockerMockAdapter Docker MySQL 测试适配器
type DockerMockAdapter struct {
db *gorm.DB
container testcontainers.Container
}
// NewDockerMockAdapter 创建一个新的基于 Docker MySQL 的测试适配器
func NewDockerMockAdapter() (*DockerMockAdapter, error) {
ctx := context.Background()
req := testcontainers.ContainerRequest{
Image: "mysql:8.0",
ExposedPorts: []string{"3306/tcp"},
Env: map[string]string{
"MYSQL_ROOT_PASSWORD": "test",
"MYSQL_DATABASE": "test",
},
WaitingFor: wait.ForAll(
wait.ForLog("ready for connections"),
wait.ForListeningPort("3306/tcp"),
),
}
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
if err != nil {
return nil, fmt.Errorf("failed to start container: %w", err)
}
// 获取映射端口
mappedPort, err := container.MappedPort(ctx, "3306")
if err != nil {
container.Terminate(ctx)
return nil, fmt.Errorf("failed to get container port: %w", err)
}
// 使用 localhost 而不是容器 IP
dsn := fmt.Sprintf("root:test@tcp(localhost:%s)/test?charset=utf8mb4&parseTime=True&loc=Local",
mappedPort.Port(),
)
fmt.Printf("connecting with dsn: %s\n", dsn)
// 添加重试逻辑
var db *gorm.DB
maxRetries := 5
var lastErr error
for i := 0; i < maxRetries; i++ {
db, lastErr = gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if lastErr == nil {
break
}
fmt.Printf("retry %d: %v\n", i+1, lastErr)
time.Sleep(time.Second * 2)
}
if lastErr != nil {
_ = container.Terminate(ctx)
return nil, fmt.Errorf("failed to connect to database after %d retries: %w", maxRetries, lastErr)
}
if err := db.AutoMigrate(&TestEntity{}); err != nil {
_ = container.Terminate(ctx)
return nil, fmt.Errorf("failed to migrate table: %w", err)
}
return &DockerMockAdapter{
db: db,
container: container,
}, nil
}
func (m *DockerMockAdapter) GetByID(ctx context.Context, id int64) (*TestEntity, error) {
var result TestEntity
err := m.db.WithContext(ctx).First(&result, id).Error
if err != nil {
return nil, err
}
return &result, nil
}
func (m *DockerMockAdapter) Create(ctx context.Context, data ...*TestEntity) error {
return m.db.WithContext(ctx).Create(&data).Error
}
func (m *DockerMockAdapter) Delete(ctx context.Context, id int64) error {
return m.db.WithContext(ctx).Delete(&TestEntity{}, id).Error
}
适用场景
- 需要真实数据库行为但需环境隔离的场景(如集成测试、多版本兼容性测试)
- 依赖复杂数据库生态(如 MySQL、MongoDB)的完整服务模拟
核心优势
- 数据隔离性:通过容器隔离,避免污染宿主环境
- 真实性:完全模拟真实数据库行为,支持事务、索引等高级功能
- 扩展性:可结合 Docker Compose 编排多数据库服务(如 Redis + MongoDB)
局限性
- 启动开销:容器启动和销毁时间较长,不适合高频单元测试
- 资源占用:需完整数据库进程,内存和 CPU 消耗较高
综合对比与选型建议
维度 | SQLMock | SQLite | Docker |
---|---|---|---|
真实性 | 低(仅模拟) | 高(ACID) | 高(真实数据库) |
性能 | 极高(无 I/O) | 高(嵌入式) | 低(启动慢) |
适用阶段 | 单元测试 | 开发/轻量测试 | 集成测试 |
扩展性 | 低 | 低 | 高(多服务编排) |
选型优先级:
- 单元测试:优先通过拆分小方法的方式让代码变的易于测试,其次通过SQLMock、gomock+mockgen等方式手动打桩制造测试数据(打桩数据可读性差、不好维护,不建议大量使用)
- 集成测试:Docker 容器化数据库(真实性要求高)
- 快速验证:SQLite 或 H2(轻量级、易嵌入)
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。