头图

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(轻量级、易嵌入)

litao-2071
1 声望0 粉丝