单元测试证明不了"这段 SQL 在 MySQL 8 上真能执行"、"这条消息真能被消费"、"提交后才报唯一键冲突"。这些结论只有连上真实依赖才成立——而"真实依赖"过去意味着一台共享测试库,谁跑谁脏。
Testcontainers 把这一步变成代码:依赖以容器随测试启停,测试第一次拥有了自己的数据库。代价是启动成本与一堆新坑。
1. 分界:什么才值得起容器
| 被测内容 | 该用哪层 | 理由 |
|---|---|---|
| 分支、计算、校验规则 | 单元测试 | 纯内存,容器只会拖慢反馈 |
| SQL 方言、索引、约束、事务隔离 | 集成测试 + 真库容器 | H2 与 MySQL 的差异足以造出假绿 |
| JSON 序列化、字段映射 | @JsonTest |
不需要外部进程 |
| 消息生产/消费、重试、死信 | 集成测试 + broker 容器 | 客户端语义在真实 broker 上才成立 |
| 对第三方 HTTP 的调用格式 | WireMock / 契约测试 | 不需要真第三方 |
| 多服务链路、网关、鉴权 | 端到端或契约 + 少量集成 | 成本高,覆盖面窄 |
判据只有一条:被测行为是否跨进程边界。跨了要真依赖;没跨,容器只是把单测拖成分钟级。
2. Testcontainers 的四块拼图
@SpringBootTest
@Testcontainers
class OrderRepositoryIT {
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.4")
.withDatabaseName("clarity")
.withCommand("--character-set-server=utf8mb4");
@DynamicPropertySource
static void props(DynamicPropertyRegistry r) {
r.add("spring.datasource.url", mysql::getJdbcUrl);
r.add("spring.datasource.username", mysql::getUsername);
r.add("spring.datasource.password", mysql::getPassword);
}
}
| 拼图 | 作用 | 注意 |
|---|---|---|
JUnit 5 扩展 @Testcontainers |
扫描 @Container 字段并管理生命周期 |
忘了它,容器不会启停 |
static 字段 |
每个测试类启动一次 | 非 static 则每个测试方法一次,通常太慢 |
@DynamicPropertySource |
把随机端口、库名写进 Environment |
必须 static,且早于上下文刷新 |
@ServiceConnection |
Spring Boot 3.1+ 自动装配连接信息 | 省掉手写属性;按 3.3.x 核对可用的 ConnectionDetails 类型 |
换成 @ServiceConnection 后那段属性注入可删:
@Container
@ServiceConnection
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.4");
前提是引入 spring-boot-testcontainers 且容器类型有对应 ConnectionDetails 工厂;自建镜像或特殊账号仍回到 @DynamicPropertySource。
3. 容器复用:本地舒服,CI 危险
# ~/.testcontainers.properties
testcontainers.reuse.enable=true
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.4")
.withReuse(true)
.withCreateContainerCmdModifier(c -> c.withName("it-mysql-clarity"));
| 场景 | 是否复用 | 原因 |
|---|---|---|
| 本地反复跑单类测试 | 复用 | 起库的固定开销省掉,反馈从分钟级回到十几秒 |
| CI 每次全新构建 | 不复用 | 构建机是干净的,复用没有收益 |
| CI 上多 job 共享 runner | 不要 | 复用容器会被上一个 job 的数据污染,且镜像版本可能已变 |
复用要求容器有固定名字。一旦复用,数据不会自动清理——上一轮留下的行会参与下一轮断言,这是"本地绿、CI 红"的经典来源。
4. 启动成本的四种应对
| 手段 | 效果 | 代价 |
|---|---|---|
同类共用一个 static 容器 |
一次启动服务多个测试方法 | 方法间要自己做数据隔离 |
| 只起必要的依赖 | 不因为"以后可能用到"起 ES、Redis | 需要按测试类拆上下文 |
| 慢测试单独分组 | 单测快速反馈,集成测试按需跑 | 需要 @Tag + failsafe |
| 镜像预热 / 本地 registry | 省掉拉镜像开销 | 需要 CI 缓存 |
配置上让 failsafe 只收 **/*IT.java、绑定 <groups>integration</groups>,surefire 保持只跑 *Test:快测试与慢测试就此分家。
5. 数据准备的三种方式
| 方式 | 做法 | 适用 | 风险 |
|---|---|---|---|
| 事务回滚 | 测试方法加 @Transactional,结束回滚 |
同进程、同数据源 | 跨进程时完全失效(见第 6 节) |
| 清库重建 | TRUNCATE 全表或重建 schema |
集成测试、消息消费 | 外键顺序、自增重置、耗时 |
| 最小数据 | 每个用例只插它需要的那几行 | 大多数场景,推荐 | 需要唯一键避免跨用例冲突 |
@BeforeEach
void clean() {
jdbc.execute("SET FOREIGN_KEY_CHECKS = 0");
for (String t : List.of("order_item", "orders", "customer")) {
jdbc.execute("TRUNCATE TABLE " + t);
}
jdbc.execute("SET FOREIGN_KEY_CHECKS = 1");
}
清库顺序按外键依赖倒序,或临时关外键检查(MySQL 会话级即可)。TRUNCATE 重置自增,别断言具体 ID。
最小数据的要点是业务键唯一(订单号拼 UUID 后缀),这样并行、跨类都互不干扰。
6. 事务回滚为什么在跨进程下失效
@SpringBootTest(webEnvironment = RANDOM_PORT)
@Transactional // 只回滚测试线程持有的那条连接
class OrderApiIT {
@Test
void create_order_should_be_visible_to_other_request() {
// 1) 走真实 HTTP:服务端自开事务并提交
restTemplate.postForEntity("/api/orders", body, String.class);
// 2) 再查:这条数据不在测试事务里,回滚管不到它
assertThat(repo.count()).isEqualTo(1); // 数据会残留
}
}
原因有三层:
- Spring 测试事务绑在测试线程的
DataSource连接上;服务端处理 HTTP 用的是另一个线程、另一条连接,提交与否它自己决定。 - 服务端事务早已
commit,测试侧回滚只是回滚自己的连接,落库的行不受影响。 - 即使同进程,
@Transactional也会掩盖"提交后才暴露"的问题:唯一键冲突时机、延迟约束、AFTER_COMMIT监听器、缓存失效。
结论:
@Transactional只适合仓库层、纯 JDBC 切片测试。走 HTTP、MQ 或独立线程,就必须显式清理数据。
7. 迁移脚本在测试里什么时候跑
| 环节 | 发生时机 | 影响 |
|---|---|---|
| 容器启动 | 只创建空库 | 没有任何表 |
| Spring 上下文初始化 | DataSource 就绪后 Flyway/Liquibase 执行 |
表结构与种子数据到位 |
| 每个测试方法 | 不自动执行 | 数据自己准备 |
@DynamicPropertySource
static void props(DynamicPropertyRegistry r) {
r.add("spring.flyway.locations", () -> "classpath:db/migration,classpath:db/testdata");
r.add("spring.flyway.clean-disabled", () -> "false");
}
两个实践点:测试种子数据单独放一个 location,别和生产迁移混在一起;需要重跑迁移的用例显式 flyway.clean() 再 migrate(),别指望上下文刷新——它默认被缓存。
8. 外部 HTTP 依赖:WireMock 两档用法
// 进程内:验证请求格式与超时
@RegisterExtension
static WireMockExtension wm = WireMockExtension.newInstance()
.options(wireMockConfig().dynamicPort())
.build();
@DynamicPropertySource
static void props(DynamicPropertyRegistry r) {
r.add("client.pricing.base-url", () -> wm.baseUrl());
}
@Test
void should_map_remote_503_to_degraded() {
wm.stubFor(get("/price/1").willReturn(aResponse().withStatus(503)));
assertThat(pricingClient.quote(1L).state()).isEqualTo(DEGRADED);
}
要验证真实网络栈(keep-alive 复用、TLS、gzip)时,用容器化 wiremock/wiremock 镜像,让被测服务走容器网络别名访问。进程内测逻辑,容器版测链路。
9. 隔离与并行
| 冲突源 | 症状 | 处理 |
|---|---|---|
| 端口 | 并行时 Address already in use |
一律 withExposedPorts 不给固定宿主端口,用 getMappedPort |
| 数据库名 | 库已存在 / 数据串台 | withDatabaseName("it_" + UUID) |
| 容器名 | Conflict. The container name is already in use |
只有 withReuse 才固定名字,且本地单实例 |
| 共享静态容器 + 并行方法 | 偶发唯一键冲突、断言数不对 | 并行只跑方法级只读用例,或每类独立容器 |
| 时区 | 容器 UTC、断言用本地时间 | 断言里用 ZoneOffset.UTC,或容器设 TZ |
# src/test/resources/junit-platform.properties
junit.jupiter.execution.parallel.enabled=true
junit.jupiter.execution.parallel.mode.classes.default=concurrent
junit.jupiter.execution.parallel.mode.methods.default=same_thread
类间并行、方法内串行,是容器化集成测试比较稳的档位:多上下文同时起,同一张表不被踩。
10. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
Could not find a valid Docker environment |
无 Docker、socket 不可达、CI 无特权 | 检查 socket;无 Docker 环境用 @Testcontainers(disabledWithoutDocker = true) |
| 拉镜像卡住后超时 | 仓库不可达或网络策略限制 | 配镜像加速 / 预拉镜像 / 私有 registry |
| 上下文启动超时 | 容器慢于默认超时 | 提高 spring.test.context.timeout 或预热镜像 |
| 偶发唯一键冲突 | 上一轮数据残留 | 关复用;每类独立库名;按外键倒序清理 |
| 时间相关断言跨时区失败 | 容器内时区与宿主不同 | 统一 UTC,或容器 TZ 显式设置 |
| 本地全绿 CI 全红 | 复用容器、镜像 tag 漂移、并行档位不同 | CI 关复用、固定 mysql:8.4 这类明确 tag |
| 看不出容器为何退出 | 容器已被删除 | 失败时 container.getLogs() 落盘 |
排查顺序:容器日志 → 上下文启动日志 → SQL。多数"测试挂了"是依赖没起来,不是断言写错。
11. 边界:这些不该塞进容器化集成测试
| 场景 | 更合适的方式 | 理由 |
|---|---|---|
| 纯规则、分支覆盖 | 单元测试 | 容器只增加反馈延迟 |
| 每天每次提交都跑 | 单元测试 + 少量切片测试 | 端到端链路过重,会被人绕过 |
| 性能与容量结论 | 独立压测环境 | 容器资源受限,数字不可外推 |
| 前端交互 | E2E 框架 | 容器测不到浏览器行为 |
一条经验:规模控制在"每个跨进程契约一条主路径 + 关键失败分支",别把单测用例表复制一遍。数量上去后,团队会用"跳过集成测试"换速度,那时它等于不存在。
12. 自测
- 判断该不该起容器的那一条判据是什么?
@Container字段加不加static,生命周期差别在哪?withReuse(true)在 CI 上为什么应当关掉?@Transactional测试回滚为什么管不到服务端已经提交的数据?- 容器里没有表,是谁在什么时候建的?
- 并行跑集成测试,端口、库名、容器名各怎么处理?