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 消耗较高

综合对比与选型建议

维度SQLMockSQLiteDocker
真实性低(仅模拟)高(ACID)高(真实数据库)
性能极高(无 I/O)高(嵌入式)低(启动慢)
适用阶段单元测试开发/轻量测试集成测试
扩展性低低高(多服务编排)

选型优先级:

  • 单元测试:优先通过拆分小方法的方式让代码变的易于测试,其次通过SQLMock、gomock+mockgen等方式手动打桩制造测试数据(打桩数据可读性差、不好维护,不建议大量使用)
  • 集成测试:Docker 容器化数据库(真实性要求高)
  • 快速验证:SQLite 或 H2(轻量级、易嵌入)

更多推荐