我刚接触单元测试那会儿,心里其实挺抵触的。总觉得业务都写不完了,哪来的时间写测试?直到后来在某个项目里改一个公共校验方法,本地跑了十分钟都没问题,一上线就把一个老接口的数值精度带歪了,排查到凌晨才定位到——竟然是有一条边界分支没有被任何测试覆盖到。那次之后我彻底改变了看法:问题根本不在于“要不要写测试”,而在于“怎么把测试写得又快又准又省心”。这篇文章就把我在SpringBoot项目里,从零搭建JUnit5+Mockito单测体系的过程和心得完整捋一遍,聊聊每个设计选择背后的原因,也分享一些网上文档不会写的坑。
这套内容适合谁?刚工作不久、知道JUnit但没系统用过Mockito的后端开发,或者写了一段时间测试但全是“为了覆盖率而凑数”的团队。我会从整体策略讲到依赖配置,再从分层实战讲到问题排查,全程拿一个订单库存扣减的模拟业务当例子,尽量做到每个步骤都能直接抄到自己的项目里。
1. 先想清楚:你要搭的是“测试体系”,不是“一堆测试用例”
1.1 单元测试的边界到底在哪
很多团队对单元测试的理解其实是有偏差的。最常见的做法是:把项目启动起来,连上测试库,用SpringBootTest把Controller、Service、Repository全串起来跑一遍,然后管这个叫“单元测试”。这其实是集成测试,跑一趟少则十几秒多则几分钟,一旦依赖的下游服务不通,测试直接红给你看,维护成本非常高。
真正的单元测试,核心是“快”和“准”。快是指单个测试类应该在毫秒级完成,不需要启动完整容器,不需要连真实的数据库或消息队列;准是指当测试失败时,你能立刻判断出是哪个业务逻辑出了问题,而不是被环境问题、数据问题干扰。要做到这一点,核心手段就是依赖隔离——也就是用Mockito去mock掉被测类外面的那些协作者。
换句话说,单元测试的名称里带着“单元”两个字,这个单元指的是“你想验证的最小逻辑块”,不是一个完整的业务流程。我们要测的是:给定这样那样的输入,目标类的业务逻辑是否正确处理了数据、是否正确地调用了依赖、是否在异常情况下抛出了预期的异常。
1.2 测试金字塔和SpringBoot的天然分层结构
我强烈建议在动手写代码前,先在脑子里画一遍测试金字塔(不是那种复杂的图,就是一个分层意识)。金字塔底层是大量快速、廉价的单元测试,中间层是数量适中的集成测试,顶层是少量端到端测试。大多数业务代码的测试重心,应该压在底层单元测试上。因为一个100个方法的服务类,如果你只靠端到端测试来保障,等于每次都要用大炮打蚊子,既贵又难排查。
SpringBoot的分层结构其实和这个金字塔天然吻合。Controller、Service、Repository三层切得非常清晰,每一层天然适合不同粒度的测试策略。我自己在实际项目里通常用下面这套分工方案:
| 测试层次 | 主要测试工具 | 速度 | 依赖资源 | 验证重点 |
|---|---|---|---|---|
| Controller层 | JUnit5 + MockMvc + Mockito | 较快 | 无真实中间件 | HTTP契约、参数解析、状态码、响应结构 |
| Service层 | JUnit5 + Mockito | 极快 | 无 | 业务规则、异常分支、依赖交互 |
| Repository层 | JUnit5 + @DataJpaTest | 较快 | 内存数据库 | SQL映射、实体关系、自定义查询 |
| 跨模块流程 | SpringBootTest + Testcontainers | 慢 | Docker容器 | 模块集成、事务、真实中间件行为 |
这里有个关键经验:不要把Service层的测试写成“轻量级集成测试”。Service层的每一个外部依赖都用mock替代,这样你才能精准控制各种异常场景。比如数据库抛一个唯一键冲突异常,你在集成测试里很难稳定复现,但在mock环境里只要给一行when配置就行。这种可控性,就是单元测试最大的价值所在。
2. 搭基础:依赖引入与JUnit5生命周期
2.1 一个依赖搞定90%的测试框架
写SpringBoot项目,绝大多数情况下你不需要手动去配JUnit5和Mockito的坐标,直接引入spring-boot-starter-test就够了。它的传递依赖已经包含了JUnit5、Mockito、AssertJ、Hamcrest、JSONassert等一大堆测试常用库。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency>有一点要特别提醒:如果你用的是SpringBoot 2.2版本之前的老项目,这个starter默认带的是JUnit4。从2.2版本开始默认才切到JUnit5。如果你在升级老项目,注意检查pom里是否残留了junit:junit的显式依赖,如果混着用就会出现“测试类能编译但压根没跑起来,或者IDE里能跑但命令行mvn test一个用例都没执行”的诡异情况。正确的做法是保留JUnit5一条链路,如果有旧的JUnit4用例,要么把代码迁移到新注解上,要么只引入运行JUnit4用例的vintage引擎过渡一段时间,千万不要两个引擎混在一个模块里长期共存。
另外,Mockito本身有个容易踩的版本陷阱。如果你要用mockStatic来mock静态方法,需要确认Mockito版本是4.x以上,并且项目中要能支持将mock引擎切换到inline mock maker。在SpringBoot 2.7对应的spring-boot-starter-test里,Mockito版本已经足够新,所以一般不用操心;但如果你在非SpringBoot环境下用,记得单独检查版本。
2.2 JUnit5生命周期注解的正确用法
JUnit5的生命周期注解和JUnit4不太一样,刚开始很容易搞混。这里我直接列一份对照:
@BeforeAll:类加载后只执行一次,适合创建重量级资源,比如Mockito的MockitoSession或固定的测试数据容器。在JUnit5里,这个方法默认要求是static的,除非类上加了@TestInstance(Lifecycle.PER_CLASS)。@BeforeEach:每个测试方法前执行,适合初始化被测对象、重置mock状态、准备通用数据。@AfterEach:每个测试方法后执行,适合清理临时文件、校验mock交互收敛。@AfterAll:整个测试类结束后执行,适合关闭资源。@DisplayName:给测试方法起一个人类可读的名字,跑报告时一目了然。@TestMethodOrder:按指定顺序执行测试方法,通常配合@Order(n)使用。
一个常见的误区是:有人习惯把mock对象的初始化放在@BeforeAll里,想着“反正这些mock在整个测试周期内都不变,能省则省”。这个思路在多数场景下没问题,但如果你写的测试里需要重置mock的stubbing,或者要对不同用例设置不同的mock行为,放在@BeforeAll里反而会碍手碍脚,因为各用例之间会互相污染状态。我个人的习惯是:@BeforeAll只放不依赖具体测试场景的基础对象创建,比如ObjectMapper;mock对象和被测对象全部放进@BeforeEach,这样每个用例都从干净状态开始。
2.3 断言库选型:AssertJ的链式风格更利于读测试
SpringBoot starter里自带了AssertJ,这是我在项目中最常用的断言库。相比JUnit自带的assertEquals和assertTrue,AssertJ最大的优势是可读性和链式组合:
assertThat(order.getTotalAmount()).isEqualTo(new BigDecimal("199.80")); assertThat(order.getItems()) .hasSize(3) .extracting("productCode") .contains("P001", "P002"); assertThat(result.isSuccess()) .withFailMessage("扣减库存应成功,当前失败原因=%s", result.getFailReason()) .isTrue();很多人以为断言就是把“值和预想的值比一比”,实际上断言的表达方式决定了出问题时的排查效率。比起写一句assertTrue(order.getItems().size() == 3),用AssertJ的hasSize(3).extracting(...)能让你在失败报告里直接看到整个集合内容以及具体哪个字段缺失。多花几秒把断言写清楚,将来调试时能省下几小时。
对于异常断言,JUnit5的assertThrows是推荐的写法,它会自动捕获抛出的异常并返回异常对象供后续校验:
BizException ex = assertThrows(BizException.class, () -> stockService.deductStock("P001", -1)); assertThat(ex.getCode()).isEqualTo("PARAM_INVALID");以前有人喜欢用expected注解方式或try/catch + fail的老套路,前者会打断测试的自然流程,后者容易漏写fail导致“异常没抛但测试还是绿的”这种假象。assertThrows把这两类坑都堵上了。
3. Mockito思想:什么该mock,什么不该mock
3.1 判断“mock边界”的核心标准
Mockito用起来门槛不高,但用得好不好,关键在边界判断。我的经验是三个问题来判定:
- 被测类是否依赖了外部进程资源,比如数据库、Redis、消息队列、远程HTTP接口?
- 被测类是否依赖了当前不好控制的设施,比如系统时钟、UUID生成器、随机数?
- 被测类是否依赖了本模块还没实现或实现起来很重的协作者?
只要命中其中一条,就应该在单元测试中把这个依赖mock掉。反过来,如果一个依赖是纯粹的、无状态的计算类,或者是一个不可变的值对象,那就不该mock——因为mock它们会掩盖真实的序列化、转换逻辑,甚至让测试测出一个和真实行为不一致的“假象”。
举一个典型的例子:Service层依赖一个OrderNoGenerator接口,用来生成订单号。如果这个接口背后实现是“日期+随机序列号”,你在测试里如果直接mock掉它,确实是省事了,但如果你忘了给mock配置返回值,运行时会得到null,然后一路空指针炸下去。这种问题最好的做法不是在测试里mock,而是把OrderNoGenerator设计成可注入的接口,并且在生产代码里注入一个真实实现、测试代码里注入一个固定值实现。这种“依赖注入+测试替身”的思路,比单纯用Mockito更干净。
3.2 Mockito高频API速览与参数匹配器
在项目里,Mockito使用频率最高的API就是下面这些:
mock(Class):创建mock对象。when(mock.method(args)).thenReturn(value):配置返回值的典型方式。doReturn(value).when(mock).method(args):写法不同,适合需要先处理异常场景或void方法的场景。doThrow(exception).when(mock).method(args):配置抛出异常,常用于测试失败分支。doAnswer(InvocationOnMock):当需要根据传入参数动态构造返回值时使用。verify(mock).method(args):校验某个方法是否被调用过。verify(mock, times(2))、never():校验调用次数。verifyNoMoreInteractions(mock):确认mock的依赖上不再有多余交互。@Mock、@InjectMocks:通过注解方式创建mock和注入被测对象。@Spy:对真实对象做部分mock,保留真实行为但可选择性覆盖。
参数匹配器也是几乎每个测试都会碰上的点,最经典的坑就是“混用匹配器和具体值”。看看这段错误代码:
when(orderMapper.findByStatusAndName(anyInt(), "P001")).thenReturn(list);报错信息会抛InvalidUseOfMatchersException,因为Mockito要求同一方法的所有参数要么全部用匹配器,要么都不用。正确写法是:
when(orderMapper.findByStatusAndName(anyInt(), eq("P001"))).thenReturn(list);我看到很多新手在这个问题上耗了很久,所以提前写出来,碰到这个异常时先检查一下参数写法。
3.3 进阶能力:静态方法mock与Clock注入
Mockito从4.x开始支持静态方法的mock,这是一个救急利器。比如在某个工具类里,你有一个静态方法IdGenerator.next(),在测试中想让它返回固定值,可以这样写:
try (MockedStatic<IdGenerator> mockedStatic = mockStatic(IdGenerator.class)) { mockedStatic.when(IdGenerator::next).thenReturn("FIXED-ID-001"); assertThat(service.createOrder(...)).isEqualTo("FIXED-ID-001"); }注意mockStatic返回的对象需要调用close()来关闭,最稳妥的就是用try-with-resources。如果不关,它会一直mock到当前线程的下一个测试里,很容易造成测试互相污染。
不过我要多说一句:静态方法mock可以用,但不要滥用。如果一个系统里到处都是静态方法,往往意味着设计的可测试性出了问题。更优雅的替代方案是注入一个Clock对象,生产代码用Clock.systemDefaultZone(),测试代码用Clock.fixed(...)。这样你既不用mock静态方法,还能精确控制“当前时间”,测试的稳定性和可读性都高一个档次。
4. 分层实战:Service/Controller/Repository三层测试拆解
4.1 Service层测试:业务规则的核心防线
Service层是单元测试的主战场。绝大多数业务规则、状态流转、异常判断都在这里,而且这一层的依赖基本都是可以mock的。我拿一个库存扣减的例子来演示完整过程。假设有这样一个服务类:
@Service public class StockService { private final StockRepository stockRepository; private final StockClient stockClient; public StockService(StockRepository stockRepository, StockClient stockClient) { this.stockRepository = stockRepository; this.stockClient = stockClient; } public DeductResult deductStock(String productCode, int quantity) { if (quantity <= 0) { throw new BizException("PARAM_INVALID", "扣减数量必须为正整数"); } StockAccount account = stockRepository.findByProductCode(productCode); if (account == null) { throw new BizException("PRODUCT_NOT_FOUND", "商品不存在"); } if (account.getAvailableStock() < quantity) { throw new BizException("STOCK_NOT_ENOUGH", "库存不足"); } account.deduct(quantity); stockRepository.save(account); stockClient.notifyStockChanged(productCode); return DeductResult.success(account.getAvailableStock()); } }先创建一个对应的测试类:
class StockServiceTest { @Mock private StockRepository stockRepository; @Mock private StockClient stockClient; @InjectMocks private StockService stockService; @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); } @Test @DisplayName("库存充足时,扣减成功并通知仓储系统") void deduct_should_success_when_stock_enough() { StockAccount account = StockAccount.builder() .productCode("P001") .availableStock(10) .build(); when(stockRepository.findByProductCode("P001")).thenReturn(account); DeductResult result = stockService.deductStock("P001", 3); assertThat(result.isSuccess()).isTrue(); assertThat(result.getAvailableStock()).isEqualTo(7); verify(stockRepository).save(account); verify(stockClient).notifyStockChanged("P001"); } @Test @DisplayName("库存不足时,抛出业务异常且不触发远程通知") void deduct_should_throw_when_stock_not_enough() { StockAccount account = StockAccount.builder() .productCode("P001") .availableStock(2) .build(); when(stockRepository.findByProductCode("P001")).thenReturn(account); BizException ex = assertThrows(BizException.class, () -> stockService.deductStock("P001", 5)); assertThat(ex.getCode()).isEqualTo("STOCK_NOT_ENOUGH"); verify(stockRepository, never()).save(any()); verify(stockClient, never()).notifyStockChanged(any()); } @Test @DisplayName("数量非法时,直接抛出参数异常") void deduct_should_throw_when_quantity_invalid() { assertThrows(BizException.class, () -> stockService.deductStock("P001", -1)); verifyNoInteractions(stockRepository, stockClient); } }通过这个例子可以看到,mock掉Repository和Client之后,测试完全聚焦在StockService自身的逻辑上。正常路径校验了返回值,异常路径校验了异常码,还通过verify和never验证了“库存不足时不能保存也不能通知下游”。这个“行为校验”是单元测试里非常值钱的部分,它能直接确认业务规则的关键副作用。
写这类测试时有两个经验:一是构造测试数据建议用Builder模式,让每个用例的数据意图一眼能看出;二是不要在同一个测试里塞太多分支,一个测试只验证一种行为,失败时定位会非常快。
4.2 Controller层测试:用MockMvc模拟HTTP请求
Controller层不适合用普通Mockito来测,因为它的核心职责是HTTP契约:URL路由、参数绑定、响应状态码、JSON结构。哪怕你拿到一个Controller对象,能调用方法,也没法验证@PathVariable和@RequestParam是否正确映射,更没法验证请求body反序列化后的对象是否符合预期。
所以Controller层测试最常用的方案是MockMvc,它启动一个轻量级的Web环境来模拟完整的HTTP请求,但又不需要真正占用端口。这是SpringBoot测试里非常优雅的设计——既保留了真实Controller的链路,又不会启动Tomcat拖慢速度。
@WebMvcTest(controllers = OrderController.class) class OrderControllerTest { @Autowired private MockMvc mockMvc; @Autowired private ObjectMapper objectMapper; @MockBean private OrderService orderService; @Test @DisplayName("创建订单接口:成功返回订单号和金额") void createOrder_should_return_order_info() throws Exception { CreateOrderRequest request = new CreateOrderRequest("P001", 2); OrderResult result = new OrderResult("ORD-001", new BigDecimal("199.80")); when(orderService.createOrder(any(CreateOrderRequest.class))).thenReturn(result); mockMvc.perform(post("/api/orders") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isOk()) .andExpect(jsonPath("$.orderNo").value("ORD-001")) .andExpect(jsonPath("$.amount").value(199.80)); } @Test @DisplayName("创建订单接口:参数缺失时返回400") void createOrder_should_return_400_when_body_missing() throws Exception { mockMvc.perform(post("/api/orders") .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isBadRequest()); } }这里有一个容易忽略的细节:@WebMvcTest只会加载Controller层和MVC相关的配置,不会加载Service、Repository。所以Controller里依赖的Service必须用@MockBean或@MockitoBean(版本不同注解名略有差异)来mock,否则容器起不来。
用MockMvc做断言时,我强烈建议用jsonPath而不是只判断HTTP状态码和body包含某字符串。jsonPath能让断言精确到某个字段,一旦接口结构变了测试立刻挂掉,这对排查接口回归非常有帮助。初学者可以先简单判断status,但深入到后面,一定要把响应体里的关键字段都断言到位。
关于认证,如果接口需要登录态,建议用@WithMockUser这类测试注解,或者在测试里mock统一的安全过滤器。不要为了测试去真实地走一遍OAuth流程,那样既慢又不稳定。
4.3 Repository层测试:用内存数据库验证真实SQL行为
很多团队写Repository层的单元测试,会用Mockito把Repository整个mock掉,然后测Service层。这种做法的弊端在于:如果Repository里的SQL写错了、字段映射漏了、JPQL方法名拼错了,单元测试层面完全感知不到,只有等到集成测试才暴露。而实际开发中,这种底层映射错误往往是最浪费时间的问题。
所以我倾向于给Repository层写@DataJpaTest风格的测试。它会自动启用嵌入式数据库,扫描JPA实体和Repository接口,并且默认包裹事务并在测试结束后回滚。这样每次测试互不干扰,速度快,还不需要你搭建外部数据库环境。
@DataJpaTest class OrderRepositoryTest { @Autowired private OrderRepository orderRepository; @Autowired private TestEntityManager entityManager; @Test @DisplayName("按用户ID查询订单列表,结果按创建时间倒序") void findOrdersByUserId_sorted_by_created_time() { Order order1 = new Order("U1001", "ORD-001", BigDecimal.TEN, LocalDateTime.now().minusDays(1)); Order order2 = new Order("U1001", "ORD-002", BigDecimal.ONE, LocalDateTime.now()); entityManager.persistAndFlush(order1); entityManager.persistAndFlush(order2); List<Order> orders = orderRepository.findByUserIdOrderByCreatedAtDesc("U1001"); assertThat(orders) .hasSize(2) .extracting("orderNo") .containsExactly("ORD-002", "ORD-001"); } }如果你用的是MyBatis而不是Spring Data JPA,也可以考虑类似的@MybatisTest切片测试。关键思想是一致的:把Repository层的SQL放到一个轻量级环境里去真实执行,让问题在最小范围内暴露。
内存数据库的选择上,默认的H2足够应付60%的场景。但如果项目里用了比较多的数据库方言特性,比如PostgreSQL的JSONB字段、数组类型、特定函数,H2可能模拟不了,这时我建议用Testcontainers启动一个真实的目标数据库容器。虽然速度慢一些,但至少结果是可信的。
5. 测试数据管理与参数化测试
5.1 写测试数据的三层境界
测试数据的构造方式,决定了一个测试类后期好不好维护。我总结下来,有三种由低级到高级的做法。
第一层是内联硬编码,直接在测试方法里new对象、set属性。这种方式最直白,但当测试类有十几个用例时,几乎每个方法都在重复构建相似数据,改一个字段要改一大片。
第二层是测试数据工厂或Builder工具类。把常用的对象构造收敛到一个静态方法里,比如OrderTestData.sampleOrder(),需要微调时再在测试方法里临时修改。这种方式避免了重复,但如果工厂方法参数太多,调用方依然很难看出“这个用例和那个用例到底差在哪个字段”。
第三层是场景化构造器,直接用一个高表达力的API描述你想要的数据形态。比如OrderDataBuilder.anOrder().withProductCode("P001").withQuantity(2).build()。这种方式的可读性最好,测试方法里一眼就能看出业务状态是怎样被构造出来的。我在项目里通常用第三层的思路配合静态工厂来写测试数据,而不是去引入额外的重量级框架,自己用简单的方式实现清晰的数据构造就够了。
5.2 时间与随机数据的处理
单元测试最怕“运行结果依赖于具体时间”或者“随机数据导致不可复现”。我处理这种问题的原则其实很简单:在代码里不要直接调用LocalDateTime.now(),而是注入一个Clock。测试的时候用Clock.fixed(...)锁定时间点,生产环境用Clock.systemDefaultZone()即可。
// 生产代码 @Service public class OrderService { private final Clock clock; public OrderService(Clock clock) { this.clock = clock; } public Order createOrder(...) { Order order = new Order(); order.setCreatedAt(LocalDateTime.now(clock)); } } // 测试代码 Clock fixedClock = Clock.fixed( LocalDateTime.of(2025, 3, 22, 10, 0).toInstant(ZoneOffset.UTC), ZoneOffset.UTC );这种设计的本质,是把“不可控的当前时间”变成了“可注入的受控时间源”。它比在测试里到处mock静态的LocalDateTime干净得多,代码也更好理解。
5.3 参数化测试大幅压缩用例规模
JUnit5的参数化测试是另一个值得重点使用的特性。当一个方法有多个同类分支时,与其复制粘贴多个测试方法,不如用@ParameterizedTest把输入和期望值作为参数传进去。
@ParameterizedTest @CsvSource({ "P001, 0, PARAM_INVALID", "P001, -1, PARAM_INVALID", "P999, 10, PRODUCT_NOT_FOUND", "P001, 99, STOCK_NOT_ENOUGH" }) @DisplayName("扣减库存:非法参数和异常场景统一验证") void deduct_should_throw_biz_exception(String productCode, int quantity, String expectedCode) { if (productCode.equals("P999")) { when(stockRepository.findByProductCode("P999")).thenReturn(null); } else { StockAccount account = StockAccount.builder() .productCode(productCode) .availableStock(50) .build(); when(stockRepository.findByProductCode(productCode)).thenReturn(account); } BizException ex = assertThrows(BizException.class, () -> stockService.deductStock(productCode, quantity)); assertThat(ex.getCode()).isEqualTo(expectedCode); }除此之外,JUnit5还提供@ValueSource、@MethodSource、@EnumSource等。我最喜欢用的是@MethodSource,因为它可以返回包含复杂对象的Stream<Arguments>,灵活性最高,尤其适合那种“多组输入对应不同预期”的表格化测试用例。
参数化测试写完以后,测试报告会清晰展示每个参数组合的通过情况,改核心逻辑时只要跑一遍参数化用例,基本能把所有边界分支覆盖到位。不过也有个坑:参数化用例名称默认显示为“运行方法名[1] [2]”,看起来没头没尾。可以用@DisplayName配合占位符,比如@DisplayName("变更价格:{0} -> {1}"),报告里就能直接看到输入和输出,看得人心情舒畅。
6. 覆盖率与测试优先级:别被数字绑架
关于覆盖率这个话题,我特别想说几句真心话。可能各个公司都喜欢定指标,比如“核心模块行覆盖率必须达到80%”。指标本身没问题,问题是很多人为了凑这个数字,写出了一堆“假测试”:只调用目标方法但不做任何有效断言,或者把整个类mock成一个空壳,让覆盖率数字好看但实际什么都没验证。这种测试比不写更糟糕——它给了团队一种虚假的安全感,真正重构时才发现,这些测试根本保护不了任何代码。
我的建议是覆盖率要关注,但更值得关注的是“关键业务分支的覆盖率”。举个很简单的例子,一个方法里有10个if分支,你写了一个用例只覆盖了最正常的主路径,行覆盖率也许有30%,看起来不高;但如果你用一个参数化测试把那10个分支全部覆盖掉,行覆盖率上去了,分支覆盖率也上去了,这个测试的价值显然比前者高得多。
日常开发中我会给自己定几条原则:
- 核心Service层的方法必须有正向和主要异常测试;
- Repository层自定义的查询方法至少要有一次真实SQL执行验证;
- Controller层要覆盖主要成功路径和明显的参数校验失败路径;
- 工具类、纯计算方法的覆盖率尽量拉满,因为这类逻辑简单、易测、价值稳定;
- 覆盖率是排错的辅助工具,不是写测试的终点。
在构建层面,如果团队有这个基础,可以用JaCoCo生成覆盖率报告,在CI里设置一个底线,比如覆盖率和分支覆盖率低于某个阈值就触发警告,但阈值不要设太高,否则很容易逼着大家造假,或者花太多精力去测一些无价值的分支。
7. 常见问题与排查技巧实录
7.1 遇到最多的几个报错
下面这些报错,我相信每个用JUnit5+Mockito在SpringBoot里写测试的人都见过,我把常见原因和解决方式整理成一张速查表:
| 报错信息 | 常见原因 | 解决思路 |
|---|---|---|
InvalidUseOfMatchersException | 参数混用了匹配器和具体值 | 把所有参数改成匹配器,或全部用具体值 |
UnnecessaryStubbingException | 配置了when(...)但测试路径没有调用到 | 删除无用stubbing,或调整测试路径 |
NullPointerException | mock对象没有stubbing,默认返回null | 给mock配置默认返回或调整设计 |
NoSuchBeanDefinitionException | @WebMvcTest中Service没mock | 增加@MockBean,或切换为@SpringBootTest |
测试类不执行或报No tests found | JUnit4和5版本混用 | 检查依赖,清理JUnit4坐标 |
MockitoException: Mockito cannot mock this class | 静态方法mock需要inline mock maker | 升级Mockito到4.x,或在resources下配置mock-maker-inline |
| H2测试和线上行为不一致 | H2方言差异 | 考虑用真实数据库容器的Testcontainers方案 |
这个表里,UnnecessaryStubbingException特别值得展开。默认情况下Mockito的严格模式(StrictStubs)在遇到未使用的stubbing时会直接让测试失败。这个设计初衷是防止你配了一堆不生效的mock,把测试搞得混乱。但我见过不少团队被这个报错折腾得烦了,直接把MockitoSession建成了宽松模式,结果测试里又多了一堆没人管的僵尸stub,反而更难维护。我的建议是保持严格模式,检查代码路径,该删的stubbing删掉。绝大多数情况下,这个报错说明测试设计有些地方和实际行为对不上。
7.2 三个真实场景的排查复盘
再分享三个我实际踩过的坑,如果项目里出现“测试慢”“测试不稳定”“测试无效”这三类问题,基本都能在下面找到影子。
第一个是测试慢。早期我们团队很多Service测试都直接套@SpringBootTest,跑整个应用上下文,每个类启动一次,CI上一跑就是二十多分钟。后来把所有Service层的测试改成纯Mockito的@ExtendWith(MockitoExtension.class),测试时间从“分钟级”降到“秒级”,CI整体速度提升非常明显。当时我们的优化原则很简单:能用切片测试的,绝不动整个容器;能用unit test的,绝不切片。
第二个是测试不稳定。有一个测试在本地跑十次有十次通过,到CI上一跑就偶尔挂一次。排查半天发现是里面用了真实系统时间,某个生成值的秒数跨了边界,导致断言的期望值对不上。后来把Clock注入进去,用固定时间替换,问题彻底消失。这类问题的典型特征就是“明明没改代码,测试却随机失败”,以后遇到这类现象,先怀疑时间、随机数、并发这些不可控因素。
第三个是测试无效。我见过一个测试类,整个类的所有用例都是一模一样的套路:mock掉仓库、构造数据、调用方法、只断言结果不为null。这样的测试跑了等于没跑,因为一旦业务逻辑全被改坏了,只要方法不抛异常,断言照样通过。后来我把这些用例逐条改成对真实行为的关键断言,就立刻发现了原本被掩盖的几个字段赋值错误。
7.3 测试代码本身也要review
团队里应该养成review测试代码的习惯。不要只看测试是不是绿的,要问:这个测试到底验证了什么?它是怎么验证的?如果把被测试的方法逻辑改错了,这个测试会不会变红?把这三个问题记在心里,测试代码的质量就会持续往上走。
在写测试代码时,我个人还有几个小习惯:
- 测试类命名用“被测类名+Test”,测试方法请用描述行为的话,配合
@DisplayName让人一目了然。 - 一个测试方法里尽量只测一个行为点。如果一个方法里出现两个以上“assertThat”,看看它们是否在验证同一件事。
- 善用
@Nested把测试类按场景分组,尤其适合一个Service里有多个操作、每个操作都有多个分支的类。
这些看似微小的习惯,长期下来对维护成本的帮助会非常大。
8. 最后再分享一点个人体会
写单元测试这件事,技术上真的不难,难的是“设计出值得写的测试,以及持续维护它的习惯”。我在项目里踩了不少坑,最大的感受是:如果发现自己写测试时,被测类需要好几个mock才能撑起来,往往不是测试的问题,而是被测类的职责太重了——它既要做业务校验,又要做数据组装,还要调外部服务。这时候不妨停下来重构一下被测类,把职责拆开,测试反而会变得轻松、可读。
另外一个小技巧送给所有刚开始搭测试体系的朋友:不要一开始就追求把每个类的测试都写满,那样团队阻力会很大。挑一个业务价值最高、出过线上问题的模块,把它的Service层测试认真写透,让大家看到测试真的能拦住回归,后面再逐步推开。我经历过最糟糕的情况就是测试套件臃肿到没人想碰,跑一次十几分钟还时常失败,最后整个项目被迫删掉了一批测试。测试是需要持续保持健康和快速的资产,它和业务代码一样需要用心维护。
如果你今天准备在自己的项目里搭这套体系,我的建议很简单:先把spring-boot-starter-test引入,给核心Service类写十个左右的高质量用例,让CI跑起来,然后每个月回头看看那些测试还在不在保护你。能拦住回归的测试,才是真正值钱的测试。