Announcement

👇Official Account👇

Welcome to join the group & private message

Article first/tail QR code

Skip to content

Go 集成测试实战:Testcontainers 与 HTTP 测试全攻略

一、为什么需要集成测试?

1.1 单元测试的局限性

go
// 单元测试只验证逻辑正确性
// 但无法验证:
// - 数据库查询是否正确
// - HTTP 路由是否匹配
// - 外部 API 调用是否正常
// - Redis 缓存是否按预期工作

// 单元测试通过了,但真实环境可能:
// - SQL 语句语法错误
// - 数据库连接池耗尽
// - HTTP 响应解析失败

1.2 集成测试的价值

维度单元测试集成测试
速度毫秒级秒级
隔离性完全隔离需要真实依赖
覆盖范围单一函数多个组件协作
发现的问题逻辑错误接口不匹配、配置错误
运行频率每次提交每次 PR 或 Merge

二、Testcontainers 基础

2.1 为什么用 Testcontainers

传统集成测试的痛点:

  • 需要手动启动数据库、Redis 等服务
  • 测试环境不一致(开发机 vs CI)
  • 测试数据互相干扰

Testcontainers 的解决方案:在测试中用 Docker 按需启动依赖服务。

2.2 安装与配置

go
import (
    "context"
    "testing"
    
    "github.com/testcontainers/testcontainers-go"
    "github.com/testcontainers/testcontainers-go/wait"
    "github.com/testcontainers/testcontainers-go/modules/postgres"
)

func TestMain(m *testing.M) {
    // 全局设置:检查 Docker 是否运行
    if _, err := exec.LookPath("docker"); err != nil {
        fmt.Println("Docker 未安装,跳过集成测试")
        os.Exit(0)
    }
    os.Exit(m.Run())
}

2.3 PostgreSQL 容器

go
func TestWithPostgres(t *testing.T) {
    ctx := context.Background()
    
    // 启动 PostgreSQL 容器
    pgContainer, err := postgres.RunContainer(ctx,
        testcontainers.WithImage("postgres:16-alpine"),
        postgres.WithDatabase("testdb"),
        postgres.WithUsername("test"),
        postgres.WithPassword("test"),
        testcontainers.WithWaitStrategy(
            wait.ForLog("database system is ready to accept connections").
                WithOccurrence(2).
                WithStartupTimeout(30*time.Second),
        ),
    )
    if err != nil {
        t.Fatal(err)
    }
    defer pgContainer.Terminate(ctx)
    
    // 获取连接字符串
    connStr, err := pgContainer.ConnectionString(ctx)
    if err != nil {
        t.Fatal(err)
    }
    
    // 连接数据库
    db, err := sql.Open("postgres", connStr)
    if err != nil {
        t.Fatal(err)
    }
    defer db.Close()
    
    // 执行测试
    err = db.Ping()
    if err != nil {
        t.Fatal(err)
    }
    
    // 创建表并插入测试数据
    _, err = db.Exec(`
        CREATE TABLE users (
            id SERIAL PRIMARY KEY,
            name TEXT NOT NULL,
            email TEXT UNIQUE NOT NULL
        )
    `)
    assert.NoError(t, err)
    
    _, err = db.Exec(`INSERT INTO users (name, email) VALUES ($1, $2)`,
        "Alice", "alice@example.com")
    assert.NoError(t, err)
    
    // 查询测试
    var count int
    err = db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
    assert.NoError(t, err)
    assert.Equal(t, 1, count)
}

2.4 Redis 容器

go
func TestWithRedis(t *testing.T) {
    ctx := context.Background()
    
    redisContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
        ContainerRequest: testcontainers.ContainerRequest{
            Image: "redis:7-alpine",
            ExposedPorts: []string{"6379/tcp"},
            WaitingFor: wait.ForLog("* Ready to accept connections"),
        },
        Started: true,
    })
    if err != nil {
        t.Fatal(err)
    }
    defer redisContainer.Terminate(ctx)
    
    host, _ := redisContainer.Host(ctx)
    port, _ := redisContainer.MappedPort(ctx, "6379")
    
    rdb := redis.NewClient(&redis.Options{
        Addr: fmt.Sprintf("%s:%s", host, port.Port()),
    })
    defer rdb.Close()
    
    // 测试缓存
    err = rdb.Set(ctx, "key", "value", 0).Err()
    assert.NoError(t, err)
    
    val, err := rdb.Get(ctx, "key").Result()
    assert.NoError(t, err)
    assert.Equal(t, "value", val)
}

三、HTTP API 集成测试

3.1 完整 HTTP 测试

go
func TestUserAPI(t *testing.T) {
    ctx := context.Background()
    
    // 启动依赖服务
    pgContainer := startPostgres(t)
    defer pgContainer.Terminate(ctx)
    
    // 初始化服务
    db := connectDB(t, pgContainer)
    app := setupApp(db)  // 创建 HTTP 路由器
    
    // 创建 httptest 服务器
    server := httptest.NewServer(app)
    defer server.Close()
    
    client := server.Client()
    
    t.Run("创建用户", func(t *testing.T) {
        body := `{"name":"Alice","email":"alice@example.com"}`
        resp, err := client.Post(
            server.URL+"/api/users",
            "application/json",
            strings.NewReader(body),
        )
        assert.NoError(t, err)
        defer resp.Body.Close()
        
        assert.Equal(t, http.StatusCreated, resp.StatusCode)
        
        var user User
        json.NewDecoder(resp.Body).Decode(&user)
        assert.NotZero(t, user.ID)
        assert.Equal(t, "Alice", user.Name)
    })
    
    t.Run("重复邮箱返回错误", func(t *testing.T) {
        body := `{"name":"Bob","email":"alice@example.com"}`
        resp, _ := client.Post(
            server.URL+"/api/users",
            "application/json",
            strings.NewReader(body),
        )
        defer resp.Body.Close()
        
        assert.Equal(t, http.StatusConflict, resp.StatusCode)
    })
    
    t.Run("获取用户列表", func(t *testing.T) {
        resp, err := client.Get(server.URL + "/api/users")
        assert.NoError(t, err)
        defer resp.Body.Close()
        
        assert.Equal(t, http.StatusOK, resp.StatusCode)
        
        var users []User
        json.NewDecoder(resp.Body).Decode(&users)
        assert.Len(t, users, 1)
    })
}

3.2 测试 HTTP 中间件

go
func TestAuthMiddleware(t *testing.T) {
    tests := []struct {
        name       string
        token      string
        statusCode int
    }{
        {"有效 Token", "valid-token", http.StatusOK},
        {"无效 Token", "invalid-token", http.StatusUnauthorized},
        {"空 Token", "", http.StatusUnauthorized},
    }
    
    handler := authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
    }))
    
    server := httptest.NewServer(handler)
    defer server.Close()
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            req, _ := http.NewRequest("GET", server.URL, nil)
            req.Header.Set("Authorization", "Bearer "+tt.token)
            
            resp, err := http.DefaultClient.Do(req)
            assert.NoError(t, err)
            assert.Equal(t, tt.statusCode, resp.StatusCode)
        })
    }
}

四、数据库测试

4.1 测试夹具(Fixture)

go
// fixture.go
type TestFixture struct {
    DB      *sql.DB
    Cleanup func()
}

func setupTestDB(t *testing.T, ctx context.Context) *TestFixture {
    pgContainer, err := postgres.RunContainer(ctx,
        testcontainers.WithImage("postgres:16-alpine"),
        postgres.WithDatabase("testdb"),
        postgres.WithUsername("test"),
        postgres.WithPassword("test"),
    )
    require.NoError(t, err)
    
    connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
    require.NoError(t, err)
    
    db, err := sql.Open("postgres", connStr)
    require.NoError(t, err)
    
    // 执行迁移
    runMigrations(db)
    
    return &TestFixture{
        DB: db,
        Cleanup: func() {
            db.Close()
            pgContainer.Terminate(ctx)
        },
    }
}

4.2 事务级隔离

go
func TestWithTransaction(t *testing.T) {
    fixture := setupTestDB(t, context.Background())
    defer fixture.Cleanup()
    
    // 在每个测试中使用独立事务
    // 测试结束后回滚,不污染数据库
    
    t.Run("insert user", func(t *testing.T) {
        tx, err := fixture.DB.Begin()
        require.NoError(t, err)
        defer tx.Rollback()  // 测试结束回滚
        
        _, err = tx.Exec(`INSERT INTO users (name, email) VALUES ($1, $2)`,
            "Alice", "alice@example.com")
        assert.NoError(t, err)
        
        var count int
        tx.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
        assert.Equal(t, 1, count)
    })
    
    t.Run("另一个测试不受影响", func(t *testing.T) {
        var count int
        fixture.DB.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
        assert.Equal(t, 0, count)  // 前一个测试的数据已回滚
    })
}

五、模拟外部 API

5.1 httptest 模拟服务器

go
func TestPaymentService(t *testing.T) {
    // 模拟支付网关
    mockGateway := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // 验证请求
        body, _ := io.ReadAll(r.Body)
        var req PaymentRequest
        json.Unmarshal(body, &req)
        
        assert.Equal(t, "order-123", req.OrderID)
        assert.Equal(t, 2999, req.Amount)  // $29.99 in cents
        
        // 返回模拟响应
        resp := PaymentResponse{
            TransactionID: "txn_abc123",
            Status:        "success",
        }
        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(resp)
    }))
    defer mockGateway.Close()
    
    // 使用模拟服务器地址
    svc := NewPaymentService(mockGateway.URL, "api-key")
    
    result, err := svc.Charge("order-123", 2999)
    assert.NoError(t, err)
    assert.Equal(t, "txn_abc123", result.TransactionID)
    assert.Equal(t, "success", result.Status)
}

5.2 记录并重放

go
// 使用 go-vcr 录制/回放 HTTP 请求
import "github.com/dnaeon/go-vcr/recorder"

func TestExternalAPIWithVCR(t *testing.T) {
    // 第一次运行:录制真实请求
    // 后续运行:回放录制的响应
    r, err := recorder.New("fixtures/external-api")
    require.NoError(t, err)
    defer r.Stop()
    
    client := &http.Client{
        Transport: r,  // 自动录制/回放
    }
    
    // 使用 client 发送请求
    resp, err := client.Get("https://api.github.com/repos/golang/go")
    require.NoError(t, err)
    defer resp.Body.Close()
    
    // 这个请求在第一次运行时真实发出
    // 之后从 fixtures/external-api.yaml 回放
}

六、测试编排

6.1 Docker Compose 集成

go
func TestWithDockerCompose(t *testing.T) {
    compose, err := testcontainers.NewDockerCompose("docker-compose.yml")
    require.NoError(t, err)
    
    ctx := context.Background()
    err = compose.WithCommand([]string{"up", "-d"}).Start()
    require.NoError(t, err)
    defer compose.Down()
    
    // 等待服务就绪
    time.Sleep(5 * time.Second)
    
    // 执行测试
    // ...
}

6.2 并行测试安全

go
func TestParallelWithSharedDB(t *testing.T) {
    // 使用不同的 schema 或数据库隔离
    // 每个并行测试使用独立的 schema
    for i := 0; i < 5; i++ {
        i := i
        t.Run(fmt.Sprintf("parallel-%d", i), func(t *testing.T) {
            t.Parallel()
            
            schema := fmt.Sprintf("test_schema_%d", i)
            createSchema(t, db, schema)
            defer dropSchema(t, db, schema)
            
            // 在独立的 schema 中测试
            setSearchPath(t, db, schema)
            // ...
        })
    }
}

七、CI/CD 中的集成测试

yaml
name: Integration Tests
on:
  pull_request:
    paths:
      - '**.go'
      - 'go.mod'
      - 'go.sum'

jobs:
  integration:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_DB: testdb
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
      redis:
        image: redis:7-alpine
        options: >-
          --health-cmd "redis-cli ping"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: '1.22'
      - run: |
          go test -tags=integration -v ./...
        env:
          TEST_DATABASE_URL: postgres://test:test@postgres:5432/testdb?sslmode=disable
          TEST_REDIS_URL: redis://redis:6379

八、最佳实践总结

8.1 测试金字塔

markdown
         ╱╲
        ╱ E2E ╲         ← 少量(关键用户流程)
       ╱────────╲
      ╱ Integration ╲    ← 中等(服务间交互)
     ╱────────────────╲
    ╱   Unit Tests      ╲  ← 大量(业务逻辑)
   ╱──────────────────────╲

8.2 集成测试清单

8.3 DO ✅ / DON'T ❌

DO ✅

  • ✅ 使用 Testcontainers 管理测试依赖
  • ✅ 每个测试独立清理数据
  • ✅ 使用 build tags 区分单元测试和集成测试
  • ✅ 测试要幂等(可重复运行)
  • ✅ 对关键用户流程写 E2E 测试

DON'T ❌

  • ❌ 测试依赖特定端口(用随机端口)
  • ❌ 测试依赖外部网络(Mock 外部 API)
  • ❌ 共享测试数据(使用隔离数据)
  • ❌ 在单元测试中写集成测试逻辑

本文是 Go 质量保障系列的一部分,后续将覆盖性能测试与基准测试等主题。

上次更新于: