集成测试进阶:@SpringBootTest 与切片测试
集成测试解决的是一类很具体的尴尬:单元测试全绿,一上线就炸。原因是单元测试里你手动 new 对象、手动塞假件,而线上跑的是容器装配出来的真对象——中间那段「零件到底有没有拼上」根本没人验证过。集成测试就是让容器真的拼一遍,切片测试则是只拼你要验证的那一小片。
先把五个词一句话解释(全文都会用到):
- 上下文(ApplicationContext):Spring 那个帮你创建并管理所有对象的大管家,启动它 = 把整个应用的零件装一遍
- 切片(Slice Test):只加载某一层的测试注解,比如
@WebMvcTest只装 Web 层、@DataJpaTest只装 JPA 层 - MockMvc:不发真网络请求,直接把一个「假请求」喂给 Spring MVC 的处理入口,走完路由→参数绑定→序列化整条路
- TestContext:Spring Test 的调度中枢,负责准备上下文、注入字段、执行回调、处理事务回滚这一整套流程
- 上下文缓存:配置完全相同的测试会复用同一个上下文;只要 key 差一点,就得重建一个
切片测试就像在自家书房改方案时只开那一盏台灯,而不是为了看一本书把整栋楼的电闸全推上去。@SpringBootTest 是「整栋楼通电」:灯全亮、空调全开、电梯全跑——最能发现问题,也最费电、最慢。聪明的做法是先问一句:这一处出错,只开台灯能不能看出来? 能就别拉总闸。

学完这一篇,你应该能回答三个问题:
- 我的用例到底该用
@SpringBootTest、@WebMvcTest还是@DataJpaTest?判断标准是什么? - 为什么加了个
@MockitoBean,整个测试套件的耗时翻了三倍? @Transactional测试自动回滚,为什么会掩盖住唯一约束的真 Bug?
上一篇把单元测试的威力讲透了——它能毫秒级验证业务分支。但它有一个天生的盲区:Mock 只会按你的剧本回答,永远不会报错。于是有三类问题,单元测试全绿、线上却炸:
- 装配错误:你在测试里
new UserService(mockRepo),一切正常;真实启动时容器里有两个UserRepository实现,谁都没加@Qualifier,启动直接抛NoSuchUniqueBeanDefinitionException。你测的是对象,运行时用的是容器。 - SQL 错误:Mock 的仓库永远返回数据;可真实的
@Query里字段名写成了username,而实体属性其实是userName。单元测试根本不会去解析那条 JPQL,直到接口一调就 500。 - 序列化错误:DTO 里有个
LocalDateTime,没注册JavaTimeModule。单元测试不经过 JSON 序列化,直到真实 HTTP 响应写出时抛InvalidDefinitionException。
这三类事故的共同点是:问题不在业务逻辑里,而在「组件拼接」这件事上。这正是集成测试与切片测试要覆盖的领域。
@SpringBootTest 做的事情就一件:启动真实的 Spring 应用上下文,把该装配的都装配上。它最常用的属性有两个:
// classes:指定用哪个配置类(默认找 @SpringBootConfiguration)// webEnvironment:决定起不起真实服务器、用哪种 Web 环境@SpringBootTest( classes = DemoApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)class UserApiIntegrationTest { @LocalServerPort private int port; // 实际监听的随机端口,注入进来}webEnvironment 有四种模式,差别很大,选错会付出「起不来」或「慢一倍」的代价:
| 模式 | 行为 | 提供什么 | 适用场景 |
|---|---|---|---|
MOCK(默认) | 不启动真实服务器,只装配 Web 环境 | MockMvc / MockEnvironment | 大多数 Web 层测试,快 |
RANDOM_PORT | 启动内嵌服务器,随机端口 | 真实 HTTP,配合 @LocalServerPort | 想验证真实网络栈、过滤器 |
DEFINED_PORT | 用配置端口(默认 8080) | 真实 HTTP,端口固定 | 要与固定端口的东西联调 |
NONE | 完全不提供 Web 环境 | 普通 ApplicationContext | 纯服务层 / 定时任务集成测试 |

启动一个全量上下文,像为了检查一个花洒有没有出水,把整栋酒店的中央供暖、电梯、泳池循环泵全开一遍。你确实测到了「花洒真的出水」,代价是整栋楼的电。切片就是只开这一层的阀门——快,但问题若出在锅炉房(装配、连接池、事务管理器),它看不见。下面这条时间线会反复用到,第十一节用它给「测试慢」看病。

除了用主配置,你还能用 @TestConfiguration 补充「只在测试里存在」的 Bean——比如一个假的发短信客户端:
@TestConfigurationclass TestBeans { @Bean @Primary // 覆盖真实实现,不污染主配置 SmsClient fakeSmsClient() { return (phone, msg) -> System.out.println("stub sms -> " + phone); }}说明:@TestConfiguration 是「测试专用配置」,它不会被组件扫描自动拾取,必须通过 @Import 或作为静态内部类使用。这样做的好处是:测试需要的替身,绝不会误入生产上下文。
测试环境该配什么,其实是「生产配置的一份变体」——数据源换成内嵌库、DDL 交给 Hibernate、profile 单独一个 test。别去抄现成的 src/test/resources/application.yml,勾一遍,看它长出哪几行、每行去掉会怎样:
server:
port: 8080
spring:
application:
name: demo-service
datasource:
url: jdbc:mysql://127.0.0.1:3306/bee_order?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: ${DB_USER:root} # ${} 占位符:环境变量优先,冒号后是默认值
password: ${DB_PASS:}
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
max-lifetime: 1740000 # 必须小于 MySQL 的 wait_timeout
pool-name: beeHikari
jpa:
open-in-view: never
hibernate:
ddl-auto: validate # 生产用 validate,别用 update/create
show-sql: false
properties:
hibernate.format_sql: true
hibernate.jdbc.batch_size: 50
---
spring:
config:
activate:
on-profile: prod
logging:
level: { root: WARN }
---
spring:
config:
activate:
on-profile: dev
spring:
jpa:
show-sql: true这张生成器里的 spring.datasource.url 一旦被换成真实 MySQL 的地址,而你又忘了关 @AutoConfigureTestDatabase,测试连上的仍然是内嵌库——配置被切片替换了,你写的 yml 根本没被采纳。这正是第七节 replace = Replace.NONE 那一行存在的原因。
集成测试最烦的是「数据污染」——A 用例插的数据影响 B 用例。Spring Test 提供了一个漂亮的解法:在测试类上加 @Transactional,每个测试方法结束后自动回滚。
@SpringBootTest@Transactional // 每个测试方法跑完 → 自动回滚,数据不留痕class UserServiceIntegrationTest { @Autowired private UserService userService; @Test void should_create_and_query_user() { Long id = userService.create("alice", "alice@example.com"); assertThat(userService.getById(id).getUsername()).isEqualTo("alice"); // 方法结束,事务回滚,数据库恢复原样 }}它为什么能回滚?因为 Spring Test 在测试方法外开了一个事务并绑定到当前线程的 ThreadLocal,测试里的所有操作复用了同一条连接,方法结束后由框架统一回滚。这也埋了两个坑:
- 对
RANDOM_PORT的真实 HTTP 请求无效:HTTP 请求跑在服务器的线程里,那条线程拿不到测试线程绑定的连接,所以请求本身的写操作不在测试事务内,回滚不了。 - 它掩盖了约束问题:因为数据最终被回滚,一些外键、唯一约束问题可能被延迟到 flush 时才暴露,甚至被掩盖过去。
@Transactional 测试回滚只对「同一线程内直接调用 Bean」有效。一旦走了真实 HTTP、新线程或异步方法,事务就断裂了——此时该改用 @Sql 清理或 @DirtiesContext,而不是指望回滚。
这条「同线程有效、换线程失效」的分界,光看结论记不住。下面这段动画把框架那笔事务的一生摆出来——重点看第 ⑥ 帧,那一刻写的行再也没有人回滚:

再把它摊成一次单步执行。左边是这个用例真实会走的六行,右边同步刷新「此刻的变量」和「调用栈」——连点下一步,盯两处:第 ③ 拍那个「服务层以为提交了」,和第 ⑥ 拍换掉的线程名:
@SpringBootTest(webEnvironment = RANDOM_PORT) // 第 ⑥ 拍的命运由这一行决定@Transactional // 标在测试类上,不是标在服务层Long id = userService.create("alice"); // 服务层自己也有 @TransactionalassertThat(userService.getById(id)).isPresent(); // 读得到:写的和读的是同一条连接// afterTestMethod:框架无条件调用 connection.rollback()rest.getForEntity("/users/1", User.class); // 这一句跑在 Tomcat 的工作线程上| 线程 | main |
| 上下文缓存 key | DemoApplication + test + {} |
| 事务状态 | 还没开 |
SpringExtension.beforeAllbuildMergedContextConfiguration上面那句「服务层的提交只是投一票」,可以在内核里亲眼看一下——同一个 @Transactional,传播行为不同,结局完全不同:
MOCK 模式下不启动服务器,但你可以用 MockMvc 把请求「喂」进 DispatcherServlet,走完整个 MVC 流程:
@SpringBootTest@AutoConfigureMockMvcclass UserControllerTest { @Autowired private MockMvc mockMvc; @Test void should_return_user_json_when_found() throws Exception { mockMvc.perform(get("/users/42") // 构造请求 .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) // 1. 状态码 .andExpect(header().string("Content-Type", containsString("application/json"))) // 2. 响应头 .andExpect(jsonPath("$.id").value(42)) // 3. JSON 字段 .andExpect(jsonPath("$.username").value("alice")) .andDo(print()); // 打印请求/响应,调试利器 } @Test void should_return_404_when_missing() throws Exception { mockMvc.perform(get("/users/999")) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value("USER_NOT_FOUND")); }}perform(...)构造并执行请求,支持get/post/put/delete与contentType/content(JSON body)andExpect(...)是一串链式断言:状态码 → 头 → JSON 字段,层层递进jsonPath("$.id")用 JSONPath 定位字段,比手动解析字符串稳得多andDo(print())把完整的请求与响应打到控制台,排查断言失败时先加它
提示:处理中文时,注意 MockMvc 的字符编码。若响应里出现乱码,先确认 Content-Type 带了 charset=UTF-8,并检查控制器 / 全局配置里 HttpMessageConverter 的编码设置——JSON 默认是 UTF-8,但纯字符串返回值可能受 StringHttpMessageConverter 的默认编码影响。
MOCK 的 MockMvc 不经过真实网络栈。要验证真实的 HTTP 行为(过滤器、序列化、状态码处理),用 RANDOM_PORT + 真实客户端:
| 客户端 | 适用栈 | 特点 |
|---|---|---|
MockMvc | Servlet(MOCK 模式) | 最快,不走网络,适合多数断言 |
TestRestTemplate | Servlet(RANDOM_PORT) | 阻塞式,写法简单,适合同步接口 |
WebTestClient | WebFlux / 也可测 Servlet | 响应式,支持流式断言,功能更强 |
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)class UserApiRealHttpTest { @Autowired private TestRestTemplate rest; // 自动指向随机端口 @Test void should_fetch_user_over_real_http() { ResponseEntity<User> resp = rest.getForEntity("/users/42", User.class); assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(resp.getBody().getUsername()).isEqualTo("alice"); }}@SpringBootTest配RANDOM_PORT后,TestRestTemplate会被自动装配并指向真实端口,无需手写 URL- 它经过完整的 Servlet 栈:过滤器、拦截器、序列化器都会真实执行——这正是 MockMvc 测不到的部分
- 代价是慢:每次都要启动真实服务器,用到它的测试应尽量少而精
全量上下文太重。多数时候你只想测「Web 层」或「JPA 层」,此时用切片测试注解,Spring 只装配相关的组件,其他一律不加载:

| 注解 | 加载范围 | 典型用途 |
|---|---|---|
@WebMvcTest | 只加载 MVC 层(Controller / 过滤器 / 转换器) | 测控制器路由、参数、校验、异常处理 |
@DataJpaTest | 只加载 JPA 相关(实体 / Repository),默认内嵌数据库 | 测派生查询、@Query、映射 |
@JsonTest | 只加载序列化相关(ObjectMapper / Jackson) | 测 JSON 序列化与反序列化 |
@RestClientTest | 只加载 HTTP 客户端相关 | 测对外部接口的调用与解析 |
@WebMvcTest 只装 Web 层,所以 Controller 依赖的 Service 不会被装配——这时用 @MockBean 补一个:
@WebMvcTest(UserController.class)class UserControllerSliceTest { @Autowired private MockMvc mockMvc; @MockBean private UserService userService; // 切片里 Service 不在,用 @MockBean 顶上 @Test void should_return_user() throws Exception { when(userService.getById(42L)).thenReturn(new User(42L, "alice")); mockMvc.perform(get("/users/42")) .andExpect(status().isOk()) .andExpect(jsonPath("$.username").value("alice")); }}@DataJpaTestclass UserRepositorySliceTest { @Autowired private UserRepository userRepository; @Autowired private TestEntityManager entityManager; // 切片提供的便捷工具 @Test void should_find_by_username() { entityManager.persist(new User(null, "alice")); entityManager.flush(); assertThat(userRepository.findByUsername("alice")).isPresent(); }}要点:@DataJpaTest 默认每个测试方法都在一个事务里、结束后回滚,并且默认用内嵌数据库替换真实数据源。它加载的组件极少,所以非常快——但正因为它用的是内嵌库,方言差异会在下一篇的「真数据库」里成为重点。
上面这张表写的是「切片装什么」,但真实工作里你问的是反过来的问题:手上这个 Bug,该开哪一盏灯才能照到它? 来玩一局——左边点一类故障,右边点它最便宜的捕获手段,配错了当场告诉你为什么。
H2 内存库快,但它和 MySQL 有方言差异——原生函数、ON DUPLICATE KEY、分页语法、字段类型都可能对不上。于是一个残酷的现实出现了:H2 上全绿,切到 MySQL 直接报语法错误。
| 策略 | 速度 | 保真度 | 风险 |
|---|---|---|---|
| H2 内存库 | 极快 | 低 | 方言差异导致「测试通过、生产失败」 |
| 本地真实 MySQL | 中 | 高 | 依赖本地环境,CI 里不稳定 |
| Testcontainers(Docker 起真库) | 较慢(首次拉镜像) | 最高 | 需要 Docker 环境,CI 需配置 |
Testcontainers 的思路很直接——测试时用 Docker 起一个真的 MySQL:
@DataJpaTest@Testcontainers@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // 别再用内嵌库class UserRepositoryContainerTest { @Container static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0") .withDatabaseName("demo") .withUsername("test") .withPassword("test"); @DynamicPropertySource static void props(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", mysql::getJdbcUrl); registry.add("spring.datasource.username", mysql::getUsername); registry.add("spring.datasource.password", mysql::getPassword); }}@Testcontainers+@Container负责容器的启动与销毁(static 容器整个类只起一次)@AutoConfigureTestDatabase(replace = NONE)是关键:默认它会把数据源换成内嵌库,必须关掉才能真正连上容器@DynamicPropertySource把容器的动态端口注入 Spring 配置- 复用容器开关:开启
testcontainers.reuse.enable=true,可在本地反复运行测试时复用已启动的容器,省掉每次拉镜像的等待(CI 里建议关掉,保证隔离)
说明:Testcontainers 是「真数据库测试」的现代标准答案。它把「H2 与 MySQL 不一致」这个长期痛点从根上解决了——你测的就是生产上跑的那个 MySQL 版本。代价是需要一个可用的 Docker 环境,CI 里要提前准备好。
数据怎么造,决定了测试是否稳定。三种常见做法,按「越靠后越推荐」排序:
data.sql:启动时自动执行,适合每个类都需要的基础数据(如字典表)。缺点是全局生效,容易互相干扰。@Sql:在单个测试方法或类上指定脚本,精确控制造数范围,还能配executionPhase = BEFORE_TEST_METHOD。
@Test@Sql(scripts = "/sql/users.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)void should_list_active_users() { assertThat(userService.findActive()).hasSize(3);}- Builder / 工厂方法:最灵活、最清晰,测试数据就在测试代码里,一眼看清前置条件:
static User aUser() { return User.builder() .username("alice") .email("alice@example.com") .status(Status.ACTIVE) .build();}坑:测试数据要「自带」,不要「共享」。 依赖上一个用例留下的数据,是集成测试最常见的不稳定来源——单跑通过、全量跑挂掉,往往就是数据污染。优先用 Builder 在用例内造数,或在 @BeforeEach 里清库。
在流水线里跑测试,先分清两条命令:
| 命令 | 跑到哪个阶段 | 会执行什么 |
|---|---|---|
mvn test | test 阶段 | 只跑单元测试与切片测试 |
mvn verify | verify 阶段 | 跑全部测试,并触发集成测试、failsafe 插件、覆盖率校验等 |
原因是 Maven 的两套测试插件分工不同:**surefire 跑 Test(单元 / 切片),failsafe 跑 IT(集成测试)并绑定在 verify 阶段**。所以集成测试类名通常以 IT 结尾:
UserServiceTest.java ← surefire,mvn test 就会跑UserApiIT.java ← failsafe,只有 mvn verify 才跑提示:CI 里建议「快速反馈 + 完整校验」分两道:提交时跑 mvn test 快速给反馈,合并前跑 mvn verify(含 Testcontainers 的集成测试)做完整把关。失败重跑策略一句话——先只重跑失败的用例确认是否偶发,仍未通过就必须查根因,别靠重跑掩盖 flaky。
集成测试的范围应该覆盖请求链路的每一环,下面这段演示帮你把「一条请求到底经过了多少组件」看个明白:
@MockBean 会污染上下文缓存,把测试拖慢。 Spring 会把「配置完全相同的上下文」缓存起来复用,而每出现一组不同的 @MockBean 组合,就会新建一份上下文。一个项目里若有几十种 Mock 组合,就等于启动几十次 Spring——集成测试的耗时就是这么涨上去的。建议把 @MockBean 的用法收敛、尽量集中。
@Transactional 测试会掩盖约束问题。 因为数据最终回滚,外键、唯一约束的冲突可能被推迟到 flush 甚至根本触发不了。涉及约束的用例,用真实提交(去掉 @Transactional)+ @Sql 清理,才能暴露真实问题。
前面所有魔法——自动注入、自动回滚、上下文复用——都来自同一个引擎:TestContext(Spring Test 的调度中枢,一个在你写的第一行测试代码前后跑完全部流程的对象)。它的完整回合就是第〇节那张动图:

对着图逐条落位,你就知道每个注解卡在哪个环节上:
| 回合 | TestContext 做的事 | 你能插进去的东西 |
|---|---|---|
| ① 算 key | 把配置类、@ActiveProfiles、properties、@MockitoBean 组合成一个缓存键 | 任何一项不同 → 换 key |
| ② 查缓存 | 命中就直接复用已有上下文 | 想快就尽量让别人命中 |
| ③ 建上下文 | 未命中才真的启动 Spring;切片在此过滤掉无关组件 | @WebMvcTest / @DataJpaTest |
| ④ 注入字段 | 把 @Autowired、@MockitoBean 填进测试实例 | 别在构造器里用它们 |
| ⑤ 前置回调 | 依次执行 @BeforeEach | 造数、打桩放这里 |
| ⑥ 跑测试 | 执行方法体与断言;@Transactional 时全程复用一条连接 | 真实 HTTP 会跳出这条连接 |
| ⑦ 收尾 | 回滚事务,上下文放进缓存给下一个用例复用 | @DirtiesContext 强制不复用 |
这张表有七行,但「哪一拍慢」这种事读是读不出手感的。下面这张图把同一件事做成能点的东西——从 ① 一路点到 ⑥,重点停在第 ②③ 两格:
亲手把这七步走一遍——切换参数看每一步的差别:
上面那个实验的第二个参数,是全篇最重要的一课:
- 命中缓存 = 几乎零成本:第 100 个用例可能只花 5ms,因为它复用了第 1 个用例建好的上下文
- 拆散缓存 = 每次都重新通电:多一行
properties、多一个不同的 Mock 组合,都会产生新 key - 所以工程纪律是:同一类测试的配置尽量写成完全一致,需要特殊配置的用例集中放一个包,别撒满全项目
先把「什么算进指纹」钉死——左边这一列改一个就换 key,右边这一列改一百次都照样命中:

真正能把这件事变成手感的,是缓存容量这个数字。Boot 默认只留 8 份上下文,而 CI 机器上没人提过它:
- 默认 8 对小型项目正好:切片几把钥匙 + 全量一两把
- 判断依据很简单——把 @MockitoBean 组合、profile、properties 各数一遍
- 指纹种类逼近或超过 8,日志里就会开始出现反复的 Refreshing ApplicationContext
- 这份缓存在 JVM 退出前不会释放,8 份全量上下文就是几百 MB
还有两件事只有 TestContext 能解释。第一,测试内的事务不是你写的 @Transactional 服务层事务——它由框架在回合 ⑥ 之前开启、回合 ⑦ 无条件回滚:
第二,切片为什么必须自己补 Mock——因为被过滤掉的组件根本没进容器:
上一篇文章里,测试类是你手动 new 出来的;到了集成测试,创建对象的人换成了 TestContext。先复习一下纯 Mockito 那一侧的执行顺序,再对照容器这一侧:
最后一个是本篇的收口:集成测试真正启动的,就是你在《启动流程》那篇见过的 SpringApplication.run()。区别只在于它跑在测试 JVM 里、并且跑完不回滚状态给外部:
实验按完了,换成命令行自己敲。下面这台控制台连着浏览器里的同一个内核,回显全部由内核算出来——先 beans 数一数全量上下文装了多少东西,再逐条 lab:
beans 之后再敲 lab tctx slice,两次的组件数一比,第六节那句「切片只装本层」就不再是抽象话了。lab tctx tx 和 lab tx rollback 要连着敲:前者是 TestContext 那笔外层事务,后者是服务层自己的提交——把这两笔搞混,第十一节那个沙盘就读不懂。
选择测试范围不是玄学,是一次明确的权衡:要多少保真度,愿意付多少秒数。下面这个沙盘把三种选择放在一起,切一档就能同屏看到启动耗时、内存占用、以及「有一类 Bug 能不能测出来」:
单用例耗时:180ms ~ 1.2s# 只装本层组件:@WebMvcTest 装 MVC,@DataJpaTest 装 JPA + 内嵌库覆盖:路由、参数绑定、校验、序列化、派生查询与 @Query测不出:Service/Repository 之间的装配冲突、跨层事务同类切片共用一份上下文时可稳定命中缓存
三档数字是示意,但比例是真的——一般后端的健康形态是「Mockito 占大头、切片居中、全量点缀」。如果你发现自己的分布反过来(全量最多),那基本可以断定:大部分全量用例其实在重复测切片就能测的东西。
先来一道热身题,考第九节那条「上下文缓存」的坑:
再来一道综合题,把第三节的回滚陷阱和第七节的方言差异串起来:
新手被卡住的时间,八成花在「报错看不懂」上。这张表按原文片段排序,可以直接搜:
| 报错原文(片段) | 真实原因 | 30 秒自救 | 深挖看第几篇 |
|---|---|---|---|
日志里反复出现 o.s.t.c.support.GenericTestContextBootstrapper : Loaded default TestExecutionListener,且每个用例前都有 Refreshing ApplicationContext | 上下文没被复用:@MockitoBean 组合、properties 或 @ActiveProfiles 不一致,缓存 key 每换一个就重建 | 打开 DEBUG org.springframework.test.context,比对相邻两个用例的 key;把相同配置的测试类收敛到一起 | 本篇第十一节 |
LazyInitializationException: failed to lazily initialize a collection of role: [...], could not initialize remote association | 懒加载集合要在访问时才查库,但此刻会话已关闭。测试里常见于 @DataJpaTest 之外的场景,或异步线程访问 | 断言前先 entityManager.flush() + clear();或在查询里用 JOIN FETCH;确实需要长会话就显式加 @Transactional | 持久化篇(懒加载与 N+1) |
DataIntegrityViolationException / Unique index or primary key violation 只在生产出现,测试全绿 | @Transactional 测试回滚掩盖了唯一约束;或者测试用内嵌库、生产用 MySQL,DDL 与方言不一致 | 该用例去掉 @Transactional 真提交,配 @Sql 清理;再把它挪到 Testcontainers 用例里 | 本篇第七、三节 |
java.lang.IllegalStateException: Failed to load ApplicationContext ... WebApplicationContext is required | 用了 MockMvc / @LocalServerPort,但上下文不是 Web 类型:webEnvironment = NONE,或 @SpringBootTest 找不到 @SpringBootConfiguration | 检查 webEnvironment 是否为 MOCK/RANDOM_PORT;测试类显式写 classes = XxxApplication.class | 本篇第二节 |
No qualifying bean of type 'com.example.UserService' available 出现在 @WebMvcTest 里 | 切片只装 Web 层,Service 根本不在容器里 | 加 @MockitoBean UserService userService;,或改用 @SpringBootTest | 本篇第六节 |
NoSuchBeanDefinitionException: No qualifying bean of type 'org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager' | TestEntityManager 只由 @DataJpaTest 提供,你把它放在了 @SpringBootTest 里 | 需要它就把这个类改成切片测试;否则注入 EntityManagerFactory 自己拿 | 本篇第六节 |
org.junit.ComparisonFailure: expected:<[alice]> but was:<[null]> | 断言本身没错,错在被测对象的字段还没被填充(构造器注入 vs 字段注入的时机差异) | 先 andDo(print()) 看响应体;确认序列化字段名一致;再检查是不是异步还没写完 | 单元测试篇 |
java.lang.IllegalStateException: Unable to read SQL script location '/sql/users.sql' | 脚本路径写的是文件系统路径,但 @Sql 按 classpath 解析,测试资源没被复制进去 | 放到 src/test/resources/sql/,路径写 /sql/users.sql;IDEA 里先 mvn test-compile 确认资源被复制 | 本篇第八节 |
这一栏里出现最多的关键词是 Failed to load ApplicationContext。它几乎从来不是那句后面的内容写错了,而是「上下文压根没建起来」——顺着 caused by 往下找第一个非 Spring 的类名,那里才是根因。
上面那条提示最好现场练一次。下面这段就是 @WebMvcTest 里最常撞的现场,先别看解析——点出你认为的凶手行:
新同事写了个 UserControllerSliceTest,用 @WebMvcTest 只测控制器,本地第一次跑就全红。他坚持认为「UserService 明明加了 @Service,容器里怎么可能没有它」。
目标:把一个「测 Controller」的完整切片跑起来,亲眼看到 Service 不在容器里、由 Mock 顶上。
第一步,pom.xml 只需要这一个 starter(其余从 parent 继承):
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope></dependency>第二步,被测的控制器与服务:
package com.example.user;@RestController@RequestMapping("/users")public class UserController { private final UserService userService; // 构造器注入 public UserController(UserService userService) { this.userService = userService; } @GetMapping("/{id}") public User get(@PathVariable Long id) { return userService.getById(id); }}第三步,测试类:
package com.example.user;import org.junit.jupiter.api.Test;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;import org.springframework.test.context.bean.override.mockito.MockitoBean;import org.springframework.test.web.servlet.MockMvc;import static org.hamcrest.Matchers.containsString;import static org.mockito.Mockito.when;import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;@WebMvcTest(UserController.class)class UserControllerSliceTest { @Autowired private MockMvc mockMvc; @MockitoBean private UserService userService; // 切片里没有 Service,这里造一个假的 @Test void should_return_user_json() throws Exception { when(userService.getById(42L)).thenReturn(new User(42L, "alice")); mockMvc.perform(get("/users/42")) .andExpect(status().isOk()) .andExpect(content().string(containsString("alice"))) .andExpect(jsonPath("$.id").value(42)) .andExpect(jsonPath("$.username").value("alice")) .andDo(print()); // 把请求/响应整个打出来 }}第四步,命令与预期输出:
$ mvn -q test -Dtest=UserControllerSliceTest[INFO] Running com.example.user.UserControllerSliceTest2026-10-07T10:12:33.482 INFO c.e.u.UserControllerSliceTest : Started in 1.94 secondsMockHttpServletRequest: HTTP Method = GET Request URI = /users/42MockHttpServletResponse: Status = 200 Content type = application/json Body = {"id":42,"username":"alice"}[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0[INFO] BUILD SUCCESS看到 Started in 1.94 seconds 和 Tests run: 1 就算过关。注意启动横幅里没有 Tomcat started on port——切片压根没起服务器,这就是它快的原因。
- 在这个测试类上多加一行
@SpringBootTest,观察启动横幅出现 Tomcat、耗时上升,并思考缓存 key 变了什么。你会观察到:控制台多出Starting ... using Java ...的全量装配日志,且第一次运行时间明显变长。 - 把
@MockitoBean UserService删掉,直接跑。你会观察到:Failed to load ApplicationContext,caused by 指向NoSuchBeanDefinitionException: UserService——这就第十五节表格第五行的现场版。 - 复制这个测试类改名成
UserControllerSliceTest2,只把 Mock 的打桩返回值从alice改成bob,然后跑整包。你会观察到:耗时几乎没涨(缓存 key 没变,上下文复用)。再在其中一个类上加@TestPropertySource(properties = "server.port=0")重跑——这次多出一份新上下文。这就是第十节那两个坑的量化的样子。
做一个「三层测试最小样板」:同一个下单接口,分别用 @WebMvcTest、@DataJpaTest、@SpringBootTest + Testcontainers 各测一次,并附一份 README 说明各自测什么。验收清单:
- [ ] 三个测试类能在
mvn verify下一次全绿,且切片测试单独跑mvn test也能全绿 - [ ] Web 层用例断言了状态码 + JSON 字段 + 至少一个响应头
- [ ] JPA 层用例验证了一个真实的
@Query(不是派生查询),并显式flush() - [ ] 全量用例用 Testcontainers 起了真 MySQL,
@AutoConfigureTestDatabase(replace = NONE)有写上 - [ ] 有一个用例专门验证唯一约束:不加
@Transactional,靠@Sql清理,第二次插入必须失败 - [ ] 统计并写下三档各自的单用例耗时,比例大致符合第十三节沙盘的形状
- [ ] README 里回答一句话:「如果只能保留一个全量用例,你留哪个,为什么?」
我能不能一眼说出「这个 Bug 该在哪一档测出来」——装配去全量、SQL 去 @DataJpaTest、路由与序列化去 @WebMvcTest / @JsonTest?
我的测试类里有没有那种「只有我在用的 properties」?如果有,它就是缓存杀手,要么合并配置,要么承认这个类每次都要重建上下文。
我有没有在任何 @Transactional 测试里,用「数据没留下来」来证明「逻辑是对的」?真实 HTTP 和新线程都不会被这个回滚保护。
断言失败时我会不会先加 andDo(print())?看不到请求响应体的调试都是猜。
我能写出 Failed to load ApplicationContext 的前两个真实原因吗(缓存 key 变化 / 切片缺 Bean)?
能开一盏灯就别推总闸;推了总闸就让它多测点真东西。
把这篇压成几句话——单元测试的盲区是「装配、SQL、序列化」,只有真容器或切片能覆盖;@SpringBootTest 用 webEnvironment 四选一:MOCK 最快、RANDOM_PORT 最真、NONE 最适合服务层;@Transactional 测试自动回滚,但对真实 HTTP / 新线程无效;MockMvc 走 MVC 全流程做链式断言,切片测试(@WebMvcTest / @DataJpaTest)只装关心的那一层;数据库策略从 H2 走向 Testcontainers,用真库消灭方言差异;mvn test 跑单元、mvn verify 才跑集成测试。记住这几句,你就知道「什么时候该启动真容器」,而不是把所有测试都做成慢吞吞的巨无霸。