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(輕量級、易嵌入)