動態

詳情 返回 返回

GO單元測試&集成測試的 mock 方案 - 動態 詳情

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

Add a new 評論

Some HTML is okay.