集成测试:数据库与外部依赖怎么取舍

集成测试的红灯大多不是断言写错,而是边界没划清:用 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 按四段式产出用例,但数据准备与清理规则必须由你审查,因为集成测试最贵的成本不是写,而是长期维护一套互相污染、偶发变红的用例。

笔记加载中…