资讯动态

SpringBoot中JUnit5+Mockito单测体系搭建实战与避坑指南

发布时间:2026/10/20 16:29:58 来源:尧图企业网站定制
我刚接触单元测试那会儿心里其实挺抵触的。总觉得业务都写不完了哪来的时间写测试直到后来在某个项目里改一个公共校验方法本地跑了十分钟都没问题一上线就把一个老接口的数值精度带歪了排查到凌晨才定位到——竟然是有一条边界分支没有被任何测试覆盖到。那次之后我彻底改变了看法问题根本不在于“要不要写测试”而在于“怎么把测试写得又快又准又省心”。这篇文章就把我在SpringBoot项目里从零搭建JUnit5Mockito单测体系的过程和心得完整捋一遍聊聊每个设计选择背后的原因也分享一些网上文档不会写的坑。这套内容适合谁刚工作不久、知道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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/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只放不依赖具体测试场景的基础对象创建比如ObjectMappermock对象和被测对象全部放进BeforeEach这样每个用例都从干净状态开始。2.3 断言库选型AssertJ的链式风格更利于读测试SpringBoot starter里自带了AssertJ这是我在项目中最常用的断言库。相比JUnit自带的assertEquals和assertTrueAssertJ最大的优势是可读性和链式组合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什么不该mock3.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 (MockedStaticIdGenerator 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); ListOrder 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因为它可以返回包含复杂对象的StreamArguments灵活性最高尤其适合那种“多组输入对应不同预期”的表格化测试用例。参数化测试写完以后测试报告会清晰展示每个参数组合的通过情况改核心逻辑时只要跑一遍参数化用例基本能把所有边界分支覆盖到位。不过也有个坑参数化用例名称默认显示为“运行方法名[1] [2]”看起来没头没尾。可以用DisplayName配合占位符比如DisplayName(变更价格{0} - {1})报告里就能直接看到输入和输出看得人心情舒畅。6. 覆盖率与测试优先级别被数字绑架关于覆盖率这个话题我特别想说几句真心话。可能各个公司都喜欢定指标比如“核心模块行覆盖率必须达到80%”。指标本身没问题问题是很多人为了凑这个数字写出了一堆“假测试”只调用目标方法但不做任何有效断言或者把整个类mock成一个空壳让覆盖率数字好看但实际什么都没验证。这种测试比不写更糟糕——它给了团队一种虚假的安全感真正重构时才发现这些测试根本保护不了任何代码。我的建议是覆盖率要关注但更值得关注的是“关键业务分支的覆盖率”。举个很简单的例子一个方法里有10个if分支你写了一个用例只覆盖了最正常的主路径行覆盖率也许有30%看起来不高但如果你用一个参数化测试把那10个分支全部覆盖掉行覆盖率上去了分支覆盖率也上去了这个测试的价值显然比前者高得多。日常开发中我会给自己定几条原则核心Service层的方法必须有正向和主要异常测试Repository层自定义的查询方法至少要有一次真实SQL执行验证Controller层要覆盖主要成功路径和明显的参数校验失败路径工具类、纯计算方法的覆盖率尽量拉满因为这类逻辑简单、易测、价值稳定覆盖率是排错的辅助工具不是写测试的终点。在构建层面如果团队有这个基础可以用JaCoCo生成覆盖率报告在CI里设置一个底线比如覆盖率和分支覆盖率低于某个阈值就触发警告但阈值不要设太高否则很容易逼着大家造假或者花太多精力去测一些无价值的分支。7. 常见问题与排查技巧实录7.1 遇到最多的几个报错下面这些报错我相信每个用JUnit5Mockito在SpringBoot里写测试的人都见过我把常见原因和解决方式整理成一张速查表报错信息常见原因解决思路InvalidUseOfMatchersException参数混用了匹配器和具体值把所有参数改成匹配器或全部用具体值UnnecessaryStubbingException配置了when(...)但测试路径没有调用到删除无用stubbing或调整测试路径NullPointerExceptionmock对象没有stubbing默认返回null给mock配置默认返回或调整设计NoSuchBeanDefinitionExceptionWebMvcTest中Service没mock增加MockBean或切换为SpringBootTest测试类不执行或报No tests foundJUnit4和5版本混用检查依赖清理JUnit4坐标MockitoException: Mockito cannot mock this class静态方法mock需要inline mock maker升级Mockito到4.x或在resources下配置mock-maker-inlineH2测试和线上行为不一致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跑起来然后每个月回头看看那些测试还在不在保护你。能拦住回归的测试才是真正值钱的测试。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑