2569 字
约 8 分钟
15
S16-X15 · 集成测试:容器化依赖与数据准备
2024-12-31
2026-11-09

单元测试证明不了"这段 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. 自测

  1. 判断该不该起容器的那一条判据是什么?
  2. @Container 字段加不加 static,生命周期差别在哪?
  3. withReuse(true) 在 CI 上为什么应当关掉?
  4. @Transactional 测试回滚为什么管不到服务端已经提交的数据?
  5. 容器里没有表,是谁在什么时候建的?
  6. 并行跑集成测试,端口、库名、容器名各怎么处理?
S16-X15 · 集成测试:容器化依赖与数据准备
https://www.clxhxhhr.top/posts/5965/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。