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 质量保障系列的一部分,后续将覆盖性能测试与基准测试等主题。

