大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
集成测试
这一课你将能做到
- 说出哪些问题 mock 测不到(SQL、唯一索引、事务、迁移),并归类「可 mock / 必集成 / 不必每次跑」三张清单
- 用 Testcontainers +
@ServiceConnection写出对真 MySQL 的插入-查询往返用例,包括唯一索引冲突 - 用
*Test/*IT命名约定与 Failsafe 插件把快慢测分开跑
为什么要学
单元测试把 Mapper mock 掉之后,仍会出现「单测全绿、线上炸库」:SQL 写错、唯一索引没建、事务边界不对、字段映射错位。人话:替身演员台词背得再熟,也不代表真舞台的布景能承重。
集成测试用真实或容器化的 MySQL / Redis,验证多组件一起工作。它贵(秒级起步),所以要克制:只覆盖 mock 无法替代的路径。CodeForge 生产用 MySQL 8 + Redis,集成测试就该连同样的东西,而不是找个内存库自欺欺人。
先建立图像
单元: Service ──mock──► Mapper(假,你编的答案)
集成: Service ──真──► Mapper ──真──► MySQL(Testcontainers 容器)
成本: 启容器 + 建表 + 插入 + 查询 ≫ 纯 Mockito
收益: 抓住「SQL / 事务 / 装配 / 迁移」类缺陷
| 方式 | 优点 | 注意 |
|---|---|---|
| Testcontainers | 本地与 CI 一致,版本可钉死 | 需要 Docker |
CI services: 起 MySQL | workflow 原生 | 本地要另备库 |
| 内存库(H2 等) | 快 | 方言差异风险大 |
命名约定:*Test.java 快测(Surefire 默认跑);*IT.java 集成测(Failsafe 在 verify 阶段跑)。
跟着做
步骤 1:先列三张清单,再写代码
打开 backend 的 Mapper / 建表脚本,把场景归类:
- 可 mock:纯 Service 分支、DTO 拼装(现有
*ServiceTest全在此列,保留,不要改成打真库) - 必须集成:
userMapper.insert与唯一索引、事务回滚、复杂查询 - 不必每次 PR:Redis + 判题全链路重型场景(可夜间跑)
步骤 2:引入依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mysql</artifactId>
<scope>test</scope>
</dependency>
步骤 3:写第一个真 MySQL 往返用例
Spring Boot 3.1+ 提供 @ServiceConnection,容器连接信息自动注入数据源,比手写 @DynamicPropertySource 更现行:
import com.codeforge.entity.User;
import com.codeforge.mapper.UserMapper;
import com.mybatisflex.core.query.QueryWrapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.dao.DuplicateKeyException;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import static org.assertj.core.api.Assertions.*;
@SpringBootTest
@Testcontainers
class UserMapperIT {
@Container
@ServiceConnection
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0");
@Autowired
UserMapper userMapper;
private User newUser(String name) {
User user = new User();
user.setUsername(name);
user.setEmail(name + "@example.com");
user.setPasswordHash("$2a$10$fakehashfakehashfakehash");
return user;
}
@Test
void 用户写入后可按用户名查回() {
String name = "t_" + System.nanoTime(); // 每次唯一,防脏数据
userMapper.insert(newUser(name));
User loaded = userMapper.selectOneByQuery(
QueryWrapper.create().where(User::getUsername).eq(name));
assertThat(loaded).isNotNull();
assertThat(loaded.getEmail()).isEqualTo(name + "@example.com");
assertThat(loaded.getPasswordHash()).isNotBlank();
}
@Test
void 用户名唯一索引真实生效() {
String name = "u_" + System.nanoTime();
userMapper.insert(newUser(name));
// 同名再插:mock 永远证不了这一条,只有真库能
assertThatThrownBy(() -> userMapper.insert(newUser(name)))
.isInstanceOf(DuplicateKeyException.class);
}
}
应看到:首次运行拉 mysql:8.0 镜像较慢,之后复用镜像层。表结构由项目启动时的建表初始化(DatabaseInitializer)在容器库里执行,与生产同一套脚本——这正是集成测试的价值:测的是「库结构 + 代码」的组合。
步骤 4:把快慢测分开跑
先用 Maven 的默认约定:*Test 归 Surefire(mvn test),*IT 归 Failsafe。pom.xml 加:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
</execution>
</executions>
</plugin>
mvn test # 只跑快测,日常改码用
mvn verify # 快测 + IT 全跑,提交前用(与 CI 一致)
也可用 JUnit @Tag("integration") 加 Surefire/Failsafe 的 <groups> 过滤,效果等价,团队二选一即可。
步骤 5:集成测试的卫生习惯
- 数据自备自清:每个用例自己造数(如
System.nanoTime()后缀),不依赖手工残留行,不改别人的数据 - 容器复用:容器字段
static+@Container,全类共用一个实例,避免每个用例起停 - 断言不变量:集成贵,就要买到「数据真的进去又出来」的证据
// 弱:只证明「没炸」
userMapper.insert(user);
// 强:证明往返不变量
User loaded = userMapper.selectOneByQuery(
QueryWrapper.create().where(User::getUsername).eq(name));
assertThat(loaded.getEmail()).isEqualTo(user.getEmail());
步骤 6:Redis 类需求怎么选层
- 纯内存限流逻辑(本站
MemoryRequestRateLimiter)→ 单元测即可,已有先例 - 「缓存写入后读到什么 / TTL 行为」→ 需要真 Redis:加
GenericContainer或专用RedisContainer,同样的@ServiceConnection写法 - 不确定时:先单测逻辑,集成只抽一条冒烟
讲清楚要点
- mock 答应的是你编的返回值,SQL 正确性只能集成证明:
verify(mapper).insert(...)与「插入真的落库、索引真的拦截」是两回事。 - 集成贵,所以用例短、数据自备自清:插入最小行数,断言关键不变量,禁止依赖手工数据。
- 镜像钉版本:
mysql:8.0优于浮动latest——今日绿明日红的 flaky 多半来自环境漂移。 - 建表脚本与生产同源:测「库结构 + 代码」组合才有意义;容器里跑项目自己的初始化逻辑。
@ServiceConnection取代手写动态属性:Boot 3.1+ 自动把容器端口/凭据接进 DataSource,少一份样板、少一个出错点。- 快慢分轨:
*Test挡每个 PR,*IT在verify/ 主分支 / nightly 档位跑——不是所有集成都要拦每条 push。
你可能会懵的地方
-
MockMvc 都过了,还要 IT 吗? 要。MockMvc +
@MockitoBean可能完全不碰 SQL。IT 回答的是「Mapper、索引、迁移」问题——两层的失败原因根本不同。 -
@Transactional自动回滚算集成吗? 算(同进程真库 + 回滚隔离)。但注意它会掩盖事务边界问题:被测代码若自己管理事务,外层回滚假设就失效了;异步写入也回滚不到。 -
H2 能代替 MySQL 吗? 简单 CRUD 有时可以,但分页方言、JSON 字段、锁行为差异会让你测了个寂寞。CodeForge 生产是 MySQL,关键路径用容器 MySQL,别拿 H2 的绿灯换安全感。
-
CI 没有 Docker 怎么办? GitHub Actions 的 ubuntu runner 自带 Docker,Testcontainers 开箱即用;自建环境可把 IT 挪到 nightly job,用
services:起库替代。 -
为什么我的 IT 偶发「connection refused」? 容器还没就绪测试就开始了。
@ServiceConnection+ Spring 会等容器,但自写的连接逻辑要确认顺序;本地踩到多半是复用了已停止的旧容器实例。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
| 所有测试都打真库 | 套件无人愿跑 | 底多中少,IT 只管 SQL/事务 |
| IT 依赖开发者手工数据 | 不可重复 | 用例自插入 + 唯一后缀 |
镜像用 latest | 环境漂移型 flaky | 钉死 mysql:8.0 |
| 每个用例起一个新容器 | 分钟级浪费 | static 容器全类复用 |
| 用 IT 测纯 if-else | 大炮打蚊子 | 退回 Mockito |
卡点急救
- 本机没 Docker,IT 全红:装 Docker Desktop / WSL2 后端;或临时
@Disabled("需 Docker")并把验证交给 CI 的 verify 阶段。 - 首次拉镜像超时:配置国内镜像加速,或先
docker pull mysql:8.0预热;Testcontainers 复用镜像层后二次运行很快。 - IT 里
Started CodeForgeApplication后报连接失败:确认用的是@ServiceConnection而不是写死的localhost:3306——容器端口是随机的,写死必挂。
面试常问
-
为什么用 Testcontainers 而不是 H2 跑集成测试? 测试的价值取决于环境与生产的相似度。H2 与 MySQL 在方言、索引行为、隔离级别上有差异,会出现「H2 绿、MySQL 炸」。Testcontainers 起真实 MySQL 8 容器,本地与 CI 环境一致,镜像可钉版本,代价是需要 Docker 与秒级启动——对关键路径值得。
-
举一个「单测绿、集成红」的缺陷。 用户名唯一索引:单测 mock 了
insert返回 1,一切正常;真库插入重复名抛DuplicateKeyException,若 Service 没接住就是 500。还有 Flyway 迁移漏执行、事务内异步发消息被外层回滚等,都是 mock 测不到的类型。 -
集成测试 flaky 怎么治理? 三招:环境钉版本(镜像固定 tag)、数据隔离(用例自备自清 + 唯一后缀)、运行分轨(快测挡 PR,IT 放 verify/nightly)。稳定性一半来自断言质量,一半来自环境卫生。
自测三问
- 举一个「单测绿、集成红」的真实缺陷类型。
@ServiceConnection解决了 Testcontainers 随机端口的什么问题?- 为什么建议
*Test与*IT分开跑?各挂在 Maven 哪个阶段?
小结一句话
集成测试贵而准:只让 SQL、事务、唯一索引这些 mock 证不了的事上真库,其余仍交给 Mockito。
延伸
- Testcontainers 官方文档
- Spring Boot · Testcontainers 支持
- 下一课:test-tdd 把前面的手法组织成「红 → 绿 → 重构」的开发节奏,让测试驱动设计。