1. 从一个单元测试的"噩梦"说起
先说个真实经历。前几年我接手一个老项目,代码结构基本是Controller里直接调DAO,业务逻辑全堆在Service里,方法动不动几十行。项目跑起来没问题,但一跑测试就崩:要么测试类之间互相干扰,要么Mock根本打不中,要么数据库连接超时。最惨的一次,我为了一个简单的接口加上测试,光排查Spring上下文加载问题就花了一整天。后来我才意识到,问题不在测试代码本身,而在于整个项目对"怎么测"这件事完全没有章法。
Spring Boot的测试体系其实做得非常完善——spring-boot-starter-test一句话引入,JUnit 5、Mockito、AssertJ、MockMvc全给你配好,@SpringBootTest能拉起完整上下文,@WebMvcTest能只测Controller层,@DataJpaTest能针对Repository做切片测试,再加上Testcontainers还能起真实容器。但问题恰恰出在这:工具太全,反而容易用错。很多人不管三七二十一,所有测试全上@SpringBootTest,结果一个测试类跑10秒,几十个测试类跑下来光上下文初始化就占了大半时间;还有人把单元测试写成了"迷你集成测试",Mock了一个寂寞,断言全靠运气。
这篇博文我想把Spring Boot测试这块从思路到实操完整捋一遍。适合谁看?用过Spring Boot但没系统整理过测试体系的人,刚接手项目需要补测试的人,以及想优化测试速度和稳定性的人。我会从测试分层设计说起,逐个拆解常用注解和工具的适用场景,再给出完整可复制的测试代码,最后附上我实际踩过的问题排查表。
我不打算写成API文档式的罗列,而是按照"什么时候用什么、为什么要这么用、用了之后怎么排查问题"这条线来走。这样你读完不是记住了一堆注解名字,而是能直接在自己的项目里做出判断:这个测试该写到哪个层,该切片还是该全量拉起,该用真库还是该Mock。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先搞清楚测试金字塔再动手写代码
2.1 为什么不能所有测试都上 @SpringBootTest
先说一个最常见的认知误区。很多人觉得Spring Boot测试就是@SpringBootTest,一个注解拉起整个Spring上下文,然后想测什么测什么。这话只对了一半。@SpringBootTest确实很强大,但它的问题在于太重:它会把配置类全部加载,连数据库、消息队列、第三方服务客户端全部初始化(除非你Mock掉)。一个中等规模项目,全部依赖加载完可能要5秒到10秒,哪怕只是测一个简单的工具方法,也得承受这个开销。
测试金字塔的逻辑是:底层单元测试最多,跑得最快;中间集成测试适度;上层端到端测试最少但最接近真实环境。如果你所有测试都堆在最顶层,测试套件的执行时间会变得不可接受——一旦测试跑得慢,开发者就不愿意本地跑,CI那边排队也越长,最后测试就形同虚设了。所以我的建议是:能用切片测试解决的问题,绝不上完整上下文;能Mock的依赖,尽量Mock;只有真正需要验证组件协作时才用@SpringBootTest。
2.2 一个合理单测的"套路"分层
从实践来看,我倾向于把测试分成这么几层:
- 纯单元测试:测试Service中的纯业务逻辑,依赖全部Mock。不加载Spring上下文,只靠Mockito完成。这是速度最快的一层,单个测试执行时间以毫秒计。
- 切片测试:用
@WebMvcTest测Controller层,用@DataJpaTest测Repository层,用@JsonTest测JSON序列化。这些只加载目标Bean和必要配置,速度比完整上下文快很多。 - 集成测试:用
@SpringBootTest+真实数据库或Testcontainers,验证各层协作。这层跑得慢,但不可替代。 - 端到端测试:整个应用启动,走真实网络请求,验证关键业务流程。这层一般数量很少,只覆盖核心路径。
这样分层之后,你只需要严格控制后两层的数量,测试套件整体速度就不会太离谱。我在实际项目里经常遇到的情况是:一个Service测试类有10个用例,如果采用@SpringBootTest可能跑10秒;如果改成纯Mockito单元测试,1秒内跑完。两者覆盖的业务逻辑几乎一样,但耗时差了十倍,这就是分层带来的直接收益。
3. 核心注解逐个拆解:什么时候用哪个
3.1 @SpringBootTest:全量上下文,适合集成验证
@SpringBootTest是Spring Boot测试的基础注解,它的作用就是启动完整的ApplicationContext,让测试直接注入项目里所有已有的Bean,比如Service、Repository、配置属性、自定义组件等。
典型用法是构造函数注入加JUnit 5的@Autowired:
java复制@SpringBootTest
class OrderServiceIntegrationTest {
@Autowired
private OrderService orderService;
@Test
void shouldCreateOrderWhenStockIsEnough() {
// 集成环境下真实调用,连接真实数据库或测试数据库
Order order = orderService.createOrder(new CreateOrderRequest("item-001", 2));
assertThat(order.getOrderNo()).isNotBlank();
}
}
这个注解还配合几个实用的属性:webEnvironment可以指定Web环境,比如RANDOM_PORT会启动真实Tomcat并随机分配端口,配合TestRestTemplate能发起真实HTTP请求;properties可以临时覆盖配置项。我会在后面集成测试章节详细展开。
但我要提醒一句:@SpringBootTest一般不要在Controller层单元测试里用。因为Controller测试主要关注请求映射、参数校验、响应格式,这些用@WebMvcTest就够了,不需要拉起Service底层的那一整套依赖。
3.2 @WebMvcTest:Controller层切片测试
@WebMvcTest是我最常用的切片注解。它只加载Web层相关配置——@Controller、@ControllerAdvice、@JsonComponent、Filter、WebMvcConfigurer等,Service和Repository都不会被自动扫描进来。这意味着你必须在测试里Mock掉Service依赖。
使用方法:
java复制@WebMvcTest(controllers = OrderController.class)
class OrderControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private OrderService orderService;
@Test
void shouldReturnCreatedOrder() throws Exception {
when(orderService.createOrder(any(CreateOrderRequest.class)))
.thenReturn(new Order("NO-001", "item-001", 2));
mockMvc.perform(post("/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"itemId\":\"item-001\",\"quantity\":2}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.orderNo").value("NO-001"));
}
}
@MockBean是Spring Boot对Mockito的扩展,它会把Mock替换进应用程序上下文,所以即使Controller里注入了OrderService,注入到的也是Mock对象。这里注意一个点:@MockBean会有一定的性能开销,因为它要修改上下文缓存。如果你对性能特别在意,Spring Framework 6.1开始推荐用@MockitoBean,它能区分Mock对象与Bean的作用域,不过在Boot 3.x里目前用的最多的还是@MockBean。
还需要注意,@WebMvcTest只扫描部分组件。如果你项目里有自定义的WebMvcConfigurer、HandlerInterceptor、参数解析器,需要确认它们会被扫描到。如果不在扫描范围,会导致测试环境和真实环境不一致。常用的解决方案是在测试类上用@Import显式导入你需要的配置类。
3.3 @DataJpaTest:Repository层测试与自动事务回滚
@DataJpaTest是Repository层的切片测试。默认情况下,它会扫描@Entity和@Repository,同时会启用嵌入式数据库(如果classpath里有H2的话)并自动包裹事务,每个测试方法执行完回滚。这个自动回滚特性特别好用,因为测试之间不会互相污染数据。
一个典型的例子:
java复制@DataJpaTest
class OrderRepositoryTest {
@Autowired
private OrderRepository orderRepository;
@Test
void shouldFindOrderByOrderNo() {
Order saved = orderRepository.save(new Order("NO-001", "item-001", 2));
Optional<Order> result = orderRepository.findByOrderNo("NO-001");
assertThat(result).isPresent();
assertThat(result.get().getItemId()).isEqualTo("item-001");
}
}
有几个细节值得注意。
第一,如果你不想用嵌入式数据库,而是想测真实MySQL或其他数据库,可以用@AutoConfigureTestDatabase(replace = Replace.NONE)配合实际配置的数据源。但这时自动回滚依然生效,只是依赖你的事务管理正常。
第二,@DataJpaTest默认不会加载完整的Service层,它只加载JPA相关的Bean。所以你不能在里面直接注入Service。如果你需要验证某个复杂的Repository方法在Service里的调用链,那更适合往集成测试方向走。
第三,如果项目用的是MyBatis而不是Spring Data JPA,那@DataJpaTest就不适用了。MyBatis项目通常用@MybatisTest(来自mybatis-spring-boot-starter-test)。这点很多人容易踩坑,看名字以为所有数据访问都归它管。
3.4 @JsonTest:专门测JSON序列化与反序列化
@JsonTest是个容易被忽略但很实用的小工具。它只加载JSON相关的配置,用于测试ObjectMapper对特定类型的序列化/反序列化是否符合预期。
比如你有一个LocalDateTime字段,担心格式化不对:
java复制@JsonTest
class OrderJsonTest {
@Autowired
private ObjectMapper objectMapper;
@Test
void shouldSerializeTimeField() throws Exception {
Order order = new Order("NO-001", LocalDateTime.of(2024, 1, 1, 12, 0));
String json = objectMapper.writeValueAsString(order);
assertThat(json).contains("\"createdAt\":\"2024-01-01 12:00:00\"");
}
}
这类测试在排查"接口返回格式与前端约定不一致"时特别好用,而且不需要Spring上下文里的一堆无关Bean,速度很快。我一般在写DTO、VO、出入参对象时会顺手补一个@JsonTest。
4. MockMvc实操:Controller测试从零到上手
4.1 MockMvc是什么,为什么用它
MockMvc并不是真的把服务跑到某个端口上,而是通过模拟HTTP请求来测试DispatcherServlet的处理链路。它的好处是:不需要启动真实Tomcat,速度很快,又能完整验证请求进来后Controller的行为、参数校验、异常处理、响应格式等。这也是@WebMvcTest默认就能注入MockMvc的原因。
使用MockMvc遵循"三段式":发起请求、执行断言、验证交互。下面我写一个完整的Controller测试,包含路径参数、Query参数、请求体、响应断言、异常场景。
4.2 一个完整可复制的Controller测试模板
java复制@WebMvcTest(controllers = OrderController.class)
@Import(OrderControllerAdvice.class) // 如果需要把全局异常处理器也拉进来
class OrderControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private OrderService orderService;
// 测试正常创建订单
@Test
void shouldReturnOrderWhenCreateSuccess() throws Exception {
when(orderService.createOrder(any(CreateOrderRequest.class)))
.thenReturn(new OrderResponse("NO-001", "item-001", 2));
mockMvc.perform(post("/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"itemId": "item-001",
"quantity": 2
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.orderNo").value("NO-001"))
.andExpect(jsonPath("$.itemId").value("item-001"));
}
// 测试参数校验失败
@Test
void shouldReturnBadRequestWhenQuantityIsInvalid() throws Exception {
mockMvc.perform(post("/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"itemId": "item-001",
"quantity": 0
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message").exists());
}
// 测试服务层异常是否被正确映射
@Test
void shouldReturnNotFoundWhenOrderNotExist() throws Exception {
when(orderService.getOrder("NO-999"))
.thenThrow(new OrderNotFountException("order not found"));
mockMvc.perform(get("/orders/{orderNo}", "NO-999"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.message").value("order not found"));
}
}
我特意用了Java 15+的文本块语法来写JSON,如果你项目还在Java 8,就换成字符串拼接。jsonPath那一串是Spring Boot测试里很常用的断言工具,基于JsonPath表达式。这里有个经验:对于返回体里的时间字段、金额字段,建议不要直接断言精确值,除非你明确知道序列化格式,否则断言exists()或isNotEmpty()会更稳。
4.3 文件上传、Session等特殊场景
MockMvc也能处理文件上传场景:
java复制MockMultipartFile file = new MockMultipartFile(
"file", "test.png", MediaType.IMAGE_PNG_VALUE, "fake-image-content".getBytes());
mockMvc.perform(multipart("/files")
.file(file)
.param("description", "test"))
.andExpect(status().isOk()); // 上下文里需要注册相应的转换器等
如果你是做前后端分离项目,Controller里可能用到Session或认证信息。MockMvc默认是无状态的,需要自己往perform里塞Session信息,这多见于传统服务端渲染项目。通常我会借助Spring Security的测试支持,用@WithMockUser这类注解来模拟登录用户。这块内容比较多,涉及到安全测试,等以后专门再写一篇。
4.4 MockMvc与@SpringBootTest配合的适合场景
@WebMvcTest够了的情况下就不需要@SpringBootTest。但有几种情况我会专门用@SpringBootTest(webEnvironment = RANDOM_PORT)配合TestRestTemplate做真实请求级验证:
- 想验证整个DispatcherServlet链路,包括Filter、拦截器、拦截器返回的JSON等,而不仅仅是Controller方法;
- 想验证静态资源映射、错误页配置等Web容器的行为;
- 项目里使用了
@ControllerAdvice,且在@WebMvcTest下没有被正确加载,而你不想花精力去弄@Import。
另外,我记得旧版Spring Boot里有个@SpringBootTest(webEnvironment = MOCK)的用法,默认就配置了MockMvc,可以直接注入。这种写法比@WebMvcTest多加载全部Bean,既然你有完整上下文,测Controller时Mock依赖反而别扭,建议能用切片就用切片。
5. Mockito与MockBean:让Mock打中,而不是打偏
5.1 打桩与verify的错误示范
Mockito属于Spring Boot Test全家桶里的标配,单独说它是因为实际使用中有太多初级错误。最典型的一个坑就是"mock没打中"——方法明明调了,但返回的还是默认值,断言就挂了。核心原因大多数是方法调用参数不匹配。
java复制// 错误示范
when(orderService.createOrder(new CreateOrderRequest("item-001", 2)))
.thenReturn(new Order("NO-001", "item-001", 2));
// 实际调用
orderService.createOrder(new CreateOrderRequest("item-001", 2));
除非CreateOrderRequest重写了equals(),否则这里两个对象不同,Mockito认为调用不匹配,返回默认null。正确的做法是用any()、eq()等参数匹配器:
java复制when(orderService.createOrder(any(CreateOrderRequest.class)))
.thenReturn(new Order("NO-001", "item-001", 2));
另一个常见问题是在同一个测试中连续给同一个方法打桩,结果前面的桩被覆盖了。Mockito的打桩规则是"后者覆盖前者",如果你需要按参数区分返回值,最好用thenAnswer。比如:
java复制when(orderService.getOrder(anyString()))
.thenAnswer(invocation -> {
String orderNo = invocation.getArgument(0);
if ("NO-001".equals(orderNo)) {
return new Order("NO-001", "item-001", 2);
}
throw new OrderNotFountException("order not found");
});
verify用来验证"这个方法确实被调用了N次",经常用于确认依赖之间的协作关系。写了fixture后忘了verify是个很可惜的事,比如你可以验证价格计算完成后真的调用了优惠券核销接口而不是空跑:
java复制verify(orderService, times(1)).deductCoupon("COUPON-001");
verify(orderService, never()).useFallbackCoupon(anyString());
5.2 @MockBean 在Spring上下文里的特殊行为
在Spring Boot测试里,@MockBean是往ApplicationContext里注册一个Mock类型,替换原来的Bean。这也带来了一个隐藏问题:如果两个测试类都用了@MockBean替换了同一个Bean,Spring会缓存上下文,导致第一个测试类的Mock状态残留影响第二个测试类。Spring Boot在同一个上下文缓存下不会自动清理@MockBean的状态,所以一个常见的坑是"第二个测试类跑的时候,Mock方法的打桩失效了"。
解决办法有这么几个方向:
- 在测试类的
@AfterEach里调用Mockito.reset()清除Mock状态; - 用
@DirtiesContext强制关闭上下文,但代价是让缓存失效,速度慢; - 尽量在不同测试类里保持MockBean使用的一致性,不要有的类Mock、有的类不Mock同一个Bean。
我最常用的还是@DirtiesContext,因为测试正确性比速度更重要,只在确实出现互相干扰时才用。
5.3 静态方法和构造函数的Mock
常规Mockito不能mock静态方法,Spring Boot 3.x里如果有需要,通常会配合Mockito的mockito-inline来支持。用法是mockStatic(ClassName.class),在try-with-resources块里做桩,用完后自动释放:
java复制try (MockedStatic<IdGenerator> mocked = mockStatic(IdGenerator.class)) {
mocked.when(() -> IdGenerator.nextId()).thenReturn("ID-001");
// 调用被测逻辑...
}
不过我还是要提醒一句:能不用静态Mock就不要用。测试代码里出现静态Mock,往往说明设计上依赖了静态方法,不利于可测试性。如果你在项目里能控制代码结构,尽量把静态方法封装成一个可以被替换的Bean,或者至少提供一个可以注入的接口。
6. 数据持久化测试:从嵌入式数据库到Testcontainers
6.1 H2嵌入式数据库:方便但别太当真
@DataJpaTest默认会用H2,因为H2能模仿大部分SQL行为且无需额外部署。但H2和真实MySQL/Oracle在语法细节、索引行为、事务特性上还是有差异。比如我的项目里用到了MySQL的json_contains函数,H2里就是另一套语法,这就导致"测试过了,线上挂了"的悲剧。所以在数据访问层,我特别强调:如果项目用的是复杂SQL、自定义方言、特定函数,一定要用真实数据库测试,至少关键查询要覆盖一遍。
如果项目SQL比较标准、主要测CRUD和简单查询,H2完全够用,还省去Docker依赖。如果项目里大量使用MySQL原生语法,建议直接上Testcontainers。
6.2 Testcontainers:真实数据库下验证SQL
Testcontainers的基本用法是启动一个Docker容器作为临时数据库。Spring Boot对它有原生支持,在测试类上配合@ServiceConnection就能自动配置数据源:
java复制@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class OrderRepositoryWithMySQLTest {
@Container
@ServiceConnection
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0");
@Autowired
private OrderRepository orderRepository;
@Test
void shouldFindOrderByOrderNo() {
// 这里是真的MySQL 8.0
orderRepository.save(new Order("NO-001", "item-001", 2));
assertThat(orderRepository.findByOrderNo("NO-001")).isPresent();
}
}
@ServiceConnection是Spring Boot 3.1开始引入的特性,它能把Testcontainers容器的连接信息自动配置成数据源,省去写@DynamicPropertySource的样板代码。如果你还在Spring Boot 3.0或更早版本,需要写:
java复制@DynamicPropertySource
static void datasourceProps(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", mysql::getJdbcUrl);
registry.add("spring.datasource.username", mysql::getUsername);
registry.add("spring.datasource.password", mysql::getPassword);
}
两种方式效果一样,只是新版本更简洁。
使用Testcontainers时有一个体验上的注意点:每次测试前都会启动容器,第一个测试会比较慢。所以请把Testcontainers测试集中在需要真实SQL验证的类里,不要每个测试类都起一个容器。另外,CI环境下必须确保Docker可用,否则整个测试套件会直接失败。
6.3 随机端口下的测试配置
有时候你想在集成测试里测试Web层真实端口的行为,比如验证WebSocket连接或第三方回调,可以这样:
java复制@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class WebSocketIntegrationTest {
@LocalServerPort
private int port;
// 用真实web容器测试,随机端口避免占用冲突
}
@LocalServerPort可以拿到当前实例随机分配的端口,你可以组装真实的WebSocket连接地址等。这在测WebSocket、长连接、回调这类MockMvc覆盖不了的场景时特别有用。注意WebEnvironment有两种常用值:RANDOM_PORT会启动真实嵌入式容器;MOCK只是模拟Servlet环境,不启动真实端口。
7. 高级场景:异步、Profile与配置隔离
7.1 异步方法的测试容易出问题
Service里若有@Async方法或者消息发布逻辑,测试时经常遇到"断言执行得太早,异步任务还没完成"。解决办法无非两种:
- 同步等待,用
Awaitility库轮询等待条件满足,推荐; - 在测试配置里把异步执行器改成同步执行器。
Awaitility是常用的测试库,写法大概是:
java复制await().atMost(5, TimeUnit.SECONDS)
.untilAsserted(() -> assertThat(notificationService.getSentCount()).isEqualTo(1));
如果你不希望测试依赖真实异步调度,项目里常把@EnableAsync改成测试配置下的同步模式。这需要你在测试resources里放一份配置或在测试类上添加@TestPropertySource。我个人更倾向于保留异步行为,用Awaitility等真实结果,这样测试覆盖到的路径更真。
7.2 Profile隔离:dev、test、prod各自的应用场景
Spring Boot的Profile体系在测试里也很常用。通常项目里有application-dev.yml、application-test.yml、application-prod.yml,测试类上可以指定激活哪个Profile:
java复制@ActiveProfiles("test")
@SpringBootTest
class OrderServiceTest {
// 用application-test.yml里的配置
}
@ActiveProfiles支持在类级别和方法级别同时生效,在集成测试里可以根据不同用例指定不同的Profile,比如有的用例用内嵌数据库,有的用Testcontainers的数据库连接配置。这里要提醒一个坑:Profile配置的优先级低于@TestPropertySource,如果你在测试里指定了properties属性,它会覆盖Profile文件里的同名配置。
7.3 不让测试污染你的开发环境数据
测试数据污染是个很常见的事故。我在一个项目里见过有人写了集成测试,结果把开发库里某个表的真实数据改了。原因就是测试配置没有隔离数据源。避免这个问题的核心原则:测试数据源不能和生产配置混用。
做法上可以从三方面入手:
- Profile隔离,比如
application-test.yml里明确配置测试数据库地址,@ActiveProfiles("test")指定测试环境; - 用Testcontainers起临时数据库,彻底隔离;
- 对普通单测,使用
@DataJpaTest自动事务回滚,确保测试后数据还原。
8. 常见问题排查与实战建议
8.1 问题速查表
我整理了几个在代码评审和日常Debug中遇到过的高频问题,方便对着排查。
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 测试启动极慢 | 使用了过多@SpringBootTest,加载了完整上下文 |
评估改为@WebMvcTest/@DataJpaTest切片测试 |
| Mockbean不生效,调用仍是真实实现 | MockBean替换和Bean注入时机问题,或Bean名称不一致 | 检查@Qualifier,确认被替换Bean的类型和限定名 |
| 测试间数据互相影响 | 没有事务回滚,或测试数据没有清理 | 用@DataJpaTest或者在@AfterEach清理数据 |
could not initialize proxy错误 |
在事务外访问懒加载关联对象 | 加@Transactional或用@EntityGraph预加载 |
| 上下文加载失败,Bean找不到 | 切片测试的组件扫描范围没有覆盖需要的Bean | 在测试类上@Import对应的配置类 |
测试里Optional为空,Mock没生效 |
入参不匹配或any()使用不彻底 |
检查参数匹配器,或改用anyXxx() |
| H2能过但MySQL报错 | SQL方言或函数差异 | 用Testcontainers跑真实数据库 |
@MockBean状态残留 |
上下文缓存导致Mock状态跨测试类共享 | 用@DirtiesContext或Mockito.reset() |
| 测试里异步任务未完成就断言 | 没有等待异步结果 | 用Awaitility轮询等待条件 |
| 随机端口端口冲突 | 端口配置写死或没有用@LocalServerPort |
项目里不要硬编码端口,测试里用注入端口 |
8.2 几个我坚持的测试习惯
写测试和写业务代码一样,也需要"设计感"。我自己的习惯是:
第一,测试类命名一定要能表达行为。不要全叫Test1、Test2,而是用shouldReturnOrderWhenCreateSuccess这种方式。测试名本身就是文档,别人看代码能直接知道你测的是什么行为。
第二,每个测试类最好只验证一个核心点。如果一个测试方法里同时断言了HTTP状态码、响应体内容、Mock调用次数、数据库落库状态,那这个用例出了问题,你根本不知道是从哪一环断的。拆成多个小用例,排查成本直线下降。
第三,静态导入断言库。Spring Boot默认用AssertJ,assertThat(...).isEqualTo(...)这套链式表达式比JUnit的assertEquals可读性强得多。配合jsonPath,可以直接省写很多中间变量。
第四,重要测试必须在CI里跑。本地跑通了不是真跑通,CI里才是最终防线。如果CI太慢,优先把@SpringBootTest改成切片测试,而不是删测试。
8.3 测试覆盖率的度怎么把握
很多人喜欢用JaCoCo看覆盖率,但覆盖率只能说明"代码被执行了",不能说明"行为被验证了"。我给一个务实的标准:核心业务逻辑(价格计算、状态流转、权限判断)一定要有测试,覆盖率至少80%以上;对外提供的接口,至少覆盖正常流程和几种异常流程;工具类、模板类可以少一点,但也不是零。
比覆盖率更重要的是"断言的有效性"。我看过一个项目,覆盖率挺高,结果一细看,测试里全是一句assertThat(result).isNotNull(),根本没有验证业务结果。这种测试等于白写。断言要具体,比如"返回的订单号等于NO-001""优惠金额等于10元",这才算测试。
9. 结尾:这套东西用下来最直接的体会
我在团队里推行这套测试体系大概半年后,最明显的变化不是覆盖率数字,而是"敢改代码了"。以前哪个Service方法逻辑动一下,所有人都提心吊胆,就怕把别的地方弄坏了。现在有了一层层测试托底:Controller行为变了,WebMvcTest会立刻报警;SQL改错了,Testcontainers那层会报错;业务逻辑算错了,纯Mockito那层第一个不答应。
最后一个经验:不要试图一次性把所有测试都补齐。先选一条最核心的业务链路,把单元、切片、集成三层打通,跑通了再复制到其他模块。这样阻力最小,也最容易看到效果。如果你在搞测试的时候也遇到过那种"Mock半天打不中""上下文加载慢到怀疑人生"的情况,不妨回去看看是不是用错了测试类型——先分清测试分层,再谈写用例,效率会高很多。
