集成测试进阶:@SpringBootTest 与切片测试

bee2026-10-0865 分钟0 次阅读
什么时候需要真容器?@SpringBootTest 的 webEnvironment 四种模式、@WebMvcTest/@DataJpaTest 切片测试、MockMvc 全流程断言与 Testcontainers 真数据库方案。
1 / 142
小节
〇、30 秒看懂
2 / 142

集成测试解决的是一类很具体的尴尬:单元测试全绿,一上线就炸。原因是单元测试里你手动 new 对象、手动塞假件,而线上跑的是容器装配出来的真对象——中间那段「零件到底有没有拼上」根本没人验证过。集成测试就是让容器真的拼一遍,切片测试则是只拼你要验证的那一小片。

3 / 142

先把五个词一句话解释(全文都会用到):

4 / 142
  • 上下文(ApplicationContext):Spring 那个帮你创建并管理所有对象的大管家,启动它 = 把整个应用的零件装一遍
  • 切片(Slice Test):只加载某一层的测试注解,比如 @WebMvcTest 只装 Web 层、@DataJpaTest 只装 JPA 层
  • MockMvc:不发真网络请求,直接把一个「假请求」喂给 Spring MVC 的处理入口,走完路由→参数绑定→序列化整条路
  • TestContext:Spring Test 的调度中枢,负责准备上下文、注入字段、执行回调、处理事务回滚这一整套流程
  • 上下文缓存:配置完全相同的测试会复用同一个上下文;只要 key 差一点,就得重建一个
5 / 142
类比

切片测试就像在自家书房改方案时只开那一盏台灯,而不是为了看一本书把整栋楼的电闸全推上去。@SpringBootTest 是「整栋楼通电」:灯全亮、空调全开、电梯全跑——最能发现问题,也最费电、最慢。聪明的做法是先问一句:这一处出错,只开台灯能不能看出来? 能就别拉总闸。

6 / 142
架构图
图 · 全量上下文 vs 切片:整栋楼通电还是只开一盏灯
图 · 全量上下文 vs 切片:整栋楼通电还是只开一盏灯
7 / 142

学完这一篇,你应该能回答三个问题:

8 / 142
  • 我的用例到底该用 @SpringBootTest、@WebMvcTest 还是 @DataJpaTest?判断标准是什么?
  • 为什么加了个 @MockitoBean,整个测试套件的耗时翻了三倍?
  • @Transactional 测试自动回滚,为什么会掩盖住唯一约束的真 Bug?
9 / 142
小节
一、单元测试的盲区:Mock 永远「太听话」
10 / 142

上一篇把单元测试的威力讲透了——它能毫秒级验证业务分支。但它有一个天生的盲区:Mock 只会按你的剧本回答,永远不会报错。于是有三类问题,单元测试全绿、线上却炸:

11 / 142
  • 装配错误:你在测试里 new UserService(mockRepo),一切正常;真实启动时容器里有两个 UserRepository 实现,谁都没加 @Qualifier,启动直接抛 NoSuchUniqueBeanDefinitionException。你测的是对象,运行时用的是容器。
  • SQL 错误:Mock 的仓库永远返回数据;可真实的 @Query 里字段名写成了 username,而实体属性其实是 userName。单元测试根本不会去解析那条 JPQL,直到接口一调就 500。
  • 序列化错误:DTO 里有个 LocalDateTime,没注册 JavaTimeModule。单元测试不经过 JSON 序列化,直到真实 HTTP 响应写出时抛 InvalidDefinitionException。
12 / 142

这三类事故的共同点是:问题不在业务逻辑里,而在「组件拼接」这件事上。这正是集成测试与切片测试要覆盖的领域。

13 / 142
小节
二、@SpringBootTest 全解:它的四个模式与配置
14 / 142

@SpringBootTest 做的事情就一件:启动真实的 Spring 应用上下文,把该装配的都装配上。它最常用的属性有两个:

15 / 142
java
// classes:指定用哪个配置类(默认找 @SpringBootConfiguration)// webEnvironment:决定起不起真实服务器、用哪种 Web 环境@SpringBootTest(        classes = DemoApplication.class,        webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)class UserApiIntegrationTest {    @LocalServerPort    private int port;          // 实际监听的随机端口,注入进来}
16 / 142

webEnvironment 有四种模式,差别很大,选错会付出「起不来」或「慢一倍」的代价:

17 / 142
对照表
模式行为提供什么适用场景
MOCK(默认)不启动真实服务器,只装配 Web 环境MockMvc / MockEnvironment大多数 Web 层测试,快
RANDOM_PORT启动内嵌服务器,随机端口真实 HTTP,配合 @LocalServerPort想验证真实网络栈、过滤器
DEFINED_PORT用配置端口(默认 8080)真实 HTTP,端口固定要与固定端口的东西联调
NONE完全不提供 Web 环境普通 ApplicationContext纯服务层 / 定时任务集成测试
18 / 142
原理动画
动图 · 一次集成测试的生命周期
动图 · 一次集成测试的生命周期
19 / 142
类比

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

20 / 142
原理动画
动图 · TestContext 的一次往返:从查缓存到复用
动图 · TestContext 的一次往返:从查缓存到复用
21 / 142

除了用主配置,你还能用 @TestConfiguration 补充「只在测试里存在」的 Bean——比如一个假的发短信客户端:

22 / 142
代码对照
代码java
@TestConfigurationclass TestBeans {    @Bean    @Primary                                     // 覆盖真实实现,不污染主配置    SmsClient fakeSmsClient() {        return (phone, msg) -> System.out.println("stub sms -> " + phone);    }}
解读

说明:@TestConfiguration 是「测试专用配置」,它不会被组件扫描自动拾取,必须通过 @Import 或作为静态内部类使用。这样做的好处是:测试需要的替身,绝不会误入生产上下文。

23 / 142

测试环境该配什么,其实是「生产配置的一份变体」——数据源换成内嵌库、DDL 交给 Hibernate、profile 单独一个 test。别去抄现成的 src/test/resources/application.yml,勾一遍,看它长出哪几行、每行去掉会怎样:

24 / 142
生成器
生成器测试环境的 application.yml:勾一遍就知道哪几行必须存在application.yml3 / 5
只勾「数据源」与「JPA」,看能跑起来的最小一组(内嵌库 + create-drop);再叠加「Profile」和「日志」,对照第七节「H2 与 MySQL 的方言差异」和第十二节那条「多一行 properties 就换一个缓存 key」;最后逐项取消,猜哪一行去掉会让上下文建不起来
产物
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
勾了这些,代价与理由在这里
datasource池参数写在这里才生效;写在代码里 new HikariDataSource() 就白配了。
jpaopen-in-view: never 关掉「请求期间懒加载」这条隐式事务延长线,避免 Controller 里查库把连接占满。
profiles + 分档配置多文档块用 --- 分隔,spring.config.activate.on-profile 指定生效条件。
25 / 142
坑

这张生成器里的 spring.datasource.url 一旦被换成真实 MySQL 的地址,而你又忘了关 @AutoConfigureTestDatabase,测试连上的仍然是内嵌库——配置被切片替换了,你写的 yml 根本没被采纳。这正是第七节 replace = Replace.NONE 那一行存在的原因。

26 / 142
小节
三、测试事务:@Transactional 自动回滚的机制与坑
27 / 142

集成测试最烦的是「数据污染」——A 用例插的数据影响 B 用例。Spring Test 提供了一个漂亮的解法:在测试类上加 @Transactional,每个测试方法结束后自动回滚。

28 / 142
java
@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");        // 方法结束,事务回滚,数据库恢复原样    }}
29 / 142

它为什么能回滚?因为 Spring Test 在测试方法外开了一个事务并绑定到当前线程的 ThreadLocal,测试里的所有操作复用了同一条连接,方法结束后由框架统一回滚。这也埋了两个坑:

30 / 142
  • 对 RANDOM_PORT 的真实 HTTP 请求无效:HTTP 请求跑在服务器的线程里,那条线程拿不到测试线程绑定的连接,所以请求本身的写操作不在测试事务内,回滚不了。
  • 它掩盖了约束问题:因为数据最终被回滚,一些外键、唯一约束问题可能被延迟到 flush 时才暴露,甚至被掩盖过去。
31 / 142
坑

@Transactional 测试回滚只对「同一线程内直接调用 Bean」有效。一旦走了真实 HTTP、新线程或异步方法,事务就断裂了——此时该改用 @Sql 清理或 @DirtiesContext,而不是指望回滚。

32 / 142

这条「同线程有效、换线程失效」的分界,光看结论记不住。下面这段动画把框架那笔事务的一生摆出来——重点看第 ⑥ 帧,那一刻写的行再也没有人回滚:

33 / 142
原理动画
动图 · 测试事务的开与回
动图 · 测试事务的开与回
34 / 142

再把它摊成一次单步执行。左边是这个用例真实会走的六行,右边同步刷新「此刻的变量」和「调用栈」——连点下一步,盯两处:第 ③ 拍那个「服务层以为提交了」,和第 ⑥ 拍换掉的线程名:

35 / 142
单步调试台
单步台跟着调试器走一遍:这行数据到底有没有落库1 / 6
按「下一步」走六拍,第 ③ 拍决定「断言为什么能读到」,第 ⑥ 拍决定「回滚为什么不管用」
被调试的代码
1@SpringBootTest(webEnvironment = RANDOM_PORT) // 第 ⑥ 拍的命运由这一行决定
2@Transactional // 标在测试类上,不是标在服务层
3Long id = userService.create("alice"); // 服务层自己也有 @Transactional
4assertThat(userService.getById(id)).isPresent(); // 读得到:写的和读的是同一条连接
5// afterTestMethod:框架无条件调用 connection.rollback()
6rest.getForEntity("/users/1", User.class); // 这一句跑在 Tomcat 的工作线程上
此刻的变量
线程main
上下文缓存 keyDemoApplication + test + {}
事务状态还没开
调用栈
1SpringExtension.beforeAll
2buildMergedContextConfiguration
1先分清两件事:@SpringBootTest 只负责把上下文准备好,事务此刻还不存在。而 RANDOM_PORT 意味着内嵌 Tomcat 真的在监听——这一条为第 ⑥ 拍埋下伏笔。
36 / 142

上面那句「服务层的提交只是投一票」,可以在内核里亲眼看一下——同一个 @Transactional,传播行为不同,结局完全不同:

37 / 142
内核实验
TeaVM服务层那笔事务,是不是测试这一笔未启动
选「外层回滚」:看清 REQUIRED 之下内层的提交如何被外层一句撤销吞掉;再切「REQUIRES_NEW」,那种写法会真的落库、回滚不掉
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
38 / 142
小节
四、MockMvc 实战:一条完整的断言链
39 / 142

MOCK 模式下不启动服务器,但你可以用 MockMvc 把请求「喂」进 DispatcherServlet,走完整个 MVC 流程:

40 / 142
代码对照
代码java
@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 的默认编码影响。

41 / 142
小节
五、真实 HTTP 调用:TestRestTemplate 与 WebTestClient
42 / 142

MOCK 的 MockMvc 不经过真实网络栈。要验证真实的 HTTP 行为(过滤器、序列化、状态码处理),用 RANDOM_PORT + 真实客户端:

43 / 142
对照表
客户端适用栈特点
MockMvcServlet(MOCK 模式)最快,不走网络,适合多数断言
TestRestTemplateServlet(RANDOM_PORT)阻塞式,写法简单,适合同步接口
WebTestClientWebFlux / 也可测 Servlet响应式,支持流式断言,功能更强
44 / 142
代码对照
代码java
@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 测不到的部分
  • 代价是慢:每次都要启动真实服务器,用到它的测试应尽量少而精
45 / 142
小节
六、切片测试:只要我关心的那一层
46 / 142

全量上下文太重。多数时候你只想测「Web 层」或「JPA 层」,此时用切片测试注解,Spring 只装配相关的组件,其他一律不加载:

47 / 142
架构图
图 1 · 从切片到全量:测试范围光谱
图 1 · 从切片到全量:测试范围光谱
48 / 142
对照表
注解加载范围典型用途
@WebMvcTest只加载 MVC 层(Controller / 过滤器 / 转换器)测控制器路由、参数、校验、异常处理
@DataJpaTest只加载 JPA 相关(实体 / Repository),默认内嵌数据库测派生查询、@Query、映射
@JsonTest只加载序列化相关(ObjectMapper / Jackson)测 JSON 序列化与反序列化
@RestClientTest只加载 HTTP 客户端相关测对外部接口的调用与解析
49 / 142

@WebMvcTest 只装 Web 层,所以 Controller 依赖的 Service 不会被装配——这时用 @MockBean 补一个:

50 / 142
java
@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"));    }}
51 / 142
代码对照
代码java
@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 默认每个测试方法都在一个事务里、结束后回滚,并且默认用内嵌数据库替换真实数据源。它加载的组件极少,所以非常快——但正因为它用的是内嵌库,方言差异会在下一篇的「真数据库」里成为重点。

52 / 142

上面这张表写的是「切片装什么」,但真实工作里你问的是反过来的问题:手上这个 Bug,该开哪一盏灯才能照到它? 来玩一局——左边点一类故障,右边点它最便宜的捕获手段,配错了当场告诉你为什么。

53 / 142
配对闯关
闯关这类 Bug 该在哪一层被抓到已配对 0/6 · 配错 0
六组都是真实工单里的现象,右边是「最便宜的那个捕获手段」——别靠位置猜,两列都打乱了
先点左边一个
54 / 142
小节
七、测试数据库策略:从 H2 到 Testcontainers
55 / 142

H2 内存库快,但它和 MySQL 有方言差异——原生函数、ON DUPLICATE KEY、分页语法、字段类型都可能对不上。于是一个残酷的现实出现了:H2 上全绿,切到 MySQL 直接报语法错误。

56 / 142
对照表
策略速度保真度风险
H2 内存库极快低方言差异导致「测试通过、生产失败」
本地真实 MySQL中高依赖本地环境,CI 里不稳定
Testcontainers(Docker 起真库)较慢(首次拉镜像)最高需要 Docker 环境,CI 需配置
57 / 142

Testcontainers 的思路很直接——测试时用 Docker 起一个真的 MySQL:

58 / 142
代码对照
代码java
@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 里要提前准备好。

59 / 142
小节
八、测试数据准备:别让用例互相污染
60 / 142

数据怎么造,决定了测试是否稳定。三种常见做法,按「越靠后越推荐」排序:

61 / 142
  • data.sql:启动时自动执行,适合每个类都需要的基础数据(如字典表)。缺点是全局生效,容易互相干扰。
  • @Sql:在单个测试方法或类上指定脚本,精确控制造数范围,还能配 executionPhase = BEFORE_TEST_METHOD。
62 / 142
代码对照
代码java
@Test@Sql(scripts = "/sql/users.sql",     executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)void should_list_active_users() {    assertThat(userService.findActive()).hasSize(3);}
解读
  • Builder / 工厂方法:最灵活、最清晰,测试数据就在测试代码里,一眼看清前置条件:
63 / 142
代码对照
代码java
static User aUser() {    return User.builder()            .username("alice")            .email("alice@example.com")            .status(Status.ACTIVE)            .build();}
解读

坑:测试数据要「自带」,不要「共享」。 依赖上一个用例留下的数据,是集成测试最常见的不稳定来源——单跑通过、全量跑挂掉,往往就是数据污染。优先用 Builder 在用例内造数,或在 @BeforeEach 里清库。

64 / 142
小节
九、CI 中运行测试:mvn test 与 mvn verify 的区别
65 / 142

在流水线里跑测试,先分清两条命令:

66 / 142
对照表
命令跑到哪个阶段会执行什么
mvn testtest 阶段只跑单元测试与切片测试
mvn verifyverify 阶段跑全部测试,并触发集成测试、failsafe 插件、覆盖率校验等
67 / 142

原因是 Maven 的两套测试插件分工不同:**surefire 跑 Test(单元 / 切片),failsafe 跑 IT(集成测试)并绑定在 verify 阶段**。所以集成测试类名通常以 IT 结尾:

68 / 142
代码对照
代码text
UserServiceTest.java     ← surefire,mvn test 就会跑UserApiIT.java           ← failsafe,只有 mvn verify 才跑
解读

提示:CI 里建议「快速反馈 + 完整校验」分两道:提交时跑 mvn test 快速给反馈,合并前跑 mvn verify(含 Testcontainers 的集成测试)做完整把关。失败重跑策略一句话——先只重跑失败的用例确认是否偶发,仍未通过就必须查根因,别靠重跑掩盖 flaky。

69 / 142

集成测试的范围应该覆盖请求链路的每一环,下面这段演示帮你把「一条请求到底经过了多少组件」看个明白:

70 / 142
内核实验
TeaVM被测试的其实是这条链路未启动
用 /users/42 走一遍,列出集成测试应该覆盖的每一环
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
71 / 142
小节
十、两个坑、一张决策卡与总结
72 / 142
坑

@MockBean 会污染上下文缓存,把测试拖慢。 Spring 会把「配置完全相同的上下文」缓存起来复用,而每出现一组不同的 @MockBean 组合,就会新建一份上下文。一个项目里若有几十种 Mock 组合,就等于启动几十次 Spring——集成测试的耗时就是这么涨上去的。建议把 @MockBean 的用法收敛、尽量集中。

73 / 142
坑

@Transactional 测试会掩盖约束问题。 因为数据最终回滚,外键、唯一约束的冲突可能被推迟到 flush 甚至根本触发不了。涉及约束的用例,用真实提交(去掉 @Transactional)+ @Sql 清理,才能暴露真实问题。

74 / 142
决策
决策一个团队维护一个 Spring Boot 后端,本地开发机没装 Docker,CI 环境支持 Docker,但流水线希望尽量快。测试策略应以哪种为主?
75 / 142
小节
十一、TestContext 到底做了什么:把「一次集成测试」拆开看
76 / 142

前面所有魔法——自动注入、自动回滚、上下文复用——都来自同一个引擎:TestContext(Spring Test 的调度中枢,一个在你写的第一行测试代码前后跑完全部流程的对象)。它的完整回合就是第〇节那张动图:

77 / 142
原理动画
动图 · TestContext 的一次往返:从查缓存到复用
动图 · TestContext 的一次往返:从查缓存到复用
78 / 142

对着图逐条落位,你就知道每个注解卡在哪个环节上:

79 / 142
对照表
回合TestContext 做的事你能插进去的东西
① 算 key把配置类、@ActiveProfiles、properties、@MockitoBean 组合成一个缓存键任何一项不同 → 换 key
② 查缓存命中就直接复用已有上下文想快就尽量让别人命中
③ 建上下文未命中才真的启动 Spring;切片在此过滤掉无关组件@WebMvcTest / @DataJpaTest
④ 注入字段把 @Autowired、@MockitoBean 填进测试实例别在构造器里用它们
⑤ 前置回调依次执行 @BeforeEach造数、打桩放这里
⑥ 跑测试执行方法体与断言;@Transactional 时全程复用一条连接真实 HTTP 会跳出这条连接
⑦ 收尾回滚事务,上下文放进缓存给下一个用例复用@DirtiesContext 强制不复用
80 / 142

这张表有七行,但「哪一拍慢」这种事读是读不出手感的。下面这张图把同一件事做成能点的东西——从 ① 一路点到 ⑥,重点停在第 ②③ 两格:

81 / 142
交互图解
流程一个用例的一生:它是怎么找到上下文的1 / 6
从 ① 点到 ⑥,第 ② 格决定你的测试是 5ms 还是 6s
→
→
→
→
→
① 算出指纹
把配置类、@ActiveProfiles、properties、@MockitoBean 组合全部拼成一个 key。这一步纯内存运算,永远不慢——但它决定后面所有的快与慢。
全部看懂了测试套件的耗时几乎不取决于你写了多少断言,而取决于第 ② 格命中了多少次。
82 / 142

亲手把这七步走一遍——切换参数看每一步的差别:

83 / 142
内核实验
TeaVM@SpringBootTest 到底装了多少东西未启动
选「@SpringBootTest 启动」,看全量上下文装配了哪些组件;再切「@WebMvcTest 切片」对比少了多少东西
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
84 / 142

上面那个实验的第二个参数,是全篇最重要的一课:

85 / 142
内核实验
TeaVM上下文缓存:为什么你的测试越跑越慢未启动
选「上下文缓存复用」,观察同 key 命中省掉多少时间;再看 Mock 组合如何把 key 拆散
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
86 / 142
  • 命中缓存 = 几乎零成本:第 100 个用例可能只花 5ms,因为它复用了第 1 个用例建好的上下文
  • 拆散缓存 = 每次都重新通电:多一行 properties、多一个不同的 Mock 组合,都会产生新 key
  • 所以工程纪律是:同一类测试的配置尽量写成完全一致,需要特殊配置的用例集中放一个包,别撒满全项目
87 / 142

先把「什么算进指纹」钉死——左边这一列改一个就换 key,右边这一列改一百次都照样命中:

88 / 142
架构图
图 · 上下文缓存的指纹:什么算进 key
图 · 上下文缓存的指纹:什么算进 key
89 / 142

真正能把这件事变成手感的,是缓存容量这个数字。Boot 默认只留 8 份上下文,而 CI 机器上没人提过它:

90 / 142
参数调节台
调节台上下文缓存只留 8 份,你的套件有几把钥匙?
spring.test.context.cache.max-size
8份上下文当前 1 – 32
Boot 默认:够日常,但没人检查过
  • 默认 8 对小型项目正好:切片几把钥匙 + 全量一两把
  • 判断依据很简单——把 @MockitoBean 组合、profile、properties 各数一遍
  • 指纹种类逼近或超过 8,日志里就会开始出现反复的 Refreshing ApplicationContext
  • 这份缓存在 JVM 退出前不会释放,8 份全量上下文就是几百 MB
缓存命中率85%
套件总耗时45%
测试 JVM 内存60%
先数指纹种类,再谈缓存大小——加 max-size 是给「配置太散」打止痛针,不是治病。
91 / 142

还有两件事只有 TestContext 能解释。第一,测试内的事务不是你写的 @Transactional 服务层事务——它由框架在回合 ⑥ 之前开启、回合 ⑦ 无条件回滚:

92 / 142
内核实验
TeaVM测试事务:跑完自动回滚未启动
选「测试内事务回滚」,看清框架在断言之后做了什么,以及数据为什么一行都不会留下
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
93 / 142

第二,切片为什么必须自己补 Mock——因为被过滤掉的组件根本没进容器:

94 / 142
内核实验
TeaVM@MockitoBean 是怎么顶替真 Bean 的未启动
选「@MockitoBean 替换」,看真实现被换成假件的过程,并留意它对缓存 key 的影响
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
95 / 142
小节
十二、从单元测试到集成测试:中间那段路谁在走
96 / 142

上一篇文章里,测试类是你手动 new 出来的;到了集成测试,创建对象的人换成了 TestContext。先复习一下纯 Mockito 那一侧的执行顺序,再对照容器这一侧:

97 / 142
内核实验
TeaVM没有容器时,测试类怎么跑未启动
先看「一个测试类的执行顺序」,再看「用 @Mock 替掉依赖」和「校验交互次数」——这三步在有容器时由 TestContext 接管
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
98 / 142

最后一个是本篇的收口:集成测试真正启动的,就是你在《启动流程》那篇见过的 SpringApplication.run()。区别只在于它跑在测试 JVM 里、并且跑完不回滚状态给外部:

99 / 142
内核实验
TeaVM集成测试里真的跑了一次启动未启动
选「八步主线」,对上第三节表格里「建上下文」那一步内部到底做了什么;再用「启动失败怎么读」练习看报错
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
100 / 142

实验按完了,换成命令行自己敲。下面这台控制台连着浏览器里的同一个内核,回显全部由内核算出来——先 beans 数一数全量上下文装了多少东西,再逐条 lab:

101 / 142
内核控制台
102 / 142
说明

beans 之后再敲 lab tctx slice,两次的组件数一比,第六节那句「切片只装本层」就不再是抽象话了。lab tctx tx 和 lab tx rollback 要连着敲:前者是 TestContext 那笔外层事务,后者是服务层自己的提交——把这两笔搞混,第十一节那个沙盘就读不懂。

103 / 142
小节
十三、沙盘:这个用例该开多大的灯
104 / 142

选择测试范围不是玄学,是一次明确的权衡:要多少保真度,愿意付多少秒数。下面这个沙盘把三种选择放在一起,切一档就能同屏看到启动耗时、内存占用、以及「有一类 Bug 能不能测出来」:

105 / 142
沙盘
沙盘测试范围选择器:开多大一盏灯
运行结果
单用例耗时:180ms ~ 1.2s
# 只装本层组件:@WebMvcTest 装 MVC,@DataJpaTest 装 JPA + 内嵌库
覆盖:路由、参数绑定、校验、序列化、派生查询与 @Query
测不出:Service/Repository 之间的装配冲突、跨层事务
同类切片共用一份上下文时可稳定命中缓存
日常主力。绝大多数「这层写错了」都能在这里被抓到,而且快到没人会跳过它。
106 / 142
说明

三档数字是示意,但比例是真的——一般后端的健康形态是「Mockito 占大头、切片居中、全量点缀」。如果你发现自己的分布反过来(全量最多),那基本可以断定:大部分全量用例其实在重复测切片就能测的东西。

107 / 142
小节
十四、随堂自测
108 / 142

先来一道热身题,考第九节那条「上下文缓存」的坑:

109 / 142
随堂自测
随堂自测项目里 30 个集成测试类本来共用一个上下文,跑 40 秒。有人在其中 6 个类上各加了不同内容的 @MockitoBean 之后,总耗时涨到 4 分钟。最可能的原因是?
先自己选一个,选中立刻告诉你对不对
110 / 142

再来一道综合题,把第三节的回滚陷阱和第七节的方言差异串起来:

111 / 142
随堂自测
随堂自测某个实体在 email 列上有唯一索引。测试类标了 @Transactional,里面连续两次 userService.create("a@x.com"),第二次照样抛 DataIntegrityViolationException,于是有人判断「约束生效了,测试通过」。下列哪种说法正确?
先自己选一个,选中立刻告诉你对不对
112 / 142
小节
十五、常见报错速查
113 / 142

新手被卡住的时间,八成花在「报错看不懂」上。这张表按原文片段排序,可以直接搜:

114 / 142
对照表
报错原文(片段)真实原因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 确认资源被复制本篇第八节
115 / 142
提示

这一栏里出现最多的关键词是 Failed to load ApplicationContext。它几乎从来不是那句后面的内容写错了,而是「上下文压根没建起来」——顺着 caused by 往下找第一个非 Spring 的类名,那里才是根因。

116 / 142

上面那条提示最好现场练一次。下面这段就是 @WebMvcTest 里最常撞的现场,先别看解析——点出你认为的凶手行:

117 / 142
报错急救
报错急救IllegalStateException: Failed to load ApplicationContext
切片测试起不来:整屏报错里没有一行是你的业务代码

新同事写了个 UserControllerSliceTest,用 @WebMvcTest 只测控制器,本地第一次跑就全红。他坚持认为「UserService 明明加了 @Service,容器里怎么可能没有它」。

java.lang.IllegalStateException: Failed to load ApplicationContext for [WebMergedContextConfiguration@4b8d7ca1 testClass = com.example.user.UserControllerSliceTest, locations = [], classes = [com.example.demo.DemoApplication], activeProfiles = [], propertySourceProperties = [], contextCustomizers = [WebMvcTestContextCustomizer@2f1a3b], contextLoader = SpringBootContextLoader, parent = null]
at org.springframework.test.context.cache.DefaultCacheAwareContextLoaderDelegate.loadContext(DefaultCacheAwareContextLoaderDelegate.java:124)
at org.springframework.test.context.junit.jupiter.SpringExtension.getApplicationContext(SpringExtension.java:111)
at org.springframework.test.context.junit.jupiter.SpringExtension.postProcessTestInstance(SpringExtension.java:98)
Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'userController' defined in file [UserController.class]: Unsatisfied dependency expressed through constructor parameter 0
Caused by: org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type 'com.example.user.UserService' available
Action:
Consider defining a bean of type 'com.example.user.UserService' in your configuration.
点你认为的「凶手行」(可反复试)
不会也没关系:先猜异常名,再猜哪一行在做决定。
118 / 142
小节
十六、动手练习
119 / 142
小节
第一档 · 照做
120 / 142

目标:把一个「测 Controller」的完整切片跑起来,亲眼看到 Service 不在容器里、由 Mock 顶上。

121 / 142

第一步,pom.xml 只需要这一个 starter(其余从 parent 继承):

122 / 142
xml
<dependency>    <groupId>org.springframework.boot</groupId>    <artifactId>spring-boot-starter-test</artifactId>    <scope>test</scope></dependency>
123 / 142

第二步,被测的控制器与服务:

124 / 142
java
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);    }}
125 / 142

第三步,测试类:

126 / 142
java
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());                                  // 把请求/响应整个打出来    }}
127 / 142

第四步,命令与预期输出:

128 / 142
bash
$ 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
129 / 142

看到 Started in 1.94 seconds 和 Tests run: 1 就算过关。注意启动横幅里没有 Tomcat started on port——切片压根没起服务器,这就是它快的原因。

130 / 142
小节
第二档 · 变体
131 / 142
  1. 在这个测试类上多加一行 @SpringBootTest,观察启动横幅出现 Tomcat、耗时上升,并思考缓存 key 变了什么。你会观察到:控制台多出 Starting ... using Java ... 的全量装配日志,且第一次运行时间明显变长。
  2. 把 @MockitoBean UserService 删掉,直接跑。你会观察到:Failed to load ApplicationContext,caused by 指向 NoSuchBeanDefinitionException: UserService——这就第十五节表格第五行的现场版。
  3. 复制这个测试类改名成 UserControllerSliceTest2,只把 Mock 的打桩返回值从 alice 改成 bob,然后跑整包。你会观察到:耗时几乎没涨(缓存 key 没变,上下文复用)。再在其中一个类上加 @TestPropertySource(properties = "server.port=0") 重跑——这次多出一份新上下文。这就是第十节那两个坑的量化的样子。
132 / 142
小节
第三档 · 造一个
133 / 142

做一个「三层测试最小样板」:同一个下单接口,分别用 @WebMvcTest、@DataJpaTest、@SpringBootTest + Testcontainers 各测一次,并附一份 README 说明各自测什么。验收清单:

134 / 142
  • [ ] 三个测试类能在 mvn verify 下一次全绿,且切片测试单独跑 mvn test 也能全绿
  • [ ] Web 层用例断言了状态码 + JSON 字段 + 至少一个响应头
  • [ ] JPA 层用例验证了一个真实的 @Query(不是派生查询),并显式 flush()
  • [ ] 全量用例用 Testcontainers 起了真 MySQL,@AutoConfigureTestDatabase(replace = NONE) 有写上
  • [ ] 有一个用例专门验证唯一约束:不加 @Transactional,靠 @Sql 清理,第二次插入必须失败
  • [ ] 统计并写下三档各自的单用例耗时,比例大致符合第十三节沙盘的形状
  • [ ] README 里回答一句话:「如果只能保留一个全量用例,你留哪个,为什么?」
135 / 142
小节
十七、要点自查
136 / 142
自检

我能不能一眼说出「这个 Bug 该在哪一档测出来」——装配去全量、SQL 去 @DataJpaTest、路由与序列化去 @WebMvcTest / @JsonTest?

137 / 142
自检

我的测试类里有没有那种「只有我在用的 properties」?如果有,它就是缓存杀手,要么合并配置,要么承认这个类每次都要重建上下文。

138 / 142
自检

我有没有在任何 @Transactional 测试里,用「数据没留下来」来证明「逻辑是对的」?真实 HTTP 和新线程都不会被这个回滚保护。

139 / 142
自检

断言失败时我会不会先加 andDo(print())?看不到请求响应体的调试都是猜。

140 / 142
自检

我能写出 Failed to load ApplicationContext 的前两个真实原因吗(缓存 key 变化 / 切片缺 Bean)?

141 / 142
口诀

能开一盏灯就别推总闸;推了总闸就让它多测点真东西。

142 / 142
总结

把这篇压成几句话——单元测试的盲区是「装配、SQL、序列化」,只有真容器或切片能覆盖;@SpringBootTest 用 webEnvironment 四选一:MOCK 最快、RANDOM_PORT 最真、NONE 最适合服务层;@Transactional 测试自动回滚,但对真实 HTTP / 新线程无效;MockMvc 走 MVC 全流程做链式断言,切片测试(@WebMvcTest / @DataJpaTest)只装关心的那一层;数据库策略从 H2 走向 Testcontainers,用真库消灭方言差异;mvn test 跑单元、mvn verify 才跑集成测试。记住这几句,你就知道「什么时候该启动真容器」,而不是把所有测试都做成慢吞吞的巨无霸。