适用读者:mock 通过、真实环境失败过的人;要补"能证明 SQL/事务/序列化"的测试的人。
前置:S16-B02、S07(Spring)、S11(MySQL)、本机有 Docker。
覆盖旧文:无直接旧卡(E 驱动)。依据:Spring Boot Reference「Testing」章、Testcontainers for Java 文档。
扩展点:E-S16-003(测试切片的选择)。
上一篇:S16-B02 · 单元测试:JUnit 5 + Mockito 的最小可运行
下一篇:S16-B04 · 端到端。
1. 一句话结论
集成测试的价值不在"更真实",在于它把被 mock 排除掉的那部分(SQL、事务、序列化、配置绑定)重新纳入证据。 代价是慢,所以要按切片选范围,而不是"全都 @SpringBootTest"。
| 你会漏掉的缺陷 | 只有集成能测 |
|---|---|
SQL 写错(WHERE 条件、类型映射) |
✅ |
| 事务边界(异常是否回滚) | ✅ |
| JSON 序列化(字段名、时间格式) | ✅(切片) |
| Bean 装配(循环依赖、条件装配) | ✅ |
| 分布式锁/幂等 | ❌ 需要 E2E 或多实例(B04) |
2. 环境(2026-11 实测)
// build.gradle — Spring Boot 3.3.6 / Java 17
dependencies {
testImplementation "org.springframework.boot:spring-boot-starter-test" // JUnit5 + AssertJ + Mockito
testImplementation "org.testcontainers:junit-jupiter:1.20.4"
testImplementation "org.testcontainers:mysql:1.20.4"
testImplementation "com.mysql:mysql-connector-j:8.4.0"
}
@SpringBootTest
@Testcontainers
class OrderFlowIT { // 注意:*IT 由 failsafe 跑,不混进单测时长
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.4")
.withDatabaseName("shop").withUsername("u").withPassword("p");
@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);
}
// ... 注入 OrderService / JdbcTemplate 断言
}
预期输出(首次运行会拉镜像,约 20–60s;之后 3–8s):
$ ./gradlew integrationTest
OrderFlowIT > 库存不足时整单回滚() PASSED
2 tests completed
3. 切片选择(E-S16-003)
| 注解 | 启动内容 | 适合证明 | 启动耗时(量级) |
|---|---|---|---|
@WebMvcTest(X.class) |
仅 Web 层 + 指定 Controller | 路由、参数校验、状态码、JSON 结构 | 1–3s |
@DataJpaTest |
JPA + 数据源 | 映射、查询方法、约束 | 2–5s(+容器) |
@JdbcTest |
JdbcTemplate + 数据源 | 手写 SQL、事务 | 2–5s |
@SpringBootTest(webEnvironment=RANDOM_PORT) |
全上下文 | 装配、事务、跨层调用 | 5–15s |
@SpringBootTest + Testcontainers |
全上下文 + 真依赖 | 上述全部 + 真实 SQL 方言 | 8–30s |
判据:能用切片就不用全量。全量测试每多一个,CI 时长线性增长,最终会被团队"跳过"。
4. 两个最容易踩的坑
4.1 @Transactional 让"回滚"永远为真
@Transactional // 测试结束后自己回滚
@Test
void 库存不足时整单回滚() { ... }
测试方法上的 @Transactional 会让整个用例在一个事务里跑,被测代码的"提交/回滚"被外层吞掉——于是"回滚"这条断言永远成立,等于没测。
改法:去掉测试方法上的 @Transactional,用 @Sql 或显式清理;或在断言前用 JdbcTemplate 直查(能看到未提交数据说明事务没生效)。
4.2 用 H2 代替 MySQL 证明方言
| 差异 | H2 | MySQL 8 |
|---|---|---|
| 自增与锁 | 行为不同 | AUTO_INCREMENT、间隙锁 |
| 唯一索引 + NULL | 多 NULL 允许 | 多 NULL 允许(易误解为唯一) |
| 时间精度 | 微秒/纳秒差异 | DATETIME(3) |
| 大小写敏感 | 依配置 | 依 collation |
判据:凡断言涉及锁、事务隔离、排序、字符集,必须真库;纯 CRUD 映射可以用 H2,并在类注释写明"未覆盖 MySQL 方言"。
5. 一个可跑的完整例子
@SpringBootTest
@Testcontainers
class StockIntegrationTest {
@Autowired InventoryService service;
@Autowired JdbcTemplate jdbc;
@Test
void 扣减失败时库存不变() {
jdbc.update("insert into stock(sku_id, qty) values (1, 1)");
assertThatThrownBy(() -> service.deduct(1L, 2))
.isInstanceOf(InsufficientStockException.class);
Integer left = jdbc.queryForObject("select qty from stock where sku_id=1", Integer.class);
assertThat(left).isEqualTo(1); // 关键:真库直查
}
}
这条用例只有集成层能写:单元层需要 mock repo.decrease,而实现中的 SQL 条件(WHERE qty >= ?)正是缺陷高发处。
6. 常见误区
| 误区 | 纠正 |
|---|---|
| "把单测全改成 @SpringBootTest 更保险" | 时长×10,反馈变慢,最终被跳过 |
| "容器测试不稳定,不如 H2" | 不稳定的是端口/镜像,不是容器本身;用 RANDOM_PORT + 固定镜像 tag |
| "集成测试要覆盖所有分支" | 覆盖契约与 SQL 即可;分支逻辑归单元层 |
| "测试里可以随便改库" | 每个用例自带数据、自己清理;不要依赖执行顺序 |
7. 自测
- 你的项目里有几个
@SpringBootTest?能不能降成切片? - 有没有测试方法上挂着
@Transactional却在断言回滚? - 你的 SQL 断言跑在 H2 还是 MySQL?如果是 H2,哪些结论不能外推?
8. 下一篇
S16-B04 · 端到端:跑通完整流程才算真正验证 —— 从"agent 说做完了"到"系统级证据",含四类组件边界缺陷与可执行 harness。