主题
界面
主题
《Spring 切片测试与 MockMvc》 把 Service 换成了 mock。本节要把真实的 Service、真实的存储、真实的容器都拉起来:
切片测试: MockMvc → DispatcherServlet → Controller → [mock 的 Service]
集成测试: 真实 HTTP → 真实 Tomcat → DispatcherServlet → Controller → 真实 Service → 真实存储
↑ 本节从这里开始是真的测的东西也变了:切片测试测"Web 这一层对不对";集成测试测**"这些层接在一起对不对"**。
完成本节后,你应该能够:
@SpringBootTest 与 @WebMvcTest 的本质区别;webEnvironment 的四种取值及各自用途;TestRestTemplate 写真实 HTTP 的测试;@Transactional 在 RANDOM_PORT 下为什么不生效,并给出替代的隔离策略。切片测试的三个盲区(T1-016 的 5.6 节):
| 盲区 | 切片测试为什么测不到 |
|---|---|
| Service 的真实逻辑 | 被 mock 了 |
| 跨层协作(Controller → Service → 存储) | 中间是假的 |
| 真实容器行为(HTTP 协议、超时、编码协商) | MockMvc 不经过 Tomcat |
最关键的是第二条:每个层单独测都通过,接起来却可能不通——
// Controller:把 address 传给了 Service
orderService.create(request);
// Service:从 request 里读的是 shippingAddress(字段名对不上)
String address = request.shippingAddress(); // ← 永远是 null两个类各自的单元测试都能过(Controller 的测试 mock 了 Service;Service 的测试自己 new 了 request)✗
只有集成测试会抓到它 ✓ ——因为它是"接缝处"的问题。
@SpringBootTest 与 webEnvironment @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 的小便利:它自动指向随机端口,所以测试里可以写相对路径:
restTemplate.getForEntity("/api/orders/ORD-1", OrderResponse.class);
// ↑ 不用写 http://localhost:随机端口如果要用 WebTestClient(响应式)或原始 RestClient,需要自己拼端口:
@LocalServerPort
private int port;
// 然后 "http://localhost:" + port + "/api/orders"问题:每次跑测试都启动一遍 Spring 容器?那测试套件会慢到没人跑。
Spring 的解法:把启动过的上下文缓存起来,配置相同的测试类直接复用。
// 这个类先跑:启动容器,付出全部成本
@SpringBootTest(webEnvironment = RANDOM_PORT)
class OrderApiIntegrationTest { ... }
// 这个类注解完全相同:复用上面那个上下文,几乎不花时间
@SpringBootTest(webEnvironment = RANDOM_PORT)
class OrderQueryIntegrationTest { ... }实测数据(本项目真实结果):
| 测试类 | 测试数 | Time elapsed | 说明 |
|---|---|---|---|
OrderApiIntegrationTest | 4 | 4.038 s | 第一个用该配置的类:启动容器 |
OrderControllerSliceTest | 7 | 18.44 s | 不同的配置(切片),另起一个上下文 |
OrderQueryIntegrationTest | 2 | 0.057 s | 复用上下文 |
4.038 s 与 0.057 s 的差距是约 70 倍——而它们的注解、测试内容量级都差不多。
两者差的全部就是"要不要启动一个 Spring 容器"。
"配置相同"指什么(任意一项不同,就会新建上下文):
| 维度 | 例子 |
|---|---|
| 测试注解 | @WebMvcTest vs @SpringBootTest |
webEnvironment | MOCK vs RANDOM_PORT |
@MockitoBean 的字段 | 类 A mock 了 OrderService,类 B 没有 |
@TestPropertySource / properties | 属性不同 |
@ActiveProfiles | profile 不同 |
@Import / @ContextConfiguration | 引入的配置不同 |
@DirtiesContext | 主动标记"用完就扔" |
这解释了一个实用的优化原则:
把配置相同的测试归到一起(同一个属性、同一组 mock),能让它们共享一个上下文。
测试类的组织方式,会直接影响测试套件的总耗时。
想看到缓存的实际行为:
# src/test/resources/application.properties
logging.level.org.springframework.test.context.cache=DEBUG日志里会看到 Reusing cached ApplicationContext 或 Creating new ApplicationContext ✓ 这是排查"为什么测试套件这么慢"的第一手工具。
⚠️ 缓存的代价:上下文被复用,意味着 Bean 的状态也被复用——如果一个测试改了单例 Bean 的状态,后面的测试会受影响(这就是"测试污染")✗ 见 5.5 节。
《Spring 切片测试与 MockMvc》 的实测结论要在这里说完整:
切片测试(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、消息队列)的时候 ✓ 那时候启动容器的时间反而是小头。
本节编写过程中发生的真实情况(值得完整记录):
现象:第一次运行三个测试类
OrderControllerSliceTest(切片): Tests run: 7, Errors: 7 ← 全挂
OrderApiIntegrationTest(集成): Tests run: 4, Failures: 0 ← 全过报错:
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 里显式升级):
<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 忽略版本检查:
-Dnet.bytebuddy.experimental=true对比:
| 方式 | 优点 | 缺点 |
|---|---|---|
| 升级 Byte Buddy | 真正解决问题 | 需确认新版与 Mockito 兼容 |
-Dnet.bytebuddy.experimental=true | 一行搞定 | "experimental"意味着未经验证——可能踩到别的坑 |
⚠️ 这个坑在本项目已经踩了第二次(T1-010 期间在 testing-unit 模块里遇到过同样的问题)。
教训:"升级 JDK 时,字节码工具链(Byte Buddy / ASM / CGLIB / Lombok)通常最先出问题"——它们要解析新版本 class 文件的字节码格式。
而且这类问题往往是"某个模块先炸",不是"全局炸"——因为只有用到它的模块才会加载它。
集成测试的上下文是共享的(5.2 节),所以数据也是共享的 ✗
本项目的实测体现:
// OrderApiIntegrationTest 里创建并删除了订单
// 那么 OrderQueryIntegrationTest 跑的时候,存储里有多少条数据?
// 答案:取决于前一个测试跑了什么、有没有清理所以断言不能依赖具体条数:
// ✗ 脆弱:依赖别的测试留下的数据
assertThat(page.total()).isEqualTo(1);
// ✓ 稳健:只断言"结构"与"边界"
assertThat(page.total()).isGreaterThanOrEqualTo(0);四种隔离策略:
| 策略 | 做法 | 适用 | 注意 |
|---|---|---|---|
| 不依赖具体数据 | 只断言结构与不变量 | 任何场景(首选) | 需要刻意设计断言 |
| 自己创建、自己清理 | @BeforeEach 造数据、@AfterEach 删数据 | 需要确定数据时 | 清理要可靠(用 try/finally) |
@Sql | 每个测试前执行一段 SQL 重置数据 | 数据库测试 | 需要维护 SQL 脚本 |
@DirtiesContext | 用完就扔上下文 | 最后手段 | 会毁掉缓存,整套测试变慢 ✗ |
@Transactional | 测试结束回滚 | 只适用于 MOCK 环境 | ⚠️ 见下 |
⚠️ @Transactional 在 RANDOM_PORT 下不生效(这是最经典的踩坑点):
@SpringBootTest(webEnvironment = RANDOM_PORT)
@Transactional // ✗ 加了也没用
class MyIntegrationTest { }原因:
测试线程:开启事务 → 发 HTTP 请求(返回)
↓
服务端线程:Tomcat 的另一个线程处理请求 → 它的事务是独立的
↓
测试线程:回滚 —— 但服务端写的数据早就提交了事务是"线程绑定"的(《Spring:AOP 与事务》第 5.3 节讲过 ThreadLocal)——测试线程的事务管不到 Tomcat 线程 ✗
@Transactional 什么时候有用:webEnvironment = MOCK + MockMvc(同一个线程)时 ✓ 或者直接测 Service 层(没有 HTTP)✓
结论:用 RANDOM_PORT 做集成测试时,别指望 @Transactional 帮你回滚——要么自己清理,要么用 Testcontainers 每次起个干净的库。
问题:集成测试要连数据库时,用什么?
| 方案 | 优点 | 缺点 |
|---|---|---|
| H2(内存库) | 快、无需额外环境 | 与生产数据库行为不一致 ✗ |
| 本机已装的数据库 | 真实 | 依赖开发者本机环境 ✗ CI 难复现 |
| Testcontainers | 真实数据库、每次干净、可复现 | 需要 Docker |
H2 的"不一致"具体在哪(《SQL 基础》 讲过):
| 差异点 | 例子 |
|---|---|
| SQL 方言 | LIMIT 的写法、函数名 |
| 数据类型 | 自增、JSON 类型、ENUM |
| 约束行为 | 唯一索引的报错信息、外键的级联规则 |
| 事务隔离 | 默认隔离级别不同 |
| 执行计划 | H2 上很快的 SQL,在 MySQL 上可能是全表扫描 ✗ |
Testcontainers 的做法:
@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 的老问题" |
一个务实的选择顺序:
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 存在的理由 ✓✓✓。
别用"覆盖率"决定——用"风险"决定:
| 该写集成测试 | 理由 |
|---|---|
| 跨层接缝(Controller → Service → 存储) | 单元测试看不见接缝 |
| 配置相关(事务、序列化、安全、拦截器) | 配置只在完整容器里生效 |
| SQL 与约束 | 需要真实数据库 |
| 关键业务流程的"happy path" | 一条端到端走通的测试,价值很高 |
| Bug 修复的回归测试 | 修完 bug 补一个——它防的是"下次又坏" |
不该写集成测试:
| 不该写 | 交给谁 |
|---|---|
| 每个分支、每个边界条件 | 单元测试(快、定位准) |
| 纯计算逻辑 | 单元测试 |
| 参数校验的各种非法组合 | 切片测试(T1-016 的参数化) |
一条经验:
集成测试的数量应该是"业务流程的数量级",不是"代码分支的数量级"。
一个 20 个接口的系统,集成测试大概 10~30 个;而单元测试可能是几百个。
@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);
}
}见第 7 节。
@SpringBootTest(webEnvironment = RANDOM_PORT)
class OrderServiceBranchTest {
// ✗ 用集成测试测"金额的 5 种计算分支"
// 每个分支要启动一次 HTTP 请求 → 慢,且定位不到具体分支
}问题:集成测试的成本高、定位差——用它测分支是"用大炮打蚊子" ✗
正确做法:
| 测什么 | 用什么 |
|---|---|
| 5 种计算分支 | Service 的单元测试(毫秒级、失败直接指向分支) |
| "创建订单"这条链路走得通 | 集成测试(1 个) |
文件清单(labs/spring-boot-web/src/test/java/cn/kb/learn/web/):
OrderApiIntegrationTest.java ← 完整生命周期 + 校验 + 业务冲突 + 分页
OrderQueryIntegrationTest.java ← 上下文复用实证(只读查询)
controller/OrderControllerSliceTest.java ← 上一节的切片测试OrderApiIntegrationTest(4 个测试):
@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() { ... }
}运行命令:
cd labs
./mvnw -pl spring-boot-web test预期输出:
[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 次 | 13.62 s | 3.236 s | — |
| 第 2 次 | 19.69 s | 7.438 s | — |
| 第 3 次 | 18.44 s | 4.038 s | 0.057 s |
| 第 4 次 | 11.58 s | 2.911 s | 0.034 s |
要看的不是绝对值,而是"数量级差异":首次启动 2~4 秒 vs 复用后 0.03~0.06 秒——约 70~90 倍的差距,在任何一次运行里都成立 ✓
logging.level.org.springframework.test.context.cache=DEBUG观察:OrderApiIntegrationTest 处是 Creating new ApplicationContext,OrderQueryIntegrationTest 处是 Reusing cached ApplicationContext ✓
在 WebApplication.main 处打断点:跑集成测试时会命中(容器真的启动了);跑切片测试时不会命中(切片不走 main)——这是"两种测试启动方式不同"的直接证据;
在 OrderService 的构造器处打断点:集成测试会命中(真的创建了 Service);切片测试不会(是 mock);
观察真实端口:在测试里打印 @LocalServerPort 的值,多跑几次——每次都不一样(RANDOM_PORT 的含义);
验证 @Transactional 失效:给集成测试类加上 @Transactional,观察数据仍然留在存储里(下一个测试能查到)——这是"事务绑定线程"的现场。
@Transactional 能回滚集成测试的数据 见 5.5 节。RANDOM_PORT 下不生效 ✗
修复:自己清理数据,或让断言不依赖具体数据。
@DirtiesContext @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) // ✗ 每跑一次就扔症状:测试套件从 20 秒变成几分钟 ✗
原因:它把上下文缓存毁掉了——每个类都要重新启动容器。
什么时候才该用:测试确实破坏了上下文状态,且无法用其他方式隔离(很少见)✓
更好的替代:
| 想解决的问题 | 更好的做法 |
|---|---|
| 数据污染 | 不依赖具体数据 / 自己清理 |
| 单例 Bean 状态被改 | 把状态改成不可变,或用 @DirtiesContext(classMode = BEFORE_CLASS) 缩小影响面 |
| 配置不同 | 分成不同的测试类(本来就该分) |
DEFINED_PORT 撞端口 @SpringBootTest(webEnvironment = DEFINED_PORT) // ✗ 用 application.yml 里的 18080症状:本机开着应用时,测试报 Port 18080 was already in use ✗
修复:用 RANDOM_PORT ✓
assertThat(page.total()).isEqualTo(1); // ✗ 依赖别的测试留下的数据症状:单独跑这个类能过,全量跑就失败(或反过来)—— 经典的"测试顺序依赖" ✗(违反 T1-007 的 I = Independent)
修复:
assertThat(page.total()).isGreaterThanOrEqualTo(1); // ✓ 只断言"至少有"或者在自己的测试里创建所需数据 ✓
见 6.3 节。代价高、定位差。
见 5.4 节。记住这个排查方向:
所有"用 mock 的测试"一起挂,且报错里有 Byte Buddy / ASM / CGLIB
→ JDK 版本超出字节码库支持范围
→ 升级字节码库,或加 -Dnet.bytebuddy.experimental=true顺带记住:"只有一部分测试挂"是有信息量的——它指出了依赖面在哪里 ✓
目标:亲手测出上下文缓存的威力。
步骤:
logging.level.org.springframework.test.context.cache=DEBUG在输出里搜 Reusing cached 与 Creating new,对应到具体的测试类;
OrderQueryIntegrationTest 加一个 @MockitoBean(随便 mock 一个用不到的 Bean)——观察它的耗时从 0.057 s 涨到几秒(因为配置变了,要新建上下文);@Transactional 失效:给 OrderApiIntegrationTest 加 @Transactional,然后跑两次测试——观察数据是否仍在;webEnvironment 改成 DEFINED_PORT,同时让本机应用监听 18080,运行测试——观察报错;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):
cd labs
mvn test -pl testing-unit -Ptctest # 只跑 Testcontainers 用例(3 个)观察三件事 ✓:① 首次运行会拉 postgres:16-alpine 镜像(几分钟,之后有缓存); ② 三个约束测试在真实 PG 上通过 —— 与 H2 的通过不是一回事 ✓✓; ③ 默认 mvn test -pl testing-unit 仍是 88 个用例、约 12 秒 —— 引擎依赖没有拖慢日常反馈 ✓✓✓。
扩展任务:把 OrderQueryIntegrationTest 的两个测试并到 OrderApiIntegrationTest 里(合成一个类),观察套件总耗时的变化——理解"类数量"与"上下文数量"的关系。
基础题
@SpringBootTest(webEnvironment = RANDOM_PORT) 会启动一个真实的 Tomcat。(对 / 错)@Transactional 在 RANDOM_PORT 下不能回滚数据?代码阅读题
@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());
}
}ATest 先跑,BTest 的 t2 能查到 t1 创建的那条订单吗?为什么?BTest 加上 @MockitoBean private OrderService orderService;,第 4 题的答案会变吗?编程题
POST /api/orders 创建成功(201)GET /api/orders/{orderNo} 查询刚创建的订单(200)DELETE /api/orders/{orderNo} 删除(204)GET /api/orders/{orderNo} 删除后再查(404)TestRestTemplate,webEnvironment = RANDOM_PORT;isGreaterThanOrEqualTo 之类);综合任务
OrderController、OrderService(含库存扣减、金额计算)、OrderRepository(MySQL)、一个外部"支付服务"客户端;提示 1(方向):第 4 题的关键是"ATest 与 BTest 的注解完全相同"。
提示 2(关键点):第 5 题的关键是"加了 @MockitoBean 之后,配置还一样吗?"
提示 3(接近答案):第 7 题的"看起来像集成测试、其实该写单元测试",典型例子是"金额计算的五种折扣分支"——它不需要数据库也不需要 HTTP。
RANDOM_PORT 会启动真实的内嵌容器(本项目是 Tomcat),并监听一个操作系统分配的随机端口。对比 MOCK(默认):不启动容器,只提供 mock 的 Servlet 环境(配 MockMvc 用)。
实测证据:本项目 OrderApiIntegrationTest 4.038 s(启动)→ OrderQueryIntegrationTest 0.057 s(复用)✓
测试线程 ──── 开启事务 T1 ──── 发出 HTTP 请求 ──── 等待响应 ──── 回滚 T1(什么也没滚到)
│
▼
Tomcat 线程 ──────────────── 处理请求 ──── 自己的事务 T2 ──── 提交 T2 ✓(数据留下了)关键:TestRestTemplate 发的是真实 HTTP,服务端由 Tomcat 的工作线程处理——测试线程的 ThreadLocal 事务上下文与它无关 ✓
对比 MOCK 环境:MockMvc 在同一个线程里"假装"发请求 → 事务是共享的 → @Transactional 回滚有效 ✓
这不是 @Transactional 的 bug,而是"线程绑定"这个设计的必然结果(《Spring:AOP 与事务》第 5.3 节)。
第 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 不再是同一个 → 新建一个上下文 ✗
结果:
BTest 的耗时从 0.057 s 涨回几秒;BTest 里的 OrderService 是 mock → t2 查到的数据来自空的 mock(不是 ATest 写的那个存储)→ 查不到了 ✓这一题揭示了两件事:
| 发现 | 说明 |
|---|---|
| 解释"为什么加了 mock 就慢" | 缓存按配置匹配,配置变了就得重启容器 |
| 上下文缓存是"双刃剑" | 它让测试快,但也让"共享状态"成为可能 |
一个实践推论:测试类要不要加 @MockitoBean,会影响整个套件的耗时——所以别随手加"用不到的 mock" ✓(这与实验第 3 步要验证的完全一致)
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 → 存储 三层一起工作,切片测试切断了中间 |
如果非要"用切片测试写生命周期",你只能写成:
// ✗ 这已经不是生命周期测试了,它只是"验证 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",那它该换成集成测试。
耗时记录(本项目的实测参考):
| 测试类 | 测试数 | 耗时 |
|---|---|---|
OrderApiIntegrationTest | 4 | 4.038 s |
OrderQueryIntegrationTest(复用上下文) | 2 | 0.057 s |
| 切片测试(对照) | 7 | 18.44 s |
注意:如果你的生命周期测试与前面的集成测试类配置相同,它也会复用上下文——只花几十毫秒 ✓
测试金字塔设计:
┌─────────────────────┐
│ 端到端(可选) │ ~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 序列化 | 需要维护桩数据 |
推荐组合:
日常 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 分钟,开发者就会开始绕过测试——测试的实际保护力反而下降。
分层不是妥协,而是让"该快的时候快、该全的时候全" ✓
@SpringBootTest 与 @WebMvcTest 的本质区别;webEnvironment 四种取值,并知道该用 RANDOM_PORT;TestRestTemplate 写真实 HTTP 测试(且知道它能自动指向随机端口);@Transactional 在 RANDOM_PORT 下不生效,会用自己的方式隔离数据;单元测试专项(TA)到此完成。接下来可以: