53.3 集成测试
一句话
集成测试解决 “单元都对了,拼起来却不对”——用真实依赖验证模块之间真的合得上。
为什么单元测试全绿,集成还是会挂
因为单元测试把依赖 mock 掉了。真实对接时,mock 的假设会撞上现实的契约:
| 单元测试里 mock 的假设 | 现实的契约 |
|---|---|
| "Save 返回 nil" | 数据库字段类型不匹配,报错 |
| "这个查询返回一行" | SQL 写错了,返回零行 |
| "Redis 肯定连着" | key 前缀对不上,读不到 |
🔑 **mock 验证的是 “你的假设”,集成测试验证的是 “真实的契约”。**假设对了,不代表契约对。
核心:集成测试必须用 “真实依赖”
如果用 mock,那就是单元测试,不是集成测试。集成测试的价值恰恰在 “真实”:
- 真实数据库 (Postgres / MySQL)
- 真实缓存 (Redis)
- 真实消息队列 (Kafka / RabbitMQ)
- 真实的外部 API (或其替身服务器)
Go 怎么起真实依赖:testcontainers
testcontainers-go 用 Docker 在测试里拉起真实依赖,测完自动销毁:
import (
"context"
"database/sql"
"fmt"
"testing"
_ "github.com/jackc/pgx/v5/stdlib" // pgx 驱动
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
)
func TestUserRepo_Insert(t *testing.T) {
ctx := context.Background()
// 1. 起一个真实的 Postgres 容器
pg, err := testcontainers.GenericContainer(ctx,
testcontainers.GenericContainerRequest{
ContainerRequest: testcontainers.ContainerRequest{
Image: "postgres:16-alpine",
ExposedPorts: []string{"5432/tcp"},
Env: map[string]string{
"POSTGRES_PASSWORD": "test",
"POSTGRES_DB": "app",
},
WaitingFor: wait.ForLog("database system is ready to accept connections"),
},
Started: true,
})
if err != nil {
t.Fatal(err)
}
defer pg.Terminate(ctx) // 测完销毁,不留垃圾
// 2. 拿到真实连接串
host, _ := pg.Host(ctx)
port, _ := pg.MappedPort(ctx, "5432")
dsn := fmt.Sprintf("postgres://postgres:test@%s:%s/app?sslmode=disable", host, port.Port())
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
defer db.Close()
// 3. 建表(生产上换成 migrate 迁移脚本)
if _, err := db.Exec(`CREATE TABLE users (id BIGSERIAL PRIMARY KEY, name TEXT)`); err != nil {
t.Fatal(err)
}
// 4. 走真实的 repository,再从数据库读回来验证
repo := NewUserRepo(db)
if err := repo.Insert(ctx, User{Name: "张三"}); err != nil {
t.Fatalf("Insert: %v", err)
}
var name string
if err := db.QueryRow(`SELECT name FROM users WHERE id = 1`).Scan(&name); err != nil {
t.Fatal(err)
}
if name != "张三" {
t.Fatalf("got %q, want 张三", name)
}
}
这测试真正验证了:连接串对、驱动对、建表 SQL 对、Insert 的 SQL 对、字段映射对——这五件事,单元测试的 mock 一件都验证不了。
测试数据管理:每个测试要干净
| 方案 | 做法 | 取舍 |
|---|---|---|
| 事务回滚 | 整个测试包在一个事务里,测完 rollback | 简单,但不适用于代码里有 COMMIT 的场景 |
| 独立 schema | 每个测试用独立 schema,测完 drop | 干净,实现略复杂 |
| 容器隔离 | 每个测试起一个新容器 | 最干净,但最慢 |
何时写、何时不写
| ✅ 该写集成测试 | ❌ 不该写(交给单元测试) |
|---|---|
| 数据访问层(SQL、ORM 映射、迁移脚本) | 纯业务逻辑(计算、校验) |
| 缓存层(序列化、TTL、key 设计) | 分支判断、状态机 |
| 消息层(生产/消费、序列化格式) | 工具函数 |
落到数字:真实依赖的成本
| 项 | 量级 | 说明 |
|---|---|---|
| 起一个 Postgres 容器 | 2–10s(首次拉镜像更久) | 建议用固定版本镜像 + 本地缓存 |
| 每个测试独立 schema | 10–100ms | 比 “重建库” 快得多 |
| 集成测试套件 | 1–5min | 超过 10min 就要并行/拆分 |
| 复用容器(suite 级) | 省 90% 启动时间 | 但测试间要隔离 |
// testcontainers-go:起真实 Postgres,用完即弃
pg, _ := postgres.Run(ctx, "postgres:16-alpine",
postgres.WithDatabase("test"),
postgres.WithUsername("test"), postgres.WithPassword("test"))
defer pg.Terminate(ctx)
dsn, _ := pg.ConnectionString(ctx, "sslmode=disable")
判据:集成测试的价值 = “它用的依赖有多真”;用内存库替代,就等于把要验证的契约又换成了 mock。
自检
Q1 单元测试全绿,但一上环境就报 “字段类型不匹配”。这说明什么?哪个测试层该背锅?
▸ 答案:单元测试的 mock 没验证真实契约。这类 “SQL 对不上、类型对不上” 的问题,只有集成测试 (连真实数据库) 才能提前暴露。
Q2 有人在集成测试里用内存数据库 (如 SQLite) 替代 Postgres。这会有什么盲区?
▸ 答案:SQL 方言不同 (upsert、锁语义、内置函数),“SQLite 能跑” 不代表 “Postgres 能跑”。内存数据库是另一种 mock,不是集成测试——它把你要验证的 “真实契约” 又替换掉了。
Q3 集成测试为什么不能共享一个数据库?
▸ 答案:因为测试之间会互相污染:A 测试插入的数据影响 B 的查询结果,失败还随执行顺序变化(flaky 的经典来源)。正确做法是 “每个测试独立 schema/事务回滚”,或每个 suite 一个库并串行清理。
Q4 集成测试应该放在 CI 的哪个阶段?
▸ 答案:放在单元测试之后、E2E 之前,且只在 PR/合并时跑(不必每次提交)。它需要真实依赖(容器/服务),启动成本高,但比 E2E 便宜且定位更准——是 “发现契约错” 的最后一层。
Q5 集成测试里的 “测试数据” 怎么管理?
▸ 答案:三原则:由测试自己创建(不依赖预置数据)、用固定种子/固定 ID(可重复)、结束时清理或回滚。共享的 “测试库基线数据” 会随时间腐化,最后没人知道哪条数据是谁的。