做后端到现在,我每天早上到公司的第一件事基本已经固定下来:打开 IDE,跑一遍昨天改动相关的测试,等绿灯,再看有没有影响其他模块。一开始全项目也就几百个测试,用@SpringBootTest一把梭,跑完整套将近二十分钟,虽然慢一点,但忍一忍也就过去了。直到后来项目里引入了 Netty、MQTT、智能充电桩这类物联网接入场景,事情就变味了:一个@SpringBootTest要把 MQTT 连接、Netty 服务、JPA 初始化、Redis 缓存全部拉起来,单测跑得比接口联调还慢,而且只要网络层的 broker 没启,整片测试直接红。为了把测试速度拉回来,也为了让每一层测试都足够聚焦,我把 SpringBoot 3.x 的测试切片(Test Slices)系统地用了起来。这篇文章就是我在真实项目中踩完坑之后的完整复盘。
先说清楚这个内容适合谁:如果你正在用 SpringBoot 3.x 做后端开发,尤其是涉及物联网、消息中间件、多数据源这类“重上下文”的业务,那你一定需要搞懂测试切片。它能解决一个很具体的问题——我只想测 Controller 层,凭什么要把 Service、Repository、MQTT 客户端全部加载一遍?本质上就是把 Spring 容器的“全家桶点火”改成“单档口操作”,只把你关心的那部分自动配置和组件拉起来,测试速度能快一个数量级,代码也更好维护。
1. 为什么需要测试切片:从“全家桶点火”到“单档口操作”
1.1 启动整个 Spring 上下文的代价
很多团队在刚开始写测试的时候,最习惯的做法就是@SpringBootTest。这个注解会启动一个完整的 Spring 应用上下文,加载主配置类、扫描所有组件、执行所有自动配置,最终把整个应用需要的 Bean 全部实例化出来。听起来很“完整”,但这种完整是要付出代价的。
一个典型的物联网后端,可能同时包含下面这些东西:接入充电桩下行指令的 Netty 服务,订阅遥测数据的 MQTT 客户端,存储充电订单的 JPA 仓储,对外提供 REST 接口的 Controller,以及一些定时任务、缓存组件、消息处理器。用@SpringBootTest做一次上下文启动,少则三五秒,多则十几秒。测试类稍微多一点,每个类都重新加载一次上下文,CI 跑出来的耗时就是一个很恐怖的数字。更麻烦的是,这种全量启动的方式会让测试之间互相牵连,比如某个测试想验证充电桩状态接口,结果因为 MQTT broker 连不上直接启动失败,根本无法区分是哪一层的代码出了问题。
我在实际项目中遇到的最夸张的一次,就是充电桩遥测订阅组件里有一段重连逻辑,在本地测试环境 broker 正常时没问题,但 CI 的沙箱环境没有开对应的端口,整个@SpringBootTest加载直接失败。当时一查,日志里全是连接超时,可我在测试的只是一个纯 Controller 方法。这种场景多了以后,大家都会产生一个直觉:测试不应该依赖环境的完整性,每个测试只应该关注自己关心的一小片代码。
1.2 测试切片到底切掉了什么
Spring Boot 的测试切片就是为了解决上面的问题而生的。它的核心思想是:不是把整个应用塞进测试容器,而是根据你要测的层次,只加载对应的那一组自动配置和自定义组件。比如你要测 Controller,那就加载 MVC 相关的自动配置,把 Service 和 Repository 全部排除在外;你要测 Repository,那就加载 JPA 相关的自动配置,把 Controller、Netty、MQTT 全部排除在外。
这一“切”,带来两个特别直观的好处。第一是速度,因为上下文里 Bean 的数量从成百上千个降到了几十个甚至几个,启动时间从秒级降到几百毫秒,测试跑起来非常轻快。第二是隔离性,测试失败时你会非常容易定位问题,因为能影响这个测试的因素已经被压缩到了一个很小的范围里。比如@WebMvcTest里的测试挂了,大概率就是 Controller 层的逻辑问题,而不会是因为某个 MQTT 连接断了。
在 SpringBoot 3.x 这个版本下,测试切片的机制比老版本更成熟,而且框架层面的升级也带来了更好的上下文缓存策略。只要你的测试切片组合稳定,Spring 会复用已经加载过的上下文,避免重复初始化。这一点对 CI 优化特别有用。当然,后面我会讲到,上下文缓存也不是万能的,@MockBean、@MockitoBean这类注解会改变缓存键,使用不当照样会导致多次重启上下文。
2. 测试切片的底层原理:自动配置的“减法艺术”
2.1 切片是怎么被组合出来的
很多人用过@WebMvcTest、@DataJpaTest,但并不知道这些注解内部到底是怎么工作的。你如果点进@WebMvcTest的源码去看,会发现它是一个组合注解,融合了@BootstrapWith、@ExtendWith(SpringExtension.class)、@OverrideConfiguration、@TypeExcludeFilter、@AutoConfigureCache、@AutoConfigureWebMvc、@AutoConfigureMockMvc、@ImportAutoConfiguration这一大串东西。
这一大串注解里最关键的是两个机制。第一个是TypeExcludeFilter,它像一个门卫,决定了 Spring 在扫描组件时哪些 Bean 能够进入容器。测试切片会用一个特定的排除过滤器,把不属于这个切片的用户自定义组件全部挡在门外。第二个是@AutoConfigure*系列注解,它决定了哪些自动配置类会被加载。比如@AutoConfigureWebMvc会加载 Spring MVC 相关的自动配置,@AutoConfigureMockMvc会加载 MockMvc 支持。通过这样一“滤”一“配”,最终就形成了一个只包含目标层次相关能力的轻量上下文。
2.2 自定义 Bean 是怎么被过滤掉的
这里有一个最容易踩坑的点,也是初学者最不理解的地方:我用@WebMvcTest测 Controller,为什么项目里明明写了一个@Service,在测试里却拿不到?
答案就藏在TypeExcludeFilter里。@WebMvcTest默认使用WebMvcTypeExcludeFilter,这个过滤器会扫描类路径上所有组件,然后判断它们是不是属于 Web 层相关类型。具体来说,它只允许@Controller、@ControllerAdvice、@JsonComponent、Converter、Filter、WebMvcConfigurer这些类型的 Bean 进入容器。普通@Service、@Repository、@Component都会被默认排除掉。
这就引出了测试切片的另一个基本操作:当你的 Controller 需要调用 Service 时,你不能指望 Spring 自动把 Service 实例化出来,而是要用 mock 手段,把 Service 的依赖替换成一个 mock 对象。在 SpringBoot 3.4 以上版本,官方推荐使用@MockitoBean,更加干净的 mock 管理。在 3.4 之前的版本,则用@MockBean。两者的作用类似,都是往容器里注入一个 mock 对象,替换原来的真实 Bean,只是底层实现和参数解析的机制有一些差异。
2.3 上下文缓存与切片数量的博弈
Spring 测试框架会缓存应用上下文,只要上下文配置标识一致,多个测试类就可以共用同一个上下文实例。这个设计让测试套件的总耗时显著下降。但是,测试切片机制对缓存键很敏感,只要测试类上声明的切片注解、mock 注解、属性配置有任何一个发生变化,Spring 就会认为这是一个全新的上下文配置,必须重新加载。
实际开发中最常见的缓存破坏者就是@MockitoBean。假设你有三个 Controller 测试类,每个都声明了不同的@MockitoBean,那么每个类都可能对应不同的上下文缓存键,结果是三个测试类加载三次上下文。如果能在多个测试类之间保持相同的切片配置和 mock 声明,就能复用同一个上下文。这一点在优化 CI 耗时的时候特别有价值,值得专门拿出一段时间来调整测试结构。
3. 官方内置测试切片速查:到底该用哪个、能干什么
3.1 主切片速查表
Spring Boot 为我们内置了不少测试切片注解,几乎覆盖了日常开发的主流场景。下面这个表是我整理的常用速查,遇到对应层次直接选就行:
| 测试切片 | 核心自动配置 | 典型使用场景 |
|---|---|---|
@WebMvcTest | Spring MVC、MockMvc | 测试 Controller 层接口行为 |
@WebFluxTest | Spring WebFlux、WebTestClient | 测试 WebFlux Controller 或函数式端点 |
@DataJpaTest | JPA、内嵌数据库、TestEntityManager | 测试 Repository 和数据映射 |
@DataMongoTest | MongoDB 相关配置 | 测试 MongoRepository |
@DataRedisTest | Redis 相关配置 | 测试 Redis 操作 |
@DataCassandraTest | Cassandra 相关配置 | 测试 CassandraRepository |
@JsonTest | Jackson/Gson/Jsonb | 测试 JSON 序列化和反序列化 |
@RestClientTest | RestClient/RestTemplate/WebClient builder | 测试外部接口客户端 |
@KafkaTest | Kafka 相关配置 | 测试 Kafka 消费者/生产者 |
日常用得最多的大概就是@WebMvcTest、@DataJpaTest、@JsonTest和@RestClientTest。这几个我下面展开说说。
3.2 @WebMvcTest:测 Controller 的正确姿势
@WebMvcTest是我个人使用频率最高的测试切片。它的特点是会自动配置 MockMvc,让你不用真的启动 Web 服务器就能模拟 HTTP 请求。默认情况下,它只扫描 Web 层相关组件,Service、Repository 都不会加载。测试目标类通常通过@WebMvcTest(ChargingStationController.class)指定,这样 Spring 只加载你指定的 Controller。
在实际用的时候有两个点要特别注意。第一是这个切片默认会加载自定义的 Filter 和拦截器,如果你的应用中配置了一个需要连接外部服务的全局过滤器,测试可能会因此失败,这时可以用@AutoConfigureMockMvc(addFilters = false)把过滤器关掉,专注于验证 Controller 本身的逻辑。第二是它不会加载 Spring Security 的完整安全配置,需要安全上下文的话要用@WithMockUser之类的注解辅助,或者显式引入对应的 Security 测试配置。
3.3 @DataJpaTest:测试数据访问层的关键细节
@DataJpaTest用来测试 JPA Repository 层是非常顺手的。它默认会使用内嵌数据库替换你的真实数据库,并且在每个测试方法结束后自动回滚事务,保证测试之间互不影响。它也提供了一个TestEntityManager,可以用来方便地持久化测试数据,然后通过 Repository 方法查询验证。
使用@DataJpaTest时有一个关键判断点:你的 Repository 是否依赖了数据库特有的 SQL 功能,或者生产环境使用的数据库方言跟内嵌数据库差别很大。如果只是简单的 CRUD 和分页,内嵌 H2 通常没问题;如果涉及复杂的 JSON 字段、PostgreSQL 的特定类型,或者使用了数据库特有的 SQL 函数,那最好放弃内嵌数据库,改用@AutoConfigureTestDatabase(replace = Replace.NONE),配合 Testcontainers 启动一个真实的数据库镜像。Spring Boot 3.1 以后提供了@ServiceConnection,可以让你在测试里自动发现 Testcontainers 容器连接信息,这个组合在 3.x 项目中非常推荐。
3.4 @JsonTest 和 @RestClientTest:容易被忽略的两个好帮手
@JsonTest主要用来测试 JSON 的序列化和反序列化,比如你有一个充电桩上报消息的 DTO,想验证ObjectMapper能否正确把 JSON 字符串转成对象,或者把对象序列化成预期格式,直接用@JsonTest最合适。它提供了JacksonTester、JsonbTester、GsonTester这些测试工具,使用起来非常简洁。
@RestClientTest则用于测试项目中通过 RestClient 或 RestTemplate 调用外部服务的那一层。它自动配置了MockRestServiceServer,可以拦截 HTTP 请求并返回预设响应,从而在不发起真实网络请求的情况下验证客户端的 URL 拼接、请求头设置、响应解析逻辑。在我们的充电桩项目里,有一个调用第三方充电协议鉴权服务的客户端,就是用@RestClientTest写测试的,跑得非常快。
4. 实战:把测试切片用进 SpringBoot 3.x + Netty + MQTT 智能充电桩
4.1 项目背景与技术栈
为了把测试切片讲得更具体,我用一个真实的物联网场景来演示:智能充电桩管理系统。充电桩设备通过 MQTT 协议上报实时状态,Netty 服务负责维护与设备的长连接,后端需要把这些遥测数据入库,并提供 REST 接口给前端查询充电桩信息。技术栈就是标题里对应的那套:SpringBoot 3.x + Netty + MQTT + JPA。
在这种系统里,如果还是纯靠@SpringBootTest来跑测试,代价非常高。因为你要准备一个 MQTT broker,还要启动 Netty 服务端口,稍有不慎测试就会因为端口冲突或者 broker 不可用而失败。用测试切片之后,Controller 层的测试不依赖网络,Repository 层的测试不依赖 Controller,每一层都只需要关心自己。下面我会分别演示 Controller 层和 Repository 层的测试代码。
4.2 用 @WebMvcTest 验证充电桩查询接口
先看 REST 层。假设我们有一个充电桩查询 Controller:
@RestController @RequestMapping("/api/stations") public class ChargingStationController { private final ChargingStationService chargingStationService; public ChargingStationController(ChargingStationService chargingStationService) { this.chargingStationService = chargingStationService; } @GetMapping("/{id}") public StationDetailResponse getStation(@PathVariable Long id) { return chargingStationService.getStationDetail(id); } }正常情况下,ChargingStationService会从数据库查询,还会拼装一些领域数据。但在 Web 层测试中,我们并不关注这些,只关注 HTTP 路由、参数解析、状态码、响应 JSON 结构是否正确。所以用@WebMvcTest配合 mock Service 是再合适不过的:
@WebMvcTest(ChargingStationController.class) class ChargingStationControllerTest { @Autowired private MockMvc mockMvc; @MockitoBean private ChargingStationService chargingStationService; @Test void shouldReturnStationDetail() throws Exception { StationDetailResponse response = new StationDetailResponse( "P-001", 85, 30L, "CHARGING", "2025-01-01T10:00:00"); when(chargingStationService.getStationDetail(1L)).thenReturn(response); mockMvc.perform(get("/api/stations/1")) .andExpect(status().isOk()) .andExpect(jsonPath("$.stationCode").value("P-001")) .andExpect(jsonPath("$.status").value("CHARGING")); } }这个测试完全不关注 MQTT broker、Netty 服务、数据库连接。Service 被 mock 之后,测试只验证接口层的行为。真的跑起来,上下文加载可能连一秒钟都用不到,就算 MQTT 服务在那个环境不可用,也完全不影响这个测试通过。这就是测试切片带来的隔离性。
4.3 用 @DataJpaTest 验证充电记录仓储
再看数据层。假设我们有一个充电记录的表结构,想要验证查询最近充电记录的方法是否正确:
public interface ChargingSessionRepository extends JpaRepository<ChargingSession, Long> { List<ChargingSession> findByStationCodeAndStartTimeAfterOrderByStartTimeDesc( String stationCode, LocalDateTime startTime); }对应的测试,可以用@DataJpaTest加上TestEntityManager来做:
@DataJpaTest class ChargingSessionRepositoryTest { @Autowired private ChargingSessionRepository chargingSessionRepository; @Autowired private TestEntityManager entityManager; @Test void shouldFindSessionsAfterGivenTime() { LocalDateTime baseTime = LocalDateTime.of(2025, 1, 1, 0, 0); entityManager.persistAndFlush(new ChargingSession(null, "P-001", 100, 20, 65.5, baseTime)); entityManager.persistAndFlush(new ChargingSession(null, "P-002", 80, 40, 32.0, baseTime)); List<ChargingSession> result = chargingSessionRepository .findByStationCodeAndStartTimeAfterOrderByStartTimeDesc("P-001", baseTime); assertThat(result).hasSize(1); assertThat(result.get(0).getStationCode()).isEqualTo("P-001"); } }这个测试会自动使用内嵌数据库,测试结束自动回滚,所以哪怕你在同一个类里写了很多个测试,数据也不会互相干扰。如果你的项目里的 Repository 方法比较复杂,比如涉及原生 SQL 或者数据库函数,可以考虑引入 Testcontainers,用@ServiceConnection指向真实的 PostgreSQL,这样既能复用切片,又能保证数据库行为一致。
4.4 遥测消息处理链路怎么测
那是不是所有的测试都要用切片?也不是。充电桩上报 MQTT 消息,然后 Netty 服务解析消息,最后调用业务 Service 入库,这样一条跨网络层的完整链路,并不适合用切片硬测。切片擅长验证单层逻辑,而像这种“消息进来之后是否被正确解析、是否调用了正确的处理接口”,更适合写成纯单元测试,把 MQTT 消息的 payload 构造好,直接调用处理器的方法。
比如可以写一个纯粹的单元测试,验证 MQTT 遥测消息能转换成领域事件:
class ChargingTelemetryParserTest { private final ChargingTelemetryParser parser = new ChargingTelemetryParser(); @Test void shouldParseTelemetryPayload() { String payload = "{\"stationCode\":\"P-001\",\"currentPower\":85,\"duration\":30,\"energy\":65.5}"; ChargingTelemetry telemetry = parser.parse(payload); assertThat(telemetry.getStationCode()).isEqualTo("P-001"); assertThat(telemetry.getCurrentPower()).isEqualTo(85); } }这种测试连 Spring 上下文都不需要启动,速度几乎可以忽略不计。所以不要一听到“测试切片”就觉得所有测试都应该切成轻量上下文,正确的思路是:能不用容器启动的测试(纯业务逻辑、工具类、DTO 转换)就直接写 JUnit 单元测试;需要验证某一层的容器装配和组件协作时,再考虑切片。
4.5 什么时候升级回 @SpringBootTest
有同学可能会问:那@SpringBootTest是不是就没用了?当然不是。测试切片解决的是“快速反馈、局部验证”的问题,但有些场景必须用全量上下文来验证。比如你要验证 MQTT 消息从 broker 进来之后,是否完整地经过了消息处理器、业务 Service、Repository,最终落库到正确的表里。
这种跨层的集成测试,切片反而不适合,因为切片恰恰是把其他层排除掉。我会在项目里保留少数几个@SpringBootTest作为“冒烟测试”,只覆盖核心链路,其他大量细节交给切片或者纯单元测试。这样既保证了核心链路的完整性,又不会让整套测试变慢。
5. 自定义测试切片:覆盖内置切片够不到的地方
5.1 什么时候需要自定义测试切片
内置切片已经覆盖了大部分场景,但真正到了复杂项目里,你总会遇到一些“夹层代码”。比如我们的充电桩系统里,有很多@MqttHandler注解标记的消息处理器,它们负责处理不同主题的 MQTT 消息。按道理说,这些处理器属于业务逻辑层,你可以用纯单元测试,但这里有一个麻烦:它们往往注入了对象转换器、业务 Service、甚至一些外部网关客户端,纯单元测试要把这些依赖一个一个 mock 掉,写起来很痛。
如果直接用@SpringBootTest,又要启动整个应用上下文,每次跑这类测试都带着一堆无关 Bean,也不合适。这个时候,就很适合自定义一个测试切片:只加载带@MqttHandler的处理器,再加上 JSON 转换等必要配置,其他一切不加载。
5.2 动手做一个轻量级 @MqttSliceTest
自定义测试切片的核心思路是:用组合注解定义一个新的测试注解,通过TypeExcludeFilter限制组件扫描范围,通过@ImportAutoConfiguration引入必要的自动配置。
先定义一个注解,用来标记切片的候选处理器:
@Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) public @interface MqttHandler { }再定义组合注解:
@Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @Documented @Inherited @BootstrapWith(SpringBootTestContextBootstrapper.class) @TypeExcludeFilter(MqttSliceTypeExcludeFilter.class) @AutoConfigureJson @ImportAutoConfiguration public @interface MqttSliceTest { }接着实现MqttSliceTypeExcludeFilter:
public class MqttSliceTypeExcludeFilter extends TypeExcludeFilter { @Override public boolean match(MetadataReader metadataReader, MetadataReaderFactory metadataReaderFactory) throws IOException { // 只保留标注了 @MqttHandler 的类,其余全部排除 return !metadataReader.getAnnotationMetadata() .hasAnnotation(MqttHandler.class.getName()); } }别忘记给测试类配一个基础配置类,把需要加载的外部配置放进去,比如对象转换器或者 mock 的 MQTT 客户端。然后测试类就能这么写了:
@MqttSliceTest class ChargingStatusMqttHandlerTest { @Autowired private ChargingStatusMqttHandler handler; @Test void shouldHandleStatusMessage() { // 构造 MQTT 消息并断言处理结果 } }这个自定义切片的好处很明显:以后新增一个@MqttHandler,只要它需要测试,直接加上@MqttSliceTest就能跑,不用为每个类单独写上下文配置。对于几十个消息处理器的项目来说,收益是很可观的。
5.3 自定义切片的注意事项
自定义切片虽然好用,但不要过度设计。第一个要注意的是 TypeExcludeFilter 的匹配逻辑要尽量精确,白名单越窄越好,否则很容易把一些不该加载的 Bean 也放进来,污染切片上下文。第二个是@ImportAutoConfiguration要显式声明需要加载的自动配置类,不要幻想它能自动推断一切。第三是自定义切片不会自动包含内置切片的能力,比如它不会自动配 MockMvc,也不会自动配内嵌数据库,需要的话要自己加对应的自动配置注解。
还有一个非常现实的坑:自定义切片测试类如果没想清楚就设计得很复杂,很容易在排查问题上花费大量时间。所以我的建议是,先确认内置切片确实无法满足需求,再动手自定义。一般情况下,@WebMvcTest加上@DataJpaTest加几个纯单元测试,已经能覆盖百分之八九十的场景。
6. 常见问题与排查技巧实录
6.1 常见错误速查表
| 症状 | 根本原因 | 解决办法 |
|---|---|---|
测试里@Service为 null,NPE 满天飞 | 测试切片默认排除了 Service Bean | 用@MockitoBean或@MockBean注入 mock |
@WebMvcTest启动报“Failed to load ApplicationContext” | Controller 依赖了未加载的组件,或者全局 Filter 需要外部依赖 | 检查 Controller 依赖;用addFilters = false关闭过滤器 |
@DataJpaTest报“Unable to determine embedded database driver class” | 缺少内嵌数据库依赖 | 添加 H2 或改用 Testcontainers |
| 测试方法之间数据互相影响 | 没有事务回滚或数据库状态残留 | @DataJpaTest默认会回滚,检查是否用了手动 commit |
| 多个测试类分别重新加载上下文,速度很慢 | @MockitoBean声明不一致,缓存键变化 | 尽量让同类测试拥有相同的 mock 声明,复用上下文 |
@JsonTest序列化结果和预期不一致 | 自定义 Jackson 配置没有加载 | 检查@JsonComponent是否被扫描到,必要时用@Import引入配置 |
6.2 我个人的排查套路
遇到测试起不来的情况,我一般按照下面这个顺序去查。第一,确认依赖是否齐全,特别是内嵌数据库、AssertJ、MockMvc 这些测试依赖,很容易因为缺一个坐标导致自动配置不生效。第二,看测试类上有没有故意写一些不匹配的注解组合,比如既加@SpringBootTest又加@WebMvcTest,这种事我见过不少,最后导致上下文加载逻辑互相干扰。第三,打印实际加载的 Bean 数量,如果发现比你预期多出很多,多半是某个@ComponentScan或者@Import把无关配置拉进来了,这时候要回到TypeExcludeFilter的过滤逻辑上。
还有一个小技巧是,当测试切片里出现一个诡异问题,怎么都定位不到原因时,可以临时加一个@SpringBootTest(classes = 某个最小配置类.class)做一个最小化复现,看问题到底是切片机制引起的,还是业务代码本身的问题。这个方法帮我省过不少时间。
6.3 最后再分享两个实操心得
第一个心得是:给测试分类。我在项目里喜欢把测试分成三类——纯单元测试、切片测试、全链路冒烟测试。纯单元测试负责业务逻辑和工具类,切片测试负责各层的装配与协作,全链路冒烟测试只保留几条核心链路。这样的分层让 CI 结构非常清晰,平时开发的节奏很快,只有提交到主干之前才去跑完整的冒烟测试。
第二个心得是:不要羞于改动测试代码。很多团队初期用@SpringBootTest写了一大堆测试,后来意识到太慢,却因为“测试已经能跑就不动它”而一直留着。我通常建议趁着新功能开发的机会,把老测试一点点拆成切片测试。改完之后你会发现,每次跑测试的时间短了,失败的反馈更精准了,连带着代码的模块边界也会更加清楚。这不只是测试层面的提升,对整个项目的架构演进也有好处。