集成测试与 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
进程数11 + 依赖组件全部服务
真实依赖数据库/队列/虚拟服务全真实
运行时机每次保存每次 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;通过 @Containerfixturet.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备注
Javaorg.testcontainers@Container + JUnit 5 扩展生态最全,模块最多
Pythontestcontainers上下文管理器 / pytest fixture与 pytest 集成良好
Gotestcontainers-got.Cleanup显式 Terminate,适合表驱动
Node/TStestcontainersbefore/after 钩子支持 Vitest/Jest
.NETTestcontainersIAsyncLifetime与 xUnit/NUnit 集成
RusttestcontainersDrop/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 中会留下垃圾容器。


四、服务虚拟化与录制回放

当依赖是外部第三方(支付、短信)或尚未就绪的内部服务时,用虚拟服务替代:

工具形态能力不适用场景
WireMockJava 库 / 独立容器请求匹配、状态化 stub、故障注入、录制非 HTTP 协议
MountebankNode 独立进程多协议(HTTP/TCP/SMTP)需要复杂状态机
HoverflyGo 独立进程录制回放、模拟延迟与错误团队不熟悉其匹配语法
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 测试框架对比

框架语言浏览器并行调试能力不适用场景
PlaywrightTS/Python/Java/.NETChromium/Firefox/WebKit原生并行 + 分片trace viewer、视频、快照需要极老浏览器(IE)
CypressJS/TSChromium/Firefox/WebKit需付费或分片时间旅行调试多标签页、跨域流程弱
Selenium多语言全部(含旧版)Grid 扩展依赖日志与截图新项目(等待机制原始,flaky 多)
场景工具说明
HTTP API E2EPostman/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,以及哪些场景应下沉到集成测试。

  • 返回目录:多语言工程化