本节把 TaskHub 的集成测试推进到「敢并行跑」:多个测试共享一个真实 Postgres,如果没有隔离方案,它们会互相读到对方的数据、随机红。我们对比四种隔离手段,用事务回滚把每个用例关进自己的气泡里,再写一条端到端测试验证多租户隔离真的生效。
适用版本:Go 1.27(实测go1.27.0)+testcontainers-go v0.44.0+pgx v5。
11.3 集成/端到端与数据隔离
11.1 解决了「怎么起真实依赖」,但留了一个更大的问题没碰:当几十个测试共享同一个数据库时,它们怎么互不干扰?
这个问题比它看起来难。一个测试插了一行 tenant_id='acme' 的记录,另一个测试查「acme 有多少条任务」,如果第一个测试的数据还在,第二个就随机会多。测试污染(test pollution)是集成测试最恶心的失败模式——单跑绿、全跑红、顺序一变结果又变。
11.3.1 四种隔离方案对比
| 方案 | 做法 | 速度 | 隔离性 | 代价 |
|---|---|---|---|---|
| 事务回滚 | 每用例一个 tx,结束 rollback | 快 | 强 | 被测代码必须接受 tx |
| TRUNCATE | 每用例前清空所有表 | 中 | 强 | 表多/有外键时慢,且是全局操作 |
| 独立 schema | 每用例建自己的 schema | 中 | 最强 | DDL 开销,连接要切 search_path |
| 唯一前缀 | 数据加测试专属前缀 | 快 | 弱 | 查询要带前缀,容易漏 |
没有银弹,要按被测对象的性质选:
- 逻辑跑在同一个连接上(repository 层)→ 事务回滚最优雅。
- 逻辑自己开连接(HTTP handler 走连接池)→ 事务回滚用不了,得换别的。
- 需要真正并行跑 → 独立 schema。
本节的做法是两者结合:能进事务的用例用回滚隔离;端到端用例走连接池、用唯一租户前缀隔离。
11.3.2 共享容器:TestMain 起一次
先解决成本。每个测试起一个容器在 CI 上是灾难,用 TestMain 让整个 package 共用一个 Postgres:
var testPool *pgxpool.Pool
func TestMain(m *testing.M) {
ctx := context.Background()
pg, err := postgres.Run(ctx, "postgres:17-alpine",
postgres.WithDatabase("taskhub"),
postgres.WithUsername("taskhub"),
postgres.WithPassword("secret"),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).WithStartupTimeout(60*time.Second)),
)
if err != nil {
fmt.Fprintln(os.Stderr, "start container:", err)
os.Exit(1)
}
dsn, _ := pg.ConnectionString(ctx, "sslmode=disable")
pool, _ := pgxpool.New(ctx, dsn)
// 全局 schema,只建一次
_, _ = pool.Exec(ctx, `CREATE TABLE tasks (
id BIGSERIAL PRIMARY KEY,
tenant_id TEXT NOT NULL,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open')`)
testPool = pool
code := m.Run() // 跑所有测试
pool.Close()
_ = pg.Terminate(ctx)
os.Exit(code) // 清理后再退出
}
三个纪律重申:容器全局共享、schema 只建一次、os.Exit 放最后(TestMain 里 defer 不执行,用了就漏容器)。
11.3.3 事务回滚隔离:每个用例一个气泡
对于跑在单连接上的逻辑,最干净的隔离是:每个用例 Begin 一个事务,测完 Rollback。数据库状态仿佛从没被改过:
// withRollbackTx 给每个用例一个事务,结束时回滚,测试之间零污染
func withRollbackTx(t *testing.T) (context.Context, pgx.Tx) {
t.Helper()
ctx := context.Background()
tx, err := testPool.Begin(ctx)
require.NoError(t, err)
t.Cleanup(func() { _ = tx.Rollback(ctx) }) // 关键:用 t.Cleanup,不是 defer
return ctx, tx
}
用 t.Cleanup 而不是 defer 是有讲究的:t.Cleanup 会在子测试也执行,而且它注册的回滚一定在该用例所有断言之后跑。多个用例天然并行安全——每个在自己的事务里,互相看不见。
验证隔离真的生效,最好的办法是写两条互相依赖对方没留垃圾的用例:
func TestRollbackIsolation_A(t *testing.T) {
ctx, tx := withRollbackTx(t)
insertTask(ctx, tx, "acme", "a-1")
insertTask(ctx, tx, "acme", "a-2")
require.Equal(t, 2, countTasks(ctx, tx, "acme"))
}
func TestRollbackIsolation_B(t *testing.T) {
ctx, tx := withRollbackTx(t)
// 上一个用例插的两行在真实表里存在吗?事务回滚后应为 0
require.Equal(t, 0, countTasks(ctx, tx, "acme"))
insertTask(ctx, tx, "globex", "b-1")
require.Equal(t, 1, countTasks(ctx, tx, "globex"))
}
B 的第一条断言就是隔离的证明:A 明明插了两行 acme,B 却看到 0 行。如果哪天有人把 withRollbackTx 改成直接 testPool.Exec,B 会立刻红——这条测试守的就是隔离机制本身。
11.3.4 多租户隔离:把「查询强制带 tenant」测成断言
TaskHub 是多租户系统,最大的安全风险是跨租户数据泄漏。这个风险必须在测试里显式守住:
func TestTenantIsolation(t *testing.T) {
ctx, tx := withRollbackTx(t)
insertTask(ctx, tx, "acme", "secret-a")
insertTask(ctx, tx, "globex", "secret-b")
// 带 tenant 过滤的查询只能看到自己的
require.Equal(t, 1, countTasks(ctx, tx, "acme"))
require.Equal(t, 1, countTasks(ctx, tx, "globex"))
// 不带过滤会串租户——这正是要防的 bug
var total int
_ = tx.QueryRow(ctx, `SELECT count(*) FROM tasks`).Scan(&total)
require.Greater(t, total, 1,
"不带租户过滤会看到多个租户的数据,隔离必须靠查询强制注入 tenant_id")
}
这条测试同时是文档:它用代码说清了「为什么每条查询都必须带 tenant_id」。测试里的 total > 1 不是 bug,是故意展示不加过滤的后果。
11.3.5 端到端:HTTP → 逻辑 → Postgres
前面测的是 repository 层。端到端测试把整条路径打通:起一个真的 HTTP 服务(用 httptest.NewServer),发真请求,断言数据库里的最终状态。
// newAPI 用真实连接池装配一个最小 HTTP 服务(端到端:HTTP → 逻辑 → Postgres)
func newAPI(pool *pgxpool.Pool) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/tasks", func(w http.ResponseWriter, r *http.Request) {
tenant := r.Header.Get("X-Tenant-ID")
if tenant == "" {
http.Error(w, `{"error":"missing tenant"}`, http.StatusBadRequest)
return
}
ctx := r.Context()
switch r.Method {
case http.MethodPost:
var body struct{ Title string }
_ = json.NewDecoder(r.Body).Decode(&body)
var id int64
err := pool.QueryRow(ctx,
`INSERT INTO tasks (tenant_id, title) VALUES ($1,$2) RETURNING id`,
tenant, body.Title).Scan(&id)
// ... 返回 201 + id
case http.MethodGet:
rows, _ := pool.Query(ctx,
`SELECT id, title FROM tasks WHERE tenant_id = $1 ORDER BY id`, tenant)
// ... 返回该租户的任务列表
}
})
return mux
}
测试断言覆盖三件事:
func TestE2E_TenantScopedCRUD(t *testing.T) {
srv := httptest.NewServer(newAPI(testPool))
defer srv.Close()
require.Equal(t, http.StatusCreated, post("e2e-acme", "acme-task").StatusCode)
require.Equal(t, http.StatusCreated, post("e2e-globex", "globex-task").StatusCode)
acme := get("e2e-acme")
globex := get("e2e-globex")
require.Len(t, acme, 1)
require.Len(t, globex, 1)
require.Equal(t, "acme-task", acme[0]["title"])
require.Equal(t, "globex-task", globex[0]["title"])
}
三件事:创建成功(201)、租户各自只看得到自己的一条、没带租户头被拒(400)。最后一条尤其重要——它守的是「多租户服务的入口必须校验租户」这条安全底线。
11.3.6 本机实测输出
$ go test ./internal/integration/ -v -count=1 -timeout 180s
🐳 Creating container for image postgres:17-alpine
🐳 Starting container: 148c4bb1f499
⏳ Waiting for container id 148c4bb1f499 ... (occurrence: 2)
=== RUN TestE2E_TenantScopedCRUD
e2e_test.go:99: acme 看到 1 条,globex 看到 1 条,无租户头=400
--- PASS: TestE2E_TenantScopedCRUD (0.03s)
=== RUN TestRollbackIsolation_A
--- PASS: TestRollbackIsolation_A (0.03s)
=== RUN TestRollbackIsolation_B
--- PASS: TestRollbackIsolation_B (0.01s)
=== RUN TestTenantIsolation
isolation_test.go:61: 本租户 1 条,不带过滤看到 4 条
--- PASS: TestTenantIsolation (0.02s)
ok demo/internal/integration 2.533s
注意 TestTenantIsolation 那句 不带过滤看到 4 条——比本用例插的 2 条多,多出来的是端到端用例已经提交的 2 条。这恰好暴露了下一个坑。
11.3.7 真实踩坑:TRUNCATE 与事务回滚会互相踩踏
这个测试套件的第一版不是上面这样。第一版里端到端用例为了「干净」,开头加了一句:
_, err := testPool.Exec(ctx, `TRUNCATE tasks RESTART IDENTITY`)
跑出来是这样的(本机实际输出):
=== RUN TestE2E_TenantScopedCRUD
--- PASS: TestE2E_TenantScopedCRUD (0.07s)
=== RUN TestRollbackIsolation_A
--- FAIL: TestRollbackIsolation_A (0.02s)
=== RUN TestRollbackIsolation_B
--- FAIL: TestRollbackIsolation_B (0.01s)
=== RUN TestTenantIsolation
--- FAIL: TestTenantIsolation (0.04s)
三个回滚用例全红。原因:端到端用例提交了数据(它走连接池,不能用事务回滚),又和回滚用例共用了 acme 这个租户名。回滚用例 A 以为表里只有自己插的 2 条,实际还有端到端提交的 1 条,countTasks 数出来是 3,断言 == 2 就红了。
而 TRUNCATE 更是火上浇油:它是全局操作,会和其他用例正在进行的连接抢锁,还可能把它们正在用的事务数据一起清掉。
修复用了两条规则,值得抄进任何项目:
- 端到端用例用独立租户前缀(
e2e-acme、e2e-globex),绝不和回滚用例共用租户名。 - 回滚隔离与 TRUNCATE 清理不要在同一个 package 里混用——它们是两套互不兼容的世界观。要么全走事务回滚,要么全走「清库 + 唯一前缀」。
修完之后四个用例全绿(就是 11.3.6 的输出)。这个坑的教训比结论更有价值:隔离方案必须全局统一,混用往往出问题。
11.3.8 并行、超时与 CI
最后三个工程细节:
-count=1关掉结果缓存。go test默认缓存「同样的输入同样的输出」,集成测试依赖外部状态,必须每次真跑。-timeout一定要给。容器启动可能卡住,默认 10 分钟超时太晚。集成测试给-timeout 180s,卡住就快速失败。- 并行要显式开。
t.Parallel()只对能并行的用例开;共享容器 + 事务回滚的用例可以放心并行,因为它们互不可见;端到端用例如果各自用唯一前缀也能并行。
CI 上的最小命令:
go test ./... -count=1 -timeout 300s
在本地没 Docker 的机器上,用 11.1 讲的 build tag 把集成测试隔开,go test ./... 就只跑单元测试,不至于因为环境而全红。
小结
- 测试污染是集成测试最恶心的失败模式;四种隔离方案各有取舍,按被测对象性质选。
- 跑在单连接上的逻辑用事务回滚隔离,用
t.Cleanup注册 rollback。 - 走连接池的端到端逻辑用唯一租户前缀隔离,不要和回滚用例共用数据。
- 多租户隔离要写成显式断言,包括「不带租户头被拒」这条安全底线。
- 隔离方案必须全局统一:TRUNCATE 清理与事务回滚混用会互相踩踏(本机实测三连红)。
- 集成测试固定带
-count=1 -timeout;无 Docker 环境用 build tag 隔离。
测试保证「改动不破坏已有行为」。下一章换一个维度:当性能真的成为问题时,怎么用基准、压测和 pprof 把瓶颈量出来,而不是猜出来。
阅读导航:上一节:11.2 契约测试与 mock · 下一节:12.1 基准与压测 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。