集成测试:数据库与外部依赖怎么取舍
集成测试的红灯大多不是断言写错,而是边界没划清:用 SQLite 跑绿了,上线才发现 PostgreSQL 的 JSON 字段与 ON CONFLICT 语义不同;或者一套用例共用开发库,同事留下的一条脏数据就让你的测试变红。判断标准只有一条——被测逻辑依赖的那个行为,是真的还是假的。
三类做法与适用边界
| 方案 | 真实度 | 速度 | 隔离性 | 适合的场景 |
|---|---|---|---|---|
| 共享真库(开发库/预发库) | 最高,与生产同款 | 快,无启动成本 | 最差,靠人工清理 | 排障、一次性验证、DDL 与迁移验证 |
| 容器化真库(Testcontainers / docker compose) | 高,同版本同方言 | 中,首次拉镜像慢 | 好,每套测试一个实例 | CI 主力;SQL、事务、约束、锁相关逻辑 |
| 内存替代(SQLite、mock 仓储、内存版 store) | 低,方言与语义都可能不同 | 最快 | 好 | 纯业务流程、编排逻辑、超大批量用例 |
取舍结论:数据库方言、事务隔离级别、唯一约束与死锁、锁行为、JSON/数组/时区处理、连接池与超时——这几类必须有真库;纯计算、分支编排、错误码映射用内存替代即可。 想让 AI 帮你划边界,直接问它「这段代码依赖的哪些数据库行为,只有真实数据库才能验证」。
隔离:优先事务回滚,不够再换实例
成本最低的隔离方式是「一个用例一个事务,结束无条件回滚」:
// withTx 为每个用例开启独立事务,退出时回滚,用例之间互不污染
func withTx(t *testing.T, db *sql.DB, fn func(tx *sql.Tx)) {
t.Helper()
tx, err := db.BeginTx(context.Background(), nil)
if err != nil {
t.Fatalf("begin tx: %v", err)
}
defer tx.Rollback() // 用例 panic 也会回滚
fn(tx)
}
回滚式隔离有三种失效场景,遇到就换成「每套测试一个容器」或 TRUNCATE 清理:被测代码自己提交事务(内部调用 Commit)、需要跨连接可见性(并发用例看不到彼此未提交的数据)、涉及 DDL(PostgreSQL 的 DDL 可回滚,MySQL 不可回滚)。
让 AI 写四段式集成测试
四段式(准备-执行-断言-清理)看着啰嗦,但它是集成测试唯一能照着检查的骨架:
为 CreateOrder 写一个集成测试,只输出一个 Go 测试函数,必须按四段式组织,段与段之间用注释标出:
1) 准备(Arrange):插入的用户、商品、库存数据逐字段内联写清,不要用共享 fixture,不要依赖库里已有的数据;
2) 执行(Act):只调用一次被测入口,一次测试里不要连续做多个写操作;
3) 断言(Assert):断言数据库里的最终状态(行内容、金额、状态字段、副作用表),不要只断言返回的 err 为 nil;
4) 清理(Cleanup):说明依赖事务回滚还是显式删除,并保证断言失败时也会清理。
写完回答三个问题:
- 这个测试依赖的数据库行为里,哪些是 SQLite 无法模拟的?
- 如果加上 t.Parallel(),哪一步会先失效?为什么?
- 这条用例真正的失败模式是什么,哪个断言会最先变红?
如果它给出的准备数据依赖「另一条测试留下的数据」,或者清理只覆盖了顺利路径,直接打回重写。
容器化跑法
// TestMain:拉起真实 PostgreSQL,跑迁移,测试结束销毁容器
var testDB *sql.DB
func TestMain(m *testing.M) {
ctx := context.Background()
pg, err := postgres.Run(ctx, "postgres:16-alpine",
postgres.WithDatabase("test"), postgres.WithUsername("t"), postgres.WithPassword("t"),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").WithOccurrence(2)),
)
if err != nil {
log.Fatal(err)
}
dsn, _ := pg.ConnectionString(ctx, "sslmode=disable")
if testDB, err = sql.Open("pgx", dsn); err != nil {
log.Fatal(err)
}
if err := migrate(testDB); err != nil {
log.Fatal(err)
}
code := m.Run()
_ = pg.Terminate(ctx)
os.Exit(code)
}
# 不想在测试代码里管容器,就用 compose 起,本地与 CI 共用一套脚本
docker compose -f test/docker-compose.yml up -d --wait
go test ./internal/order -tags=integration -count=1 -run TestCreateOrder -v
go test ./... -short # 默认只跑单测,集成用例用 testing.Short() 跳过
docker compose -f test/docker-compose.yml down -v
-count=1 用来关掉结果缓存,否则你会以为测试通过了,其实只是复用了上一次的输出。
常见坑
| 坑 | 后果 | 规避 |
|---|---|---|
| 只用 mock 断言调用次数 | 重构即红,改坏行为反而绿 | 断言数据库状态或返回值,少断言交互 |
| SQLite 跑绿的 SQL 直接上线 | 方言差异在生产爆发 | CRUD 可以妥协,约束、JSON、窗口函数必须真库 |
| 共享库加手工清理 | 用例互相污染、顺序敏感、偶发红 | 事务回滚,或每套用例独立容器 |
| 用 sleep 等异步结果 | 慢且偶发失败 | 轮询到条件成立,带超时上限 |
集成测试混进默认 go test | 本地没 Docker 就红,CI 变慢 | build tag + testing.Short() 分离 |
| 拿生产数据当测试数据 | 数据泄漏与脏数据依赖 | 用例内联造数据,敏感字段脱敏 |
小结:集成测试的边界由「你必须信任哪个真实行为」决定——SQL、事务、约束、锁、时区用容器真库,纯逻辑用内存替代;隔离优先事务回滚,回滚失效就换独立实例;让 AI 按四段式产出用例,但数据准备与清理规则必须由你审查,因为集成测试最贵的成本不是写,而是长期维护一套互相污染、偶发变红的用例。