集成测试

单元测试隔离了数据库,但有些问题只有在真实数据库环境下才能发现。集成测试就是验证代码和数据库的协作是否正常。

为什么需要集成测试

单元测试用 Mock 或 SQLite,可能漏掉这些问题:

  • SQL 语法在不同数据库的差异
  • 索引是否生效
  • 事务隔离级别问题
  • 连接池行为
  • 并发竞争

集成测试用真实的数据库环境,能发现这些隐藏问题。

测试数据库准备

集成测试需要一个独立的测试数据库:

func setupIntegrationDB(t *testing.T) *gorm.DB {
    dsn := "test_user:test_pass@tcp(localhost:3306)/test_db?charset=utf8mb4&parseTime=True&loc=Local"
    
    db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
        Logger: logger.Default.LogMode(logger.Silent),
    })
    if err != nil {
        t.Skipf("skip integration test: %v", err)
    }
    
    // 清理并重建表
    db.Migrator().DropTable(&User{}, &Order{})
    db.AutoMigrate(&User{}, &Order{})
    
    return db
}

用 t.Skip 而不是 t.Fatal,这样没有测试环境时不会报错。

Testcontainers

用 Docker 启动测试数据库更可靠:

func setupTestContainer(t *testing.T) (*gorm.DB, func()) {
    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_db",
        },
        WaitingFor: wait.ForLog("port: 3306  MySQL Community Server"),
    }
    
    mysqlContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
        ContainerRequest: req,
        Started:          true,
    })
    if err != nil {
        t.Skipf("skip: %v", err)
        return nil, func() {}
    }
    
    host, _ := mysqlContainer.Host(ctx)
    port, _ := mysqlContainer.MappedPort(ctx, "3306")
    
    dsn := fmt.Sprintf("root:test@tcp(%s:%s)/test_db?charset=utf8mb4&parseTime=True&loc=Local", host, port.Port())
    db, _ := gorm.Open(mysql.Open(dsn), &gorm.Config{})
    
    cleanup := func() {
        mysqlContainer.Terminate(ctx)
    }
    
    return db, cleanup
}

func TestIntegration(t *testing.T) {
    db, cleanup := setupTestContainer(t)
    defer cleanup()
    
    db.AutoMigrate(&User{})
    
    // 测试代码...
}

Testcontainers 的好处:

  • 每次测试都是干净的数据库
  • 不污染本地环境
  • CI 环境也能运行

测试数据工厂

创建测试数据的工厂函数:

type UserFactory struct {
    db *gorm.DB
}

func (f *UserFactory) Create(overrides ...func(*User)) *User {
    user := &User{
        Name:  "测试用户",
        Email: fmt.Sprintf("test_%d@example.com", time.Now().UnixNano()),
        Status: "active",
    }
    
    for _, override := range overrides {
        override(user)
    }
    
    if err := f.db.Create(user).Error; err != nil {
        panic(err)
    }
    
    return user
}

// 使用
factory := &UserFactory{db: db}
user := factory.Create(func(u *User) {
    u.Name = "特殊用户"
    u.Status = "inactive"
})

测试事务

事务相关的测试:

func TestTransactionRollback(t *testing.T) {
    db := setupIntegrationDB(t)
    
    // 初始状态
    db.Create(&User{Name: "张三", Balance: 100})
    
    // 模拟转账失败
    err := db.Transaction(func(tx *gorm.DB) error {
        // 扣款
        if err := tx.Model(&User{}).Where("name = ?", "张三").
            Update("balance", gorm.Expr("balance - ?", 100)).Error; err != nil {
            return err
        }
        
        // 模拟失败
        return errors.New("转账失败")
    })
    
    assert.Error(t, err)
    
    // 验证回滚
    var user User
    db.Where("name = ?", "张三").First(&user)
    assert.Equal(t, 100, user.Balance)  // 余额不变
}

并发测试

测试并发场景:

func TestConcurrentUpdate(t *testing.T) {
    db := setupIntegrationDB(t)
    
    db.Create(&User{Name: "张三", Balance: 100})
    
    var wg sync.WaitGroup
    errors := make([]error, 10)
    
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func(idx int) {
            defer wg.Done()
            
            err := db.Transaction(func(tx *gorm.DB) error {
                var user User
                if err := tx.Where("name = ?", "张三").First(&user).Error; err != nil {
                    return err
                }
                
                time.Sleep(10 * time.Millisecond)  // 模拟处理时间
                
                return tx.Model(&user).Update("balance", user.Balance+10).Error
            })
            
            errors[idx] = err
        }(i)
    }
    
    wg.Wait()
    
    // 检查最终余额
    var user User
    db.Where("name = ?", "张三").First(&user)
    // 可能不是预期的 200,需要加锁
}

这个测试暴露了并发更新的问题,需要用乐观锁或悲观锁解决。

测试索引

验证索引是否生效:

func TestIndexUsage(t *testing.T) {
    db := setupIntegrationDB(t)
    
    // 创建大量测试数据
    for i := 0; i < 10000; i++ {
        db.Create(&User{
            Name:   fmt.Sprintf("user_%d", i),
            Status: "active",
        })
    }
    
    // 用 EXPLAIN 检查
    var result []map[string]interface{}
    db.Raw("EXPLAIN SELECT * FROM users WHERE status = ?", "active").Scan(&result)
    
    // 检查是否用了索引
    assert.Contains(t, result[0]["type"], "ref")
    assert.Contains(t, result[0]["key"], "idx_status")
}

CI 集成

在 CI 中运行集成测试:

# .github/workflows/test.yml
name: Test

on: [push, pull_request]

jobs:
  integration:
    runs-on: ubuntu-latest
    
    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_ROOT_PASSWORD: test
          MYSQL_DATABASE: test_db
        ports:
          - 3306:3306
        options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
    
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-go@v4
        with:
          go-version: '1.21'
      
      - name: Run integration tests
        run: go test -tags=integration ./...
        env:
          DB_HOST: 127.0.0.1
          DB_PORT: 3306

小结

集成测试的要点:

  1. 用独立的测试数据库,不要污染开发环境
  2. Testcontainers 提供一致的测试环境
  3. 封装数据工厂,简化测试数据创建
  4. 测试事务、并发等复杂场景
  5. 集成到 CI 流程

集成测试比单元测试慢,但能发现更多问题。建议核心业务逻辑都要有集成测试覆盖。