集成测试与 E2E 编排
单元测试证明「函数是对的」,契约测试证明「接口约定没变」,但它们都不回答「这个服务真的能连上数据库、真的能把消息发进 Kafka」。集成测试与 E2E 的工程难点从来不是写断言,而是如何廉价、稳定、可重复地获得一套真实依赖。容器化把这个问题从「找测试环境」变成了「写编排文件」。
本章覆盖四件事:用 docker-compose/Testcontainers 拉起依赖、用服务虚拟化处理不可控外部系统、用 E2E 框架验证核心链路、让这些测试在 CI 中稳定并行地运行。
一、集成测试的定义与范围
集成测试验证的是模块与外部世界之间的连接:
flowchart TD SVC["被测服务"] SVC --> DB["数据库<br/>连接、事务、迁移"] SVC --> CACHE["缓存/Redis<br/>序列化、过期"] SVC --> MQ["消息队列<br/>生产/消费、顺序、重试"] SVC --> EXT["外部 HTTP/gRPC 服务<br/>超时、重试、错误码"] SVC --> FFI["同进程的 C/Rust 库<br/>FFI 边界"]
| 边界类型 | 测试手段 | 不适用场景 |
|---|---|---|
| 数据库 | Testcontainers 起真实数据库 | 极慢的遗留数据库无法容器化时用契约级 fake |
| 缓存 | 真实 Redis 容器 | 只验证 key 拼装时可用内存 fake |
| 消息队列 | 真实 Kafka/RabbitMQ 容器 | 仅验证消息体序列化时可用内存队列 |
| 外部 HTTP 服务 | WireMock 等虚拟化 | 对方要求真实联调(放联调环境而非 CI) |
| 同进程 FFI | 真实编译产物 + 真实调用 | 无(FFI 必须真实测试) |
| 维度 | 单元测试 | 集成测试 | E2E |
|---|---|---|---|
| 进程数 | 1 | 1 + 依赖组件 | 全部服务 |
| 真实依赖 | 无 | 数据库/队列/虚拟服务 | 全真实 |
| 运行时机 | 每次保存 | 每次 PR | 合并主干/发布前 |
| 时长 | 毫秒 | 秒到分钟 | 分钟到十分钟 |
二、docker-compose 搭建测试依赖
# docker-compose.test.yml —— 只包含测试需要的依赖,不带业务服务
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: app_test
ports:
- "5432" # 只声明容器端口,主机端口随机分配,避免并行冲突
healthcheck:
test: ["CMD-SHELL", "pg_isready -U test -d app_test"]
interval: 2s
timeout: 3s
retries: 15
redis:
image: redis:7-alpine
ports:
- "6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 2s
retries: 15
kafka:
image: bitnami/kafka:3.7
environment:
KAFKA_CFG_NODE_ID: "0"
KAFKA_CFG_PROCESS_ROLES: controller,broker
KAFKA_CFG_CONTROLLER_QUORUM_VOTERS: 0@kafka:9093
KAFKA_CFG_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093
KAFKA_CFG_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092
KAFKA_CFG_CONTROLLER_LISTENER_NAMES: CONTROLLER
KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT
ALLOW_PLAINTEXT_LISTENER: "yes"
ports:
- "9092"# 启动依赖并等待健康;测试结束后销毁(-v 清理数据卷)
docker compose -f docker-compose.test.yml up -d --wait
make test-integration && docker compose -f docker-compose.test.yml down -v| 方式 | 隔离性 | 速度 | 适用场景 | 不适用场景 |
|---|---|---|---|---|
| docker-compose | 中(端口随机,数据共享) | 首次慢,复用快 | 本地开发、CI 单任务 | 测试内需要频繁重建实例 |
| Testcontainers | 高(每个套件独立) | 每套件数秒 | 多语言混合、需要精确隔离 | 极端资源受限的 CI |
| 共享测试环境 | 低 | 最快 | 手工联调 | CI 自动化(数据污染、排队) |
| 服务虚拟化 | 高 | 最快 | 外部不可控依赖 | 验证真实数据库行为 |
原则:CI 里禁止连接共享测试环境——无法并行、数据互相污染、失败不可复现,是 flaky 的主要来源之一。
三、Testcontainers
3.1 原理
Testcontainers 在多语言库中按需启动容器,测试结束自动销毁。核心机制:每个容器映射到随机空闲端口;提供等待策略(日志、端口、健康检查)替代 sleep;通过 @Container、fixture、t.Cleanup 等语言惯用方式管理生命周期;支持本地复用模式。
sequenceDiagram participant T as 测试进程 participant TC as Testcontainers 库 participant D as Docker Daemon T->>TC: 启动 PostgreSQL 容器 TC->>D: docker run(随机主机端口) D-->>TC: 容器 ID + 端口映射 TC->>TC: 等待就绪(日志/健康检查) TC-->>T: 注入连接信息 T->>T: 执行集成测试 T->>TC: 测试结束 TC->>D: 停止并删除容器
| 语言 | 库 | 生命周期 API | 备注 |
|---|---|---|---|
| Java | org.testcontainers | @Container + JUnit 5 扩展 | 生态最全,模块最多 |
| Python | testcontainers | 上下文管理器 / pytest fixture | 与 pytest 集成良好 |
| Go | testcontainers-go | t.Cleanup | 显式 Terminate,适合表驱动 |
| Node/TS | testcontainers | before/after 钩子 | 支持 Vitest/Jest |
| .NET | Testcontainers | IAsyncLifetime | 与 xUnit/NUnit 集成 |
| Rust | testcontainers | Drop/RAII | 生态较新 |
3.2 两个语言示例
# Python:pytest fixture,scope=session 让容器复用整个测试会话
import pytest
from testcontainers.postgres import PostgresContainer
from sqlalchemy import create_engine
@pytest.fixture(scope="session")
def engine():
with PostgresContainer("postgres:16-alpine") as pg:
eng = create_engine(pg.get_connection_url())
yield eng
eng.dispose() # 退出上下文时容器自动销毁
def test_save_order(engine):
with engine.begin() as conn:
conn.execute(ORDER_INSERT, {"id": "o-1", "amount": 100})// Go:容器随测试清理,等待策略用日志而不是 sleep
func TestOrderRepository(t *testing.T) {
ctx := context.Background()
pg, err := postgres.Run(ctx, "postgres:16-alpine",
postgres.WithDatabase("app_test"),
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.Fatalf("启动 PostgreSQL 失败: %v", err)
}
t.Cleanup(func() { _ = pg.Terminate(ctx) })
repo := NewOrderRepository(pg.MustConnectionString(ctx))
if err := repo.Save(ctx, Order{ID: "o-1", Amount: 100}); err != nil {
t.Fatal(err)
}
}使用注意:CI 需要 Docker(托管 runner 自带,自建 runner 要确认 daemon);镜像提前拉取或缓存;每个套件起一套依赖而非每个测试方法;reuse 模式仅限本地,CI 中会留下垃圾容器。
四、服务虚拟化与录制回放
当依赖是外部第三方(支付、短信)或尚未就绪的内部服务时,用虚拟服务替代:
| 工具 | 形态 | 能力 | 不适用场景 |
|---|---|---|---|
| WireMock | Java 库 / 独立容器 | 请求匹配、状态化 stub、故障注入、录制 | 非 HTTP 协议 |
| Mountebank | Node 独立进程 | 多协议(HTTP/TCP/SMTP) | 需要复杂状态机 |
| Hoverfly | Go 独立进程 | 录制回放、模拟延迟与错误 | 团队不熟悉其匹配语法 |
| Polly.js / vcrpy / go-vcr | 语言库 | HTTP 录制回放(VCR 模式) | 请求含敏感数据需脱敏 |
| grpc-mock / WireMock gRPC | 库 / 容器 | gRPC 虚拟化 | 需要验证流式复杂语义 |
// mappings/payment-success.json —— 放入 WireMock 的 mappings 目录即可生效
{
"request": {
"method": "POST",
"urlPath": "/v1/payments",
"bodyPatterns": [{ "matchesJsonPath": "$.amount" }]
},
"response": {
"status": 200,
"headers": { "Content-Type": "application/json" },
"jsonBody": { "paymentId": "pay-123", "status": "SUCCESS" },
"fixedDelayMilliseconds": 50
}
}录制回放的三条纪律:录制数据必须脱敏(token、个人信息入 git 前清洗);回放必须校验请求体(只按 URL 匹配会静默返回旧数据);回放是过渡手段,外部接口的长期保障仍是契约测试(见 02 契约测试)。
五、E2E 测试框架对比
| 框架 | 语言 | 浏览器 | 并行 | 调试能力 | 不适用场景 |
|---|---|---|---|---|---|
| Playwright | TS/Python/Java/.NET | Chromium/Firefox/WebKit | 原生并行 + 分片 | trace viewer、视频、快照 | 需要极老浏览器(IE) |
| Cypress | JS/TS | Chromium/Firefox/WebKit | 需付费或分片 | 时间旅行调试 | 多标签页、跨域流程弱 |
| Selenium | 多语言 | 全部(含旧版) | Grid 扩展 | 依赖日志与截图 | 新项目(等待机制原始,flaky 多) |
| 场景 | 工具 | 说明 |
|---|---|---|
| HTTP API E2E | Postman/Newman、REST Assured、supertest | 无 UI,比浏览器 E2E 稳定得多 |
| 性能兼功能 | k6、Locust | 用脚本模拟真实调用链 |
| 移动端 | Appium、Maestro、Patrol(Flutter) | 设备农场成本高,优先轻量工具 |
// e2e/checkout.spec.ts —— 只覆盖「下单成功」这一条核心链路
import { test, expect } from "@playwright/test";
test("用户可以使用优惠券完成下单", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("邮箱").fill("buyer@example.com");
await page.getByLabel("密码").fill("test-password");
await page.getByRole("button", { name: "登录" }).click();
await page.getByRole("link", { name: "商品 A" }).click();
await page.getByRole("button", { name: "加入购物车" }).click();
await page.getByLabel("优惠码").fill("SAVE10");
await page.getByRole("button", { name: "结算" }).click();
await expect(page.getByText("订单已创建")).toBeVisible();
});选型建议:Web E2E 首选 Playwright;API 能覆盖的链路不要走 UI;移动端只保留登录、支付等极少数核心路径。
六、测试环境管理
| 策略 | 实现 | 隔离度 | 成本 | 适用场景 |
|---|---|---|---|---|
| 按 PR 创建临时环境 | 每个 PR 一个命名空间/项目 | 高 | 中 | 需要人工验收的 PR |
| 共享环境 + 数据前缀 | 所有测试用唯一前缀 | 中 | 低 | 自动化集成测试 |
| 每个套件独立容器 | Testcontainers | 高 | 中 | 数据库/队列类测试 |
flowchart LR PR["PR #123 提交"] --> CI["CI 触发"] CI --> ENV["创建临时环境<br/>命名空间 pr-123"] ENV --> SEED["执行迁移 + 种子数据"] SEED --> TEST["运行集成/E2E"] TEST --> RESULT["结果回写 PR"] RESULT --> CLEAN["合并/关闭后 TTL 清理"]
# docker compose:用项目名实现环境隔离
export COMPOSE_PROJECT_NAME="pr-123"
docker compose -f docker-compose.test.yml up -d --wait
# Kubernetes:命名空间隔离 + TTL 清理(定时任务删除超过 24 小时的 pr-*)
kubectl create namespace "pr-123"
kubectl -n "pr-123" apply -k overlays/test成本控制:临时环境设最大存活时间与并发上限;非必要组件不启动;夜间与周末自动缩容到零。
七、测试数据管理
| 做法 | 原理 | 优点 | 缺点 | 不适用场景 |
|---|---|---|---|---|
| 唯一前缀/租户 | 每个测试用唯一 ID 前缀 | 简单、可并行 | 数据累积需清理 | 依赖全局唯一约束的数据 |
| 独立 schema/库 | 每个 worker 一个 schema | 隔离好 | 迁移重复执行 | 迁移极慢的数据库 |
| 容器级隔离 | 每个套件一个数据库容器 | 最强隔离 | 启动开销 | 大量小测试 |
| 事务回滚 | 测试包在事务里,结束回滚 | 快、无残留 | 无法测跨事务逻辑 | 异步消费、多连接场景 |
数据管理规则:迁移脚本是唯一 schema 来源,测试不允许手写建表 SQL;种子数据用工厂函数 + 唯一前缀(如 test-<uuid>),清理只删自己创建的数据;禁止使用生产数据,需要真实分布时用脱敏快照;共享环境定期全量重建。
八、并行执行与资源竞争
| 资源 | 竞争表现 | 对策 |
|---|---|---|
| 端口 | 地址已被占用 | 用 Testcontainers 随机端口,或动态分配 |
| 数据库行 | 断言到别人的数据 | 唯一前缀 + 查询过滤自己的数据 |
| 数据库 schema | 迁移互相干扰 | 每 worker 独立 schema/库 |
| Kafka topic | 消费到别的测试的消息 | topic 带 worker 前缀,独立 consumer group |
| 临时文件 | 文件互相覆盖 | 每测试独立 tmp_path |
pytest -n auto --dist loadfile # Python:并行且同文件测试固定在同一 worker
go test -parallel 8 ./... # Go:测试级并行,共享资源需自行加锁
vitest run --shard=1/4 # JS:CI 分片,四个任务各跑一片并行前先保证可重复:串行都不稳定的测试,并行只会放大问题。正确顺序是「先修 flaky,再开并行」。
九、CI 中的编排
# .github/workflows/integration.yml —— 用 CI 平台的服务容器提供依赖
name: integration
on: [pull_request]
jobs:
integration:
runs-on: ubuntu-latest
timeout-minutes: 20
services:
postgres:
image: postgres:16-alpine
env: { POSTGRES_USER: test, POSTGRES_PASSWORD: test, POSTGRES_DB: app_test }
ports: ["5432:5432"]
options: >-
--health-cmd "pg_isready -U test -d app_test"
--health-interval 2s --health-timeout 3s --health-retries 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version: "1.23", cache: true }
- name: 运行集成测试
env:
DATABASE_URL: postgres://test:test@localhost:5432/app_test
run: go test -tags=integration -timeout=10m ./... 2>&1 | tee test-logs/integration.log
- uses: actions/upload-artifact@v4
if: always()
with: { name: integration-logs, path: test-logs/, retention-days: 7 }flowchart LR A["代码提交"] --> B["启动依赖<br/>service containers / compose"] B --> C["等待健康检查<br/>禁止 sleep"] C --> D["执行迁移 + 种子"] D --> E["并行运行测试<br/>超时 10-20 分钟"] E --> F{"全部通过?"} F -- 是 --> G["生成报告"] F -- 否 --> H["上传日志与截图"]
编排要点:健康检查而非 sleep;双层超时(任务级 timeout-minutes + 框架级 -timeout/pytest-timeout);失败必须留日志(容器日志、应用日志、测试日志作为 artifact);重试仅一次且记录 flaky 事件;缓存依赖镜像减少拉取时间。
十、常见坑与反模式
| 坑/反模式 | 后果 | 正确做法 |
|---|---|---|
| 集成测试连共享测试环境 | 互相污染、不可复现 | 每次 CI 起独立容器 |
用 sleep 10 等待依赖 | 慢且不稳 | healthcheck/等待策略 |
| 固定主机端口 | 并行冲突 | 随机端口或动态分配 |
| 测试数据不清理 | 数据累积、断言脆弱 | fixture 清理/容器销毁/唯一前缀 |
| 录制回放不脱敏 | 凭据进入 git | 回放前清洗敏感字段 |
| 测试无超时 | CI 卡死、资源耗尽 | 任务与测试双层超时 |
| 失败不留日志 | 无法定位、反复重跑 | 失败时上传全部日志与截图 |
| 串行就不稳定却先开并行 | 问题放大、排查困难 | 先修 flaky 再并行 |
本章小结
- 集成测试覆盖服务与真实依赖(数据库、缓存、消息队列、外部服务、FFI),E2E 只覆盖核心业务链路;
- docker-compose 适合本地与单任务 CI,Testcontainers 提供测试内生命周期与随机端口,共享环境禁止用于 CI;
- 服务虚拟化用于外部不可控依赖,录制回放必须脱敏并校验请求;
- Web E2E 首选 Playwright,API 能覆盖的链路不要走 UI;
- 环境隔离用按 PR 命名空间或容器项目名,配合 TTL 清理控制成本;
- 测试数据用唯一前缀 + 独立 schema/容器隔离,迁移脚本是唯一 schema 来源;
- 并行前提是测试可重复,端口、数据、topic、文件都要按 worker 隔离;CI 编排用健康检查代替 sleep,超时与日志收集必不可少。
下一章处理「在 CI 之前就拦截问题」:04 静态分析与质量门禁。
动手实践
任务 1:用 compose 搭建集成测试依赖
为一个服务准备 docker-compose.test.yml,包含 PostgreSQL 与 Redis,带健康检查;写一个集成测试验证「写入后能读到」。
验收标准:
docker compose up -d --wait在 30 秒内返回成功;- 测试在依赖未就绪时不会误报(用等待策略而非 sleep);
down -v后无残留数据,连续运行两次结果一致。
任务 2:用 Testcontainers 重写任务 1
把任务 1 的依赖改为 Testcontainers 在测试代码中管理,使用随机端口。
验收标准:
- 并行运行两个测试文件不冲突(记录端口分配日志);
- 测试结束后
docker ps无残留容器; - 对比两种方式:启动耗时、隔离性、CI 适配成本各写一条结论。
任务 3:写一条 E2E 核心链路
用 Playwright(或同类框架)对一个本地应用写一条 E2E:登录 → 执行一个核心操作 → 验证结果。
验收标准:
-
测试包含 trace/截图配置,失败时能产出可回放证据;
-
运行三次连续通过,无固定 sleep;
-
说明为何选择这条链路作为 E2E,以及哪些场景应下沉到集成测试。
-
返回目录:多语言工程化