Skip to content

Spring 集成测试与 Testcontainers 概念 ​

T1-017必学高级约 90 分钟JDK 21测试专项 A:单元测试 · Spring 测试

1. 本节定位 ​

《Spring 切片测试与 MockMvc》 把 Service 换成了 mock。本节要把真实的 Service、真实的存储、真实的容器都拉起来:

text
切片测试:  MockMvc  →  DispatcherServlet  →  Controller  →  [mock 的 Service]
集成测试:  真实 HTTP →  真实 Tomcat  →  DispatcherServlet  →  Controller  →  真实 Service  →  真实存储
                          ↑ 本节从这里开始是真的

测的东西也变了:切片测试测"Web 这一层对不对";集成测试测**"这些层接在一起对不对"**。

2. 学习目标 ​

完成本节后,你应该能够:

  • 说出 @SpringBootTest 与 @WebMvcTest 的本质区别;
  • 说出 webEnvironment 的四种取值及各自用途;
  • 用 TestRestTemplate 写真实 HTTP 的测试;
  • 说出 Spring 测试上下文缓存机制,并用实测数据说明它的价值;
  • 说出"切片测试不一定比集成测试快"的原因;
  • 说出 Testcontainers 解决的问题,以及它与 H2 的取舍;
  • 说出 @Transactional 在 RANDOM_PORT 下为什么不生效,并给出替代的隔离策略。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

切片测试的三个盲区(T1-016 的 5.6 节):

盲区切片测试为什么测不到
Service 的真实逻辑被 mock 了
跨层协作(Controller → Service → 存储)中间是假的
真实容器行为(HTTP 协议、超时、编码协商)MockMvc 不经过 Tomcat

最关键的是第二条:每个层单独测都通过,接起来却可能不通——

java
// Controller:把 address 传给了 Service
orderService.create(request);

// Service:从 request 里读的是 shippingAddress(字段名对不上)
String address = request.shippingAddress();     // ← 永远是 null

两个类各自的单元测试都能过(Controller 的测试 mock 了 Service;Service 的测试自己 new 了 request)✗

只有集成测试会抓到它 ✓ ——因为它是"接缝处"的问题。

5. 核心概念 ​

5.1 @SpringBootTest 与 webEnvironment ​

java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class OrderApiIntegrationTest {

    @Autowired
    private TestRestTemplate restTemplate;      // 自动指向那个随机端口

    @Test
    void should_create_order() {
        ResponseEntity<OrderResponse> response = restTemplate.postForEntity(
                "/api/orders",
                new CreateOrderRequest("P-1", 2, "上海市浦东新区"),
                OrderResponse.class);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
    }
}

@SpringBootTest 做的事:启动一个完整的 Spring 应用上下文(和 SpringApplication.run 几乎一样)——所有 Bean 都创建,自动配置全部生效。

webEnvironment 的四种取值:

取值行为配什么用
MOCK(默认)不启动真实服务器,提供 mock 的 Servlet 环境配合 MockMvc 用
RANDOM_PORT启动真实内嵌容器,随机端口真实 HTTP 测试(推荐)
DEFINED_PORT启动真实容器,用配置里的端口需要固定端口(容易与本地服务冲突)
NONE不启动 Web 环境只测 Service / Repository

为什么推荐 RANDOM_PORT 而不是 DEFINED_PORT:

问题DEFINED_PORT 的麻烦
端口冲突本机已经开着应用(如本项目的 18080)→ 测试起不来 ✗
并行测试两个测试类不能同时跑
CI 环境端口可能被别的任务占用

RANDOM_PORT 把端口交给操作系统选——测试之间天然隔离 ✓

TestRestTemplate 的小便利:它自动指向随机端口,所以测试里可以写相对路径:

java
restTemplate.getForEntity("/api/orders/ORD-1", OrderResponse.class);
//                      ↑ 不用写 http://localhost:随机端口

如果要用 WebTestClient(响应式)或原始 RestClient,需要自己拼端口:

java
@LocalServerPort
private int port;
// 然后 "http://localhost:" + port + "/api/orders"

5.2 上下文缓存:Spring 测试最值钱的机制 ​

问题:每次跑测试都启动一遍 Spring 容器?那测试套件会慢到没人跑。

Spring 的解法:把启动过的上下文缓存起来,配置相同的测试类直接复用。

java
// 这个类先跑:启动容器,付出全部成本
@SpringBootTest(webEnvironment = RANDOM_PORT)
class OrderApiIntegrationTest { ... }

// 这个类注解完全相同:复用上面那个上下文,几乎不花时间
@SpringBootTest(webEnvironment = RANDOM_PORT)
class OrderQueryIntegrationTest { ... }

实测数据(本项目真实结果):

测试类测试数Time elapsed说明
OrderApiIntegrationTest44.038 s第一个用该配置的类:启动容器
OrderControllerSliceTest718.44 s不同的配置(切片),另起一个上下文
OrderQueryIntegrationTest20.057 s复用上下文

4.038 s 与 0.057 s 的差距是约 70 倍——而它们的注解、测试内容量级都差不多。

两者差的全部就是"要不要启动一个 Spring 容器"。

"配置相同"指什么(任意一项不同,就会新建上下文):

维度例子
测试注解@WebMvcTest vs @SpringBootTest
webEnvironmentMOCK vs RANDOM_PORT
@MockitoBean 的字段类 A mock 了 OrderService,类 B 没有
@TestPropertySource / properties属性不同
@ActiveProfilesprofile 不同
@Import / @ContextConfiguration引入的配置不同
@DirtiesContext主动标记"用完就扔"

这解释了一个实用的优化原则:

把配置相同的测试归到一起(同一个属性、同一组 mock),能让它们共享一个上下文。

测试类的组织方式,会直接影响测试套件的总耗时。

想看到缓存的实际行为:

properties
# src/test/resources/application.properties
logging.level.org.springframework.test.context.cache=DEBUG

日志里会看到 Reusing cached ApplicationContext 或 Creating new ApplicationContext ✓ 这是排查"为什么测试套件这么慢"的第一手工具。

⚠️ 缓存的代价:上下文被复用,意味着 Bean 的状态也被复用——如果一个测试改了单例 Bean 的状态,后面的测试会受影响(这就是"测试污染")✗ 见 5.5 节。

5.3 "切片测试更快"是个误解 ​

《Spring 切片测试与 MockMvc》 的实测结论要在这里说完整:

text
切片测试(7 个): 18.44 s
集成测试(4 个):  4.038 s

切片测试反而慢 4 倍多 ✗

为什么?

原因说明
成本在"启动上下文",不在"Bean 的数量"MVC 切片要处理大量自动配置的筛选(@WebMvcTest 内部要排除几十个自动配置类、扫描类型过滤器)
切片的上下文无法与集成测试共享配置不同 → 各起一个 → 总成本叠加
切片测试用 mock,mock 需要字节码增强首次初始化 Mockito/Byte Buddy 是一笔额外开销(见 5.4 节)
本机波动很大同一份代码两次运行可能是 13.62 s 与 19.69 s

那切片测试的价值是什么?

价值说明
聚焦只加载一层,失败时不用猜是哪一层的问题
隔离不受数据库、外部服务、其他 Bean 的影响
能测到"框架在这些配置下的行为"如 @Valid 是否生效、异常处理器是否配对
可以在没有数据库/网络的 CI 上跑依赖面小

切片测试买的是"聚焦与隔离",不是"速度"。

如果你的项目里"切片测试比集成测试还慢",那很正常——特别是当集成测试没有外部依赖的时候。

什么时候集成测试会明显更慢:当它需要外部依赖(数据库、Redis、消息队列)的时候 ✓ 那时候启动容器的时间反而是小头。

5.4 一个真实事故:Byte Buddy 打挂了一整类测试 ​

本节编写过程中发生的真实情况(值得完整记录):

现象:第一次运行三个测试类

text
OrderControllerSliceTest(切片):  Tests run: 7, Errors: 7     ← 全挂
OrderApiIntegrationTest(集成):  Tests run: 4, Failures: 0   ← 全过

报错:

text
Caused by: org.mockito.exceptions.base.MockitoException:
    Could not modify all classes [class cn.kb.learn.web.service.OrderService, class java.lang.Object]
Caused by: java.lang.IllegalArgumentException:
    Java 25 (69) is not supported by the current version of Byte Buddy
    which officially supports Java 24 (68)
    - update Byte Buddy or set net.bytebuddy.experimental as a VM property

根因:Mockito 依赖 Byte Buddy 做字节码增强,而它只支持到 Java 24,本机是 JDK 25 ✗

为什么集成测试没事:

测试用 mock 吗加载 Byte Buddy 吗结果
切片测试用(@MockitoBean)是全挂 ✗
集成测试不用否全过 ✓

同一个环境问题,只打中了"依赖 mock"的那一类测试。

这就是"测试的依赖面越小,受环境变化影响越小"的反面:依赖 mock 框架,就把 mock 框架的兼容性风险也一起引入了。

修复(在 pom.xml 里显式升级):

xml
<dependency>
  <groupId>net.bytebuddy</groupId>
  <artifactId>byte-buddy</artifactId>
  <version>1.17.6</version>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>net.bytebuddy</groupId>
  <artifactId>byte-buddy-agent</artifactId>
  <version>1.17.6</version>
  <scope>test</scope>
</dependency>

另一种修复:加 JVM 参数让 Byte Buddy 忽略版本检查:

text
-Dnet.bytebuddy.experimental=true

对比:

方式优点缺点
升级 Byte Buddy真正解决问题需确认新版与 Mockito 兼容
-Dnet.bytebuddy.experimental=true一行搞定"experimental"意味着未经验证——可能踩到别的坑

⚠️ 这个坑在本项目已经踩了第二次(T1-010 期间在 testing-unit 模块里遇到过同样的问题)。

教训:"升级 JDK 时,字节码工具链(Byte Buddy / ASM / CGLIB / Lombok)通常最先出问题"——它们要解析新版本 class 文件的字节码格式。

而且这类问题往往是"某个模块先炸",不是"全局炸"——因为只有用到它的模块才会加载它。

5.5 测试数据隔离 ​

集成测试的上下文是共享的(5.2 节),所以数据也是共享的 ✗

本项目的实测体现:

java
// OrderApiIntegrationTest 里创建并删除了订单
// 那么 OrderQueryIntegrationTest 跑的时候,存储里有多少条数据?
// 答案:取决于前一个测试跑了什么、有没有清理

所以断言不能依赖具体条数:

java
// ✗ 脆弱:依赖别的测试留下的数据
assertThat(page.total()).isEqualTo(1);

// ✓ 稳健:只断言"结构"与"边界"
assertThat(page.total()).isGreaterThanOrEqualTo(0);

四种隔离策略:

策略做法适用注意
不依赖具体数据只断言结构与不变量任何场景(首选)需要刻意设计断言
自己创建、自己清理@BeforeEach 造数据、@AfterEach 删数据需要确定数据时清理要可靠(用 try/finally)
@Sql每个测试前执行一段 SQL 重置数据数据库测试需要维护 SQL 脚本
@DirtiesContext用完就扔上下文最后手段会毁掉缓存,整套测试变慢 ✗
@Transactional测试结束回滚只适用于 MOCK 环境⚠️ 见下

⚠️ @Transactional 在 RANDOM_PORT 下不生效(这是最经典的踩坑点):

java
@SpringBootTest(webEnvironment = RANDOM_PORT)
@Transactional                                  // ✗ 加了也没用
class MyIntegrationTest { }

原因:

text
测试线程:开启事务 → 发 HTTP 请求(返回)
                    ↓
服务端线程:Tomcat 的另一个线程处理请求 → 它的事务是独立的
                    ↓
测试线程:回滚 —— 但服务端写的数据早就提交了

事务是"线程绑定"的(《Spring:AOP 与事务》第 5.3 节讲过 ThreadLocal)——测试线程的事务管不到 Tomcat 线程 ✗

@Transactional 什么时候有用:webEnvironment = MOCK + MockMvc(同一个线程)时 ✓ 或者直接测 Service 层(没有 HTTP)✓

结论:用 RANDOM_PORT 做集成测试时,别指望 @Transactional 帮你回滚——要么自己清理,要么用 Testcontainers 每次起个干净的库。

5.6 Testcontainers:让集成测试连真实的数据库 ​

问题:集成测试要连数据库时,用什么?

方案优点缺点
H2(内存库)快、无需额外环境与生产数据库行为不一致 ✗
本机已装的数据库真实依赖开发者本机环境 ✗ CI 难复现
Testcontainers真实数据库、每次干净、可复现需要 Docker

H2 的"不一致"具体在哪(《SQL 基础》 讲过):

差异点例子
SQL 方言LIMIT 的写法、函数名
数据类型自增、JSON 类型、ENUM
约束行为唯一索引的报错信息、外键的级联规则
事务隔离默认隔离级别不同
执行计划H2 上很快的 SQL,在 MySQL 上可能是全表扫描 ✗

Testcontainers 的做法:

java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class OrderRepositoryIntegrationTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
            .withDatabaseName("testdb")
            .withUsername("test")
            .withPassword("test");

    @DynamicPropertySource                      // 把容器的连接信息注入 Spring 配置
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }
}

三个注解的作用:

注解作用
@Testcontainers开启容器生命周期管理
@Container声明一个容器(静态字段 = 整个类共用一个)
@DynamicPropertySource把容器的动态连接串塞进 Spring 配置(端口是随机的,不能写死)

Testcontainers 的代价:

代价说明
需要 Docker开发机、CI 都要有
首次启动慢拉镜像可能几分钟(之后有缓存)
资源占用每个容器几百 MB 内存
镜像版本要与生产一致否则又回到"H2 的老问题"

一个务实的选择顺序:

text
1. 能用纯单元测试 + mock 覆盖的  →  用单元测试
2. 需要验证 SQL / 约束 / 方言     →  Testcontainers(真实数据库)
3. 只是想测 Repository 的 CRUD    →  @DataJpaTest + H2(够用,但要清楚差异)
4. 绝不要以为"H2 过了就等于 MySQL 过了"

本项目的实操 ✓✓:labs/testing-unit/src/test/java/cn/kb/learn/testing/LibraryPgContainerTest.java —— 用 @Container PostgreSQLContainer 在真实 PostgreSQL 上验证项目 A 的三条约束 (CHECK 拒绝「可借 > 总量」、唯一索引拒绝重复 ISBN、IDENTITY 主键递增)。 默认 mvn test 不执行它(需要容器引擎),执行方式与环境准备见第 10 节 ✓。

为什么值得做:本项目在 PG 18 上实测过「H2 过了 ≠ PG 过」—— 事务内出错后 PG 拒绝后续查询(SQLSTATE 25P02)、H2 却允许继续 ✗✗。 这正是 Testcontainers 存在的理由 ✓✓✓。

5.7 什么时候该写集成测试 ​

别用"覆盖率"决定——用"风险"决定:

该写集成测试理由
跨层接缝(Controller → Service → 存储)单元测试看不见接缝
配置相关(事务、序列化、安全、拦截器)配置只在完整容器里生效
SQL 与约束需要真实数据库
关键业务流程的"happy path"一条端到端走通的测试,价值很高
Bug 修复的回归测试修完 bug 补一个——它防的是"下次又坏"

不该写集成测试:

不该写交给谁
每个分支、每个边界条件单元测试(快、定位准)
纯计算逻辑单元测试
参数校验的各种非法组合切片测试(T1-016 的参数化)

一条经验:

集成测试的数量应该是"业务流程的数量级",不是"代码分支的数量级"。

一个 20 个接口的系统,集成测试大概 10~30 个;而单元测试可能是几百个。

6. 递进示例 ​

6.1 最小示例:一个真实的往返 ​

java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class MinimalIntegrationTest {
    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    void should_return_200() {
        assertThat(restTemplate.getForEntity("/api/orders?page=1&size=1", String.class)
                .getStatusCode()).isEqualTo(HttpStatus.OK);
    }
}

6.2 业务示例:完整生命周期 ​

见第 7 节。

6.3 反例:用集成测试测分支 ​

java
@SpringBootTest(webEnvironment = RANDOM_PORT)
class OrderServiceBranchTest {
    // ✗ 用集成测试测"金额的 5 种计算分支"
    //   每个分支要启动一次 HTTP 请求 → 慢,且定位不到具体分支
}

问题:集成测试的成本高、定位差——用它测分支是"用大炮打蚊子" ✗

正确做法:

测什么用什么
5 种计算分支Service 的单元测试(毫秒级、失败直接指向分支)
"创建订单"这条链路走得通集成测试(1 个)

7. 完整可运行代码 ​

文件清单(labs/spring-boot-web/src/test/java/cn/kb/learn/web/):

text
OrderApiIntegrationTest.java        ← 完整生命周期 + 校验 + 业务冲突 + 分页
OrderQueryIntegrationTest.java      ← 上下文复用实证(只读查询)
controller/OrderControllerSliceTest.java   ← 上一节的切片测试

OrderApiIntegrationTest(4 个测试):

java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class OrderApiIntegrationTest {

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    @DisplayName("完整生命周期:创建 → 查询 → 删除 → 再查不到")
    void should_complete_full_lifecycle() {
        // 1. 创建
        ResponseEntity<OrderResponse> created = restTemplate.postForEntity(
                "/api/orders",
                new CreateOrderRequest("P-LIFE", 3, "上海市浦东新区"),
                OrderResponse.class);

        assertThat(created.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        assertThat(created.getHeaders().getLocation()).isNotNull();
        OrderResponse body = created.getBody();
        assertThat(body).isNotNull();
        assertThat(body.amountInCents()).isEqualTo(59_700L);          // 19900 × 3

        String orderNo = body.orderNo();

        // 2. 查询:真实存储里查得到
        ResponseEntity<OrderResponse> found = restTemplate.getForEntity(
                "/api/orders/" + orderNo, OrderResponse.class);
        assertThat(found.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(found.getBody().productId()).isEqualTo("P-LIFE");

        // 3. 删除
        ResponseEntity<Void> deleted = restTemplate.exchange(
                "/api/orders/" + orderNo, HttpMethod.DELETE, null, Void.class);
        assertThat(deleted.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);

        // 4. 再查:应该 404(这验证了"删除真的生效了")
        ResponseEntity<String> afterDelete = restTemplate.getForEntity(
                "/api/orders/" + orderNo, String.class);
        assertThat(afterDelete.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
        assertThat(afterDelete.getBody()).contains("RESOURCE_NOT_FOUND");
    }

    @Test
    @DisplayName("校验失败返回 422,且 details 指出两个字段")
    void should_return_422_when_validation_fails() { ... }

    @Test
    @DisplayName("业务冲突返回 409:商品已下架")
    void should_return_409_when_business_conflict() { ... }

    @Test
    @DisplayName("分页接口返回统一结构:data / page / size / total / totalPages")
    void should_return_paged_structure() { ... }
}

运行命令:

powershell
cd labs
./mvnw -pl spring-boot-web test

预期输出:

text
[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 18.44 s -- in OrderControllerSliceTest
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed:  4.038 s -- in OrderApiIntegrationTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed:  0.057 s -- in OrderQueryIntegrationTest

[INFO] Tests run: 13, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

这三个耗时数字,就是本节的全部论证:

测试类耗时说明
OrderControllerSliceTest(7 个)18.44 s切片:另起一个上下文,且首次初始化 mock 框架
OrderApiIntegrationTest(4 个)4.038 s集成:启动完整容器(这一步就是全部成本)
OrderQueryIntegrationTest(2 个)0.057 s集成:复用上下文——启动成本为零

三条推论:

  1. "切片测试更快"不成立——切片测试 18.44 s,反而比集成测试慢;
  2. 成本在"启动上下文",不在"跑测试"——0.057 s 证明了这一点(同样的真实 HTTP 请求,只是容器已经在了);
  3. 组织测试类的顺序会影响总耗时——相同配置的类挨在一起跑,能共享上下文 ✓

⚠️ 诚实提醒:这几个数字本机波动很大。同一份代码的四次运行:

运行切片测试集成(首次)集成(复用)
第 1 次13.62 s3.236 s—
第 2 次19.69 s7.438 s—
第 3 次18.44 s4.038 s0.057 s
第 4 次11.58 s2.911 s0.034 s

要看的不是绝对值,而是"数量级差异":首次启动 2~4 秒 vs 复用后 0.03~0.06 秒——约 70~90 倍的差距,在任何一次运行里都成立 ✓

8. Debug 观察点 ​

  1. 打开上下文缓存日志:
properties
logging.level.org.springframework.test.context.cache=DEBUG

观察:OrderApiIntegrationTest 处是 Creating new ApplicationContext,OrderQueryIntegrationTest 处是 Reusing cached ApplicationContext ✓

  1. 在 WebApplication.main 处打断点:跑集成测试时会命中(容器真的启动了);跑切片测试时不会命中(切片不走 main)——这是"两种测试启动方式不同"的直接证据;

  2. 在 OrderService 的构造器处打断点:集成测试会命中(真的创建了 Service);切片测试不会(是 mock);

  3. 观察真实端口:在测试里打印 @LocalServerPort 的值,多跑几次——每次都不一样(RANDOM_PORT 的含义);

  4. 验证 @Transactional 失效:给集成测试类加上 @Transactional,观察数据仍然留在存储里(下一个测试能查到)——这是"事务绑定线程"的现场。

9. 常见错误 ​

错误 1:以为 @Transactional 能回滚集成测试的数据 ​

见 5.5 节。RANDOM_PORT 下不生效 ✗

修复:自己清理数据,或让断言不依赖具体数据。

错误 2:滥用 @DirtiesContext ​

java
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)      // ✗ 每跑一次就扔

症状:测试套件从 20 秒变成几分钟 ✗

原因:它把上下文缓存毁掉了——每个类都要重新启动容器。

什么时候才该用:测试确实破坏了上下文状态,且无法用其他方式隔离(很少见)✓

更好的替代:

想解决的问题更好的做法
数据污染不依赖具体数据 / 自己清理
单例 Bean 状态被改把状态改成不可变,或用 @DirtiesContext(classMode = BEFORE_CLASS) 缩小影响面
配置不同分成不同的测试类(本来就该分)

错误 3:DEFINED_PORT 撞端口 ​

java
@SpringBootTest(webEnvironment = DEFINED_PORT)         // ✗ 用 application.yml 里的 18080

症状:本机开着应用时,测试报 Port 18080 was already in use ✗

修复:用 RANDOM_PORT ✓

错误 4:集成测试里断言"总数恰好是 1" ​

java
assertThat(page.total()).isEqualTo(1);                 // ✗ 依赖别的测试留下的数据

症状:单独跑这个类能过,全量跑就失败(或反过来)—— 经典的"测试顺序依赖" ✗(违反 T1-007 的 I = Independent)

修复:

java
assertThat(page.total()).isGreaterThanOrEqualTo(1);    // ✓ 只断言"至少有"

或者在自己的测试里创建所需数据 ✓

错误 5:用集成测试测分支 ​

见 6.3 节。代价高、定位差。

错误 6:升级 JDK 后 mock 相关的测试突然全挂 ​

见 5.4 节。记住这个排查方向:

text
所有"用 mock 的测试"一起挂,且报错里有 Byte Buddy / ASM / CGLIB
→ JDK 版本超出字节码库支持范围
→ 升级字节码库,或加 -Dnet.bytebuddy.experimental=true

顺带记住:"只有一部分测试挂"是有信息量的——它指出了依赖面在哪里 ✓

10. 动手实验 ​

目标:亲手测出上下文缓存的威力。

步骤:

  1. 运行第 7 节的命令,核对三个耗时数字(注意本机波动,多跑两次);
  2. 验证缓存:加一行配置并重新运行:
properties
logging.level.org.springframework.test.context.cache=DEBUG

在输出里搜 Reusing cached 与 Creating new,对应到具体的测试类;

  1. 破坏缓存做对比:给 OrderQueryIntegrationTest 加一个 @MockitoBean(随便 mock 一个用不到的 Bean)——观察它的耗时从 0.057 s 涨到几秒(因为配置变了,要新建上下文);
  2. 验证 @Transactional 失效:给 OrderApiIntegrationTest 加 @Transactional,然后跑两次测试——观察数据是否仍在;
  3. 制造端口冲突:把 webEnvironment 改成 DEFINED_PORT,同时让本机应用监听 18080,运行测试——观察报错;
  4. 测一个"接缝 bug":故意把 OrderService.create 里的 request.address() 改成读一个不存在的字段(如加一个 shippingAddress()),观察哪些测试会红(提示:只有集成测试会)✗

验收方式:能说出第 3 步耗时变化的原因(配置变了 → 缓存失效)。

动手实操:Testcontainers 真库验证(LibraryPgContainerTest)★★★

环境准备(二选一,测试代码完全相同 ✗✓,本库已在两种引擎上实测通过):

方案一:Docker Desktop方案二:Podman(本库采用)
安装安装后启动即可安装 Podman(含 CLI)→ podman machine init → podman machine start
额外配置无无(2026-09 Podman 6.1.2 Windows 实测):machine 启动后自动监听 npipe:////./pipe/docker_engine(Docker 默认管道),Testcontainers 零配置直连 ✓✓。⚠️ 唯一踩坑:Podman 6.1.2 在个别 Windows 机器上偶发/稳定崩溃(Go runtime 异步抢占 bug)——加环境变量 GODEBUG=asyncpreemptoff=1 后稳定运行
适用已有 Docker 的机器不能装/不想装 Docker Desktop 的机器(本机 WSL2 为前置)

执行(默认 mvn test 排除 tc 组,无引擎也不会红 —— 「跳过 ≠ 通过」,同 T2-018):

bash
cd labs
mvn test -pl testing-unit -Ptctest        # 只跑 Testcontainers 用例(3 个)

观察三件事 ✓:① 首次运行会拉 postgres:16-alpine 镜像(几分钟,之后有缓存); ② 三个约束测试在真实 PG 上通过 —— 与 H2 的通过不是一回事 ✓✓; ③ 默认 mvn test -pl testing-unit 仍是 88 个用例、约 12 秒 —— 引擎依赖没有拖慢日常反馈 ✓✓✓。

扩展任务:把 OrderQueryIntegrationTest 的两个测试并到 OrderApiIntegrationTest 里(合成一个类),观察套件总耗时的变化——理解"类数量"与"上下文数量"的关系。

11. 练习题 ​

基础题

  1. 判断:@SpringBootTest(webEnvironment = RANDOM_PORT) 会启动一个真实的 Tomcat。(对 / 错)
  2. 填空:Spring 测试的成本主要花在 ______;相同配置的测试类可以 ______。
  3. 简答:为什么 @Transactional 在 RANDOM_PORT 下不能回滚数据?

代码阅读题

java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ATest {
    @Autowired private TestRestTemplate restTemplate;

    @Test
    void t1() {
        restTemplate.postForEntity("/api/orders",
                new CreateOrderRequest("P-1", 1, "上海"), String.class);
    }
}

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class BTest {
    @Autowired private TestRestTemplate restTemplate;

    @Test
    void t2() {
        ResponseEntity<String> r = restTemplate.getForEntity("/api/orders?page=1&size=100", String.class);
        System.out.println(r.getBody());
    }
}
  1. 如果 ATest 先跑,BTest 的 t2 能查到 t1 创建的那条订单吗?为什么?
  2. 如果给 BTest 加上 @MockitoBean private OrderService orderService;,第 4 题的答案会变吗?

编程题

  1. 为 《参数校验与全局异常处理》 的订单接口写完整的集成测试:
    • 需求:覆盖四个接口的关键路径——
      • POST /api/orders 创建成功(201)
      • GET /api/orders/{orderNo} 查询刚创建的订单(200)
      • DELETE /api/orders/{orderNo} 删除(204)
      • GET /api/orders/{orderNo} 删除后再查(404)
    • 要求:
      • 用 TestRestTemplate,webEnvironment = RANDOM_PORT;
      • 断言不能依赖具体数据条数(用 isGreaterThanOrEqualTo 之类);
      • 用 AssertJ 的链式断言(T1-009);
      • 记录这个测试类的耗时,并与切片测试对比;
    • 回答:为什么这条"生命周期测试"不该用切片测试写?

综合任务

  1. 设计一个"订单系统"的完整测试策略:
    • 系统包含:OrderController、OrderService(含库存扣减、金额计算)、OrderRepository(MySQL)、一个外部"支付服务"客户端;
    • 要求:
      • 画出测试金字塔:标出每一层写多少个测试、测什么;
      • 列出至少 8 个应该写集成测试的场景,并说明各自防的是什么风险;
      • 列出至少 3 个"看起来像集成测试、其实该写成单元测试"的场景,并说明理由;
      • 说明"支付服务"这种外部依赖该怎么处理(提示:用 mock / 用契约测试 / 用沙箱环境);
      • 说明 Testcontainers 在这个项目里该用在哪一层、替代什么;
    • 额外要求:估算三个方案(纯单元、单元+切片、单元+切片+Testcontainers 集成)的CI 耗时量级,并说明你会选哪个——为什么。
分级提示

提示 1(方向):第 4 题的关键是"ATest 与 BTest 的注解完全相同"。

提示 2(关键点):第 5 题的关键是"加了 @MockitoBean 之后,配置还一样吗?"

提示 3(接近答案):第 7 题的"看起来像集成测试、其实该写单元测试",典型例子是"金额计算的五种折扣分支"——它不需要数据库也不需要 HTTP。

12. 参考答案 ​

基础题
  1. 对。RANDOM_PORT 会启动真实的内嵌容器(本项目是 Tomcat),并监听一个操作系统分配的随机端口。

对比 MOCK(默认):不启动容器,只提供 mock 的 Servlet 环境(配 MockMvc 用)。

  1. 启动 Spring 上下文(加载 Bean、执行自动配置);共享(复用)同一个上下文。

实测证据:本项目 OrderApiIntegrationTest 4.038 s(启动)→ OrderQueryIntegrationTest 0.057 s(复用)✓

  1. 因为事务是"线程绑定"的,而测试代码与服务端代码在两个不同的线程里:
text
测试线程 ──── 开启事务 T1 ──── 发出 HTTP 请求 ──── 等待响应 ──── 回滚 T1(什么也没滚到)
                                  │
                                  ▼
Tomcat 线程 ──────────────── 处理请求 ──── 自己的事务 T2 ──── 提交 T2 ✓(数据留下了)

关键:TestRestTemplate 发的是真实 HTTP,服务端由 Tomcat 的工作线程处理——测试线程的 ThreadLocal 事务上下文与它无关 ✓

对比 MOCK 环境:MockMvc 在同一个线程里"假装"发请求 → 事务是共享的 → @Transactional 回滚有效 ✓

这不是 @Transactional 的 bug,而是"线程绑定"这个设计的必然结果(《Spring:AOP 与事务》第 5.3 节)。

代码阅读题(第 4、5 题)

第 4 题:能查到。

原因:两个类的注解完全一致(@SpringBootTest(webEnvironment = RANDOM_PORT)),且都没有 @MockitoBean、没有不同的属性配置 → 它们共享同一个 Spring 上下文 → 也共享同一个 OrderService 实例 → 那个内存 ConcurrentHashMap 是同一个 ✓

所以 ATest.t1 创建的订单,BTest.t2 能查到 ✓

这正是 5.5 节讲的"数据污染":

后果说明
测试顺序会影响结果单独跑 BTest 与"ATest 后跑 BTest",结果不同
违反 T1-007 的 I 原则测试之间不独立
脆弱以后有人在 ATest 里改成"删除订单",BTest 就莫名其妙地红了

实践建议:别写这种"依赖另一个测试留下的数据"的测试 ✓ 每个测试自己准备数据。

第 5 题:会变。

原因:@MockitoBean 是"上下文配置"的一部分 → BTest 需要的上下文与 ATest 不再是同一个 → 新建一个上下文 ✗

结果:

  1. 缓存失效 → BTest 的耗时从 0.057 s 涨回几秒;
  2. 而且 BTest 里的 OrderService 是 mock → t2 查到的数据来自空的 mock(不是 ATest 写的那个存储)→ 查不到了 ✓

这一题揭示了两件事:

发现说明
解释"为什么加了 mock 就慢"缓存按配置匹配,配置变了就得重启容器
上下文缓存是"双刃剑"它让测试快,但也让"共享状态"成为可能

一个实践推论:测试类要不要加 @MockitoBean,会影响整个套件的耗时——所以别随手加"用不到的 mock" ✓(这与实验第 3 步要验证的完全一致)

编程题(第 6 题)
java
package cn.kb.learn.web;

import cn.kb.learn.web.dto.CreateOrderRequest;
import cn.kb.learn.web.dto.OrderResponse;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@DisplayName("订单接口集成测试:生命周期")
class OrderLifecycleIntegrationTest {

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    @DisplayName("创建 → 查询 → 删除 → 再查 404")
    void should_complete_lifecycle_over_real_http() {
        // Arrange & Act ①:创建
        ResponseEntity<OrderResponse> created = restTemplate.postForEntity(
                "/api/orders",
                new CreateOrderRequest("P-LIFE", 2, "上海市浦东新区"),
                OrderResponse.class);

        // Assert ①:状态码来自真实响应
        assertThat(created.getStatusCode())
                .as("创建应返回 201")
                .isEqualTo(HttpStatus.CREATED);
        assertThat(created.getHeaders().getLocation())
                .as("201 必须带 Location 头")
                .isNotNull();

        OrderResponse body = created.getBody();
        assertThat(body).isNotNull();
        String orderNo = body.orderNo();
        assertThat(orderNo).isNotBlank();

        // Act & Assert ②:查询
        ResponseEntity<OrderResponse> found = restTemplate.getForEntity(
                "/api/orders/" + orderNo, OrderResponse.class);
        assertThat(found.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(found.getBody()).isNotNull();
        assertThat(found.getBody().productId()).isEqualTo("P-LIFE");
        assertThat(found.getBody().quantity()).isEqualTo(2);

        // Act & Assert ③:删除
        ResponseEntity<Void> deleted = restTemplate.exchange(
                "/api/orders/" + orderNo, HttpMethod.DELETE, null, Void.class);
        assertThat(deleted.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
        assertThat(deleted.getBody()).as("204 不应有响应体").isNull();

        // Act & Assert ④:再查(这一步才真正证明"删除生效了")
        ResponseEntity<String> afterDelete = restTemplate.getForEntity(
                "/api/orders/" + orderNo, String.class);
        assertThat(afterDelete.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
        assertThat(afterDelete.getBody()).contains("RESOURCE_NOT_FOUND");
    }
}

为什么"生命周期测试"不该用切片测试写:

理由说明
切片测试里 Service 是 mock"删除之后再查不到"这件事完全测不到——mock 不会因为删除而"忘记"数据 ✗
生命周期本质是"状态的持久化"它验证的是"数据真的存下来了、真的删掉了"——这是存储层的职责
HTTP 语义需要真实协议204 无响应体、Location 头能否被再次访问,真实 HTTP 才有意义
跨层协作Controller → Service → 存储 三层一起工作,切片测试切断了中间

如果非要"用切片测试写生命周期",你只能写成:

java
// ✗ 这已经不是生命周期测试了,它只是"验证 Controller 调了 Service 两次"
given(orderService.create(any())).willReturn(sampleWith("ORD-1"));
given(orderService.find("ORD-1")).willReturn(sampleWith("ORD-1"));
given(orderService.find("ORD-1")).willThrow(new ResourceNotFoundException(...));   // 第三次要抛异常?

mockMvc.perform(post("/api/orders")...)...
mockMvc.perform(get("/api/orders/ORD-1"))...
mockMvc.perform(delete("/api/orders/ORD-1"))...
mockMvc.perform(get("/api/orders/ORD-1"))...

问题一目了然:你要自己"手工模拟"存储的状态变化(打桩的返回值要按调用顺序变化)✗

这已经不是在测代码,而是在测"你对 mock 的编排"— —T1-012 的自证预言 ✓

判断标准:如果一个测试需要"按调用顺序精心编排 mock",那它该换成集成测试。

耗时记录(本项目的实测参考):

测试类测试数耗时
OrderApiIntegrationTest44.038 s
OrderQueryIntegrationTest(复用上下文)20.057 s
切片测试(对照)718.44 s

注意:如果你的生命周期测试与前面的集成测试类配置相同,它也会复用上下文——只花几十毫秒 ✓

综合任务(第 7 题)

测试金字塔设计:

text
                    ┌─────────────────────┐
                    │  端到端(可选)       │  ~3 个
                    │  真实环境 + 浏览器     │  关键用户旅程
                    └─────────────────────┘
              ┌───────────────────────────────┐
              │  集成测试(@SpringBootTest)     │  ~15 个
              │  + Testcontainers(MySQL)      │  跨层接缝、SQL、事务
              └───────────────────────────────┘
        ┌─────────────────────────────────────────┐
        │  切片测试(@WebMvcTest)                  │  ~30 个
        │  路由、绑定、校验、异常响应                │
        └─────────────────────────────────────────┘
  ┌─────────────────────────────────────────────────────┐
  │  单元测试(JUnit + Mockito)                          │  ~200 个
  │  金额计算、库存规则、状态机、边界                     │
  └─────────────────────────────────────────────────────┘

8 个应该写集成测试的场景:

#场景防的风险
1下单成功:创建订单 → 库存扣减 → 落库跨层接缝(Controller 传参、事务边界)
2库存不足时整单回滚事务没有生效(《Spring:AOP 与事务》 的经典坑:自调用不代理)✗
3并发下单不会超卖悲观锁/乐观锁失效
4订单金额与明细一致计算与持久化的字段映射错位
5唯一约束冲突被正确翻译成 409数据库异常没有被全局处理器接住 → 500 ✗
6分页查询在真实 SQL 下正确H2 上过的 SQL 在 MySQL 上语义不同
7软删除的数据不会出现在查询里WHERE 条件漏写
8事务隔离级别下的可见性隔离级别配错(《数据库范式与表关联设计》)

3 个"看似集成测试、其实该写成单元测试"的场景:

#场景为什么该写成单元测试
1金额的五种折扣分支纯计算,不需要数据库也不需要 HTTP;用集成测试要发 5 次请求,且失败时看不出是哪个分支
2订单状态机的合法/非法转换纯逻辑判断(CREATED → PAID 可以,CANCELLED → PAID 不行)——枚举与分支的测试
3库存校验的各种边界(0 / 1 / 超库存)边界值逻辑,用参数化测试写一张表最清楚(T1-002)

判断标准:

"这个测试需要真实的数据库/HTTP 吗?还是我习惯性写了 @SpringBootTest?"

能被"一个输入 → 一个输出"表达清楚的逻辑,就该用单元测试。

"支付服务"这种外部依赖怎么处理:

方案做法优点缺点
Mock(最常用)在集成测试里 @MockitoBean 掉支付客户端快、可控不验证真实契约✗
契约测试用 Pact 之类的工具,把"我们的期望"与"对方的实现"对齐能发现对方改了契约需要双方配合
沙箱环境用支付方提供的测试环境最接近真实慢、不稳定、有配额
WireMock一个假的 HTTP 服务器,返回预设响应能测到真实的 HTTP 序列化需要维护桩数据

推荐组合:

text
日常 CI:   Mock 掉支付客户端(快)
专门的契约测试:验证"我们调用的接口签名没变"(独立流水线,频率低)
发版前:    在沙箱环境跑一遍关键流程(人工触发)

一句关键:Mock 掉外部服务意味着"支付这一环在集成测试里是假的"——所以必须有别的手段补上(契约测试或沙箱冒烟)✓ 别以为"集成测试全绿"就万事大吉 ✗

Testcontainers 该用在哪一层:

用法说明
替代 H2集成测试里连真实 MySQL/PostgreSQL——这是主要用途
替代"本机已装的数据库"让 CI 与本地环境一致
提供 Redis / Kafka 等任何有官方镜像的依赖

不该用的地方:单元测试与切片测试(它们本来就不该碰数据库)✓

CI 耗时估算(假设 200 单元 + 30 切片 + 15 集成 + 3 端到端):

方案耗时量级说明
纯单元10~30 秒毫秒级 × 200
单元 + 切片1~3 分钟切片上下文启动(每套配置几秒)× 若干
单元 + 切片 + 集成(Testcontainers)3~10 分钟容器拉取(有缓存后)+ 启动 + 15 个测试

我会选哪个:第三个,但要分层执行(呼应 T1-014 的分层理念):

时机跑什么目标时长
本地保存文件时只跑受影响的单元测试< 10 秒
提交前 / PR 推送单元 + 切片< 3 分钟
PR 合并前单元 + 切片 + 集成(含 Testcontainers)< 10 分钟
每晚 / 发版前全部 + 端到端不限

为什么:

反馈速度与覆盖范围成反比(T1-014 的结论)。

如果每次提交都要等 10 分钟,开发者就会开始绕过测试——测试的实际保护力反而下降。

分层不是妥协,而是让"该快的时候快、该全的时候全" ✓

13. 掌握标准 ​

  • [ ] 能说出 @SpringBootTest 与 @WebMvcTest 的本质区别;
  • [ ] 知道 webEnvironment 四种取值,并知道该用 RANDOM_PORT;
  • [ ] 会用 TestRestTemplate 写真实 HTTP 测试(且知道它能自动指向随机端口);
  • [ ] 能说出上下文缓存机制与它的实测效果(4.038 s → 0.057 s);
  • [ ] 知道"切片测试不一定更快"以及原因;
  • [ ] 知道 @Transactional 在 RANDOM_PORT 下不生效,会用自己的方式隔离数据;
  • [ ] 知道 Testcontainers 解决什么问题、与 H2 的取舍;
  • [ ] 知道"字节码库跟不上 JDK 版本"这类问题的排查方向;
  • [ ] 能判断"某个场景该写集成测试还是单元测试"。

14. 扩展资源 ​

15. 下一步学习 ​

单元测试专项(TA)到此完成。接下来可以:

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引