1. 为什么在Spring Boot单元测试中优先选择Mockito Mock很多团队写单元测试写着写着就变成了“集成测试”。要么把整个Spring上下文都拉起来要么连数据库都一起跑了。不是说这种方式完全不行而是它会让测试变得又慢又脆——稍微改点配置全盘报错CI跑一次测试够喝杯咖啡等半天。我在实际项目里踩过这些坑之后才真正体会到“单元测试就该用Mockito Mock把外部依赖全部架空”这句话的含义。Spring Boot应用的典型结构是Controller→Service→RepositoryService层通常还会调用第三方接口、消息队列、缓存组件等等。如果我们写测试时把这些真实依赖全部装配起来会面临几个非常现实的问题。第一是启动慢Spring上下文加载、数据库连接池初始化几秒钟甚至十几秒就没了第二是依赖环境本地没起Redis、Kafka或者测试数据库代码就根本跑不起来第三是结果不稳定只要某个下游接口超时或者返回异常数据被测代码一行没改测试却红了。Mockito Mock的核心理念就是把被测对象真正关注的东西留下来其余的全部替换成“模拟对象”。你在测试里只验证自己代码的逻辑分支、状态变化和协作对象的交互方式完全不关心外部依赖的真实行为。还有人会担心你这样测出来的代码到了真实环境里能用吗其实这个担忧是把单元测试和集成测试的职责混在一起了。单元测试追求的是“纯逻辑正确”集成测试才负责“环境连通”两者各有分工互相补充。我在接手一个遗留项目时遇到过更极端的场景某个Service方法内部new了好几个依赖对象方法之间的调用关系错综复杂项目里几乎没有测试每次改动都像在雷区里走路。后来我给核心流程补上了基于Mockito的单元测试把那些依赖全部mock掉测试运行时间从原来“拉全集”的8秒钟直接降到了几百毫秒而且改逻辑时能精确知道是哪一层出了问题。这才是单元测试该有的敏捷度。这一篇属于“架构之美”系列的第十七篇重点不会放在“Mockito是什么”这种入门概念上而是会完整演示在Spring Boot项目里怎么用Mock写单元测试为什么用InjectMocks注入被测对象以及如何用JaCoCo生成覆盖率报告并真正读懂报告里的数字。篇幅会比较长建议你打开IDE跟着敲一遍因为Mockito很多细节光看不练是真的记不住。2. Mockito核心注解与Spring Boot测试的最佳组合方式2.1 认识Mock、InjectMocks和Spy的分工Mockito最常用的三个注解说起来简单但用错的情况非常多。Mock是创建一个模拟对象对象的所有方法都返回默认值int返回0对象返回nullList返回空list除非我们用when().thenReturn()去定制行为。InjectMocks是创建被测对象并把当前测试类里的Mock对象按类型和名称自动注入进去。Spring容器里的Autowired发生在Bean初始化阶段而Mockito的注入发生在测试实例创建之后二者机制完全不同但效果相似。Spy则稍微特殊一点它创建的是“部分模拟”的对象没打桩的方法是走真实逻辑打桩的方法才走模拟逻辑。具体组合方式我日常写单元测试时基本是这样ExtendWith(MockitoExtension.class) class OrderServiceTest { Mock private OrderRepository orderRepository; Mock private PaymentClient paymentClient; InjectMocks private OrderService orderService; // 测试方法省略 }这里有个容易被忽视的点ExtendWith(MockitoExtension.class)是JUnit 5的桥接扩展它负责在测试执行前扫描Mock和InjectMocks注解并完成初始化。如果不加这个注解注解不会生效运行时会报空指针。如果你还在用JUnit 4对应的启动方式是RunWith(MockitoJUnitRunner.class)作用一样但API属于两个不同的时代。关于InjectMocks很多人会有个误解以为它是“万能注入器”。其实它内部的实现策略是先按类型找找不到再按字段名找。如果被测类的构造器参数有多个而测试类里的mock字段类型都匹配通常能正确注入但如果你mock字段声明顺序和构造器参数顺序不一致Mockito会优先按类型来匹配类型有重复时才会看名称。这种场景下我的建议是优先用构造器注入来写被测类而不是字段注入这样测试代码会更加直观。2.2 为什么Spring Boot测试可以用Mockito而不是SpringBootTest这是很多初入Spring Boot的人纠结最多的问题。SpringBootTest会把整个应用上下文加载起来里面每个Bean都要实例化配置都要生效数据库连接都要准备好。这让测试变得很“重”但并不能保证更可靠。而Mockito方式完全不启动Spring容器直接new一个被测类出来把它的依赖用mock替代测试逻辑非常纯粹。举一个我在项目中真实遇到的例子。某个订单Service里注入了UserService和CouponServiceUserService又依赖用户中心的Feign客户端CouponService又依赖Redis缓存。如果我用SpringBootTest来测订单Service的一个普通方法需要把用户中心、Redis全部准备好才能让测试跑通。但这个方法测试的其实是订单金额计算的业务规则和用户中心返回什么、Redis里有没有缓存根本没有直接关系。用Mockito方式就简单多了Mock private UserService userService; Mock private CouponService couponService; InjectMocks private OrderService orderService; Test void testCalculateTotalAmount() { when(userService.getUserLevel(anyLong())).thenReturn(1); when(couponService.getDiscount(anyLong())).thenReturn(new BigDecimal(0.90)); BigDecimal result orderService.calculateTotalAmount(1001L); assertThat(result).isEqualByComparingTo(90.00); verify(userService).getUserLevel(1001L); verify(couponService).getDiscount(1001L); }这个测试不依赖外部环境不连接数据库不加载Spring上下文执行时间通常在一两百毫秒以内。改回来你只需要跑这一条测试就能确认业务规则是否被破坏不需要重新拉起整个应用。测试速度快才能支持频繁的回归这才是“写单元测试”这件事能坚持做下去的根本动力。有人可能会问那Controller层测试怎么办Spring Boot提供了一个WebMvcTest切片测试它会加载Spring MVC层的相关配置但Service和Repository等Bean不会加载我们可以用MockBean来mock掉它们。这和纯Mockito方式定位不同WebMvcTest测试的是请求映射、参数绑定、序列化这类Web层行为而纯Mockito单元测试测试的是Service层或工具类的业务逻辑。两者可以结合起来使用并不冲突。我一般的原则是Service层用MockitoController层用WebMvcTestRepository层如果要测基本还是Mockito为主只有最关键的数据访问逻辑才写SpringBootTest。3. 从零搭建一个基于Mock的单元测试Demo3.1 项目基础结构和依赖准备为了让你能对照操作这里用一个非常典型的业务场景用户下单后系统要根据用户等级计算折扣并把订单保存到数据库。代码结构包括三个核心类OrderService、OrderRepository操作MySQL、UserService模拟用户中心正常项目中可能是Feign客户端调用远程服务。我们用MockitoMock把后两个依赖都mock掉只测OrderService内部的业务逻辑。文件结构大概是这样的src/main/java └── com/example/order ├── OrderService.java ├── OrderRepository.java ├── UserService.java └── Order.java src/test/java └── com/example/order └── OrderServiceTest.javapom.xml里需要引入的关键依赖如下dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope /dependencyspring-boot-starter-test是一个聚合依赖它里面已经包含了JUnit 5、Mockito、AssertJ、Hamcrest等常用的测试和mock库不需要你一个一个去加版本号。Spring Boot的依赖管理机制会帮我们统一版本多个库之间的兼容性在大多数情况下是OK的。这里有一个经验尽量不要手动覆盖这些传递依赖的版本除非你明确知道升级的原因否则很容易引发JUnit和Mockito版本冲突。3.2 核心业务代码先看被测的业务类。OrderService是整个测试的核心对象Service public class OrderService { private final OrderRepository orderRepository; private final UserService userService; public OrderService(OrderRepository orderRepository, UserService userService) { this.orderRepository orderRepository; this.userService userService; } public Order createOrder(Long userId, BigDecimal amount) { // 基础前置校验 if (amount null || amount.compareTo(BigDecimal.ZERO) 0) { throw new IllegalArgumentException(订单金额必须大于0); } // 查询用户等级 int userLevel userService.getUserLevel(userId); // 计算折扣等级1为95%等级2为90%其余为原价 BigDecimal factor; if (userLevel 1) { factor new BigDecimal(0.95); } else if (userLevel 2) { factor new BigDecimal(0.90); } else { factor BigDecimal.ONE; } BigDecimal finalAmount amount.multiply(factor) .setScale(2, RoundingMode.HALF_UP); // 保存订单 Order order new Order(userId, finalAmount); orderRepository.save(order); return order; } }这里故意把折扣逻辑放在Service层是为了测试时有明确的分支可以覆盖。OrderRepository和UserService都是我们马上要mock的接口。Order就是一个普通的实体类包含userId和amount两个字段以及对应的getter/setter这里就不展开了。3.3 测试类的完整实现与execute过程接下来就是这篇博文的核心写测试类。我们把上面创建的OrderService实例化出来但传入的orderRepository和userService都是Mockito mock出来的ExtendWith(MockitoExtension.class) class OrderServiceTest { Mock private OrderRepository orderRepository; Mock private UserService userService; InjectMocks private OrderService orderService; Test void testCreateOrder_withUserLevel1_shouldApply95PercentDiscount() { long userId 1001L; BigDecimal amount new BigDecimal(100.00); when(userService.getUserLevel(userId)).thenReturn(1); Order result orderService.createOrder(userId, amount); assertThat(result.getAmount()).isEqualByComparingTo(95.00); verify(orderRepository).save(any(Order.class)); verify(userService).getUserLevel(userId); } Test void testCreateOrder_withUserLevel2_shouldApply90PercentDiscount() { long userId 1002L; BigDecimal amount new BigDecimal(200.00); when(userService.getUserLevel(userId)).thenReturn(2); Order result orderService.createOrder(userId, amount); assertThat(result.getAmount()).isEqualByComparingTo(180.00); verify(orderRepository).save(any(Order.class)); } Test void testCreateOrder_withUnsupportedLevel_shouldNotApplyDiscount() { long userId 1003L; BigDecimal amount new BigDecimal(50.00); when(userService.getUserLevel(userId)).thenReturn(99); Order result orderService.createOrder(userId, amount); assertThat(result.getAmount()).isEqualByComparingTo(50.00); } Test void testCreateOrder_withInvalidAmount_shouldThrowException() { long userId 1004L; BigDecimal amount BigDecimal.ZERO; assertThatThrownBy(() - orderService.createOrder(userId, amount)) .isInstanceOf(IllegalArgumentException.class) .hasMessage(订单金额必须大于0); verify(orderRepository, never()).save(any()); } }这个测试类覆盖了四个关键场景等级1打折、等级2打折、其他等级不打折、非法参数抛异常。每一行代码都有明确目的。我当时写的时候特别注意了一点BigDecimal的比较用isEqualByComparingTo。如果用equals()比较BigDecimal会踩坑例如new BigDecimal(95.00)和new BigDecimal(95.0)在equals判断下是false因为scale不同但isEqualByComparingTo会基于数值比较这才是我们真正想要的结果。这个小坑在实际项目中坑过不少人。关于verify的使用这里也值得多说两句。verify(orderRepository).save(any(Order.class))的语义是确认某个协作对象的方法确实被调用过。这个断言的价值在于验证你的被测代码确实走了save这条路径。如果有一天你重构的时候把save漏掉了这个测试会第一时间报错。verify(userService).getUserLevel(userId)则确认了查询用户等级的调用确实发生。当测试不想让某些方法执行时我们用verify(mock, never()).save(any())来明确“该方法不应该被调用”这在验证出错分支时很管用。3.4 测试执行时的Mockito内部工作流程理解了代码再看运行时机就更容易深入。当MockitoExtension启动测试时大致做了这么几件事扫描测试类里标注了Mock的字段为每个字段创建一个代理对象记录方法调用的返回值设定扫描InjectMocks标注的字段实例化被测对象并把上面创建的mock对象按类型和名称注入进去。每个测试方法执行前都会重新初始化mock所以mock对象上的交互记录不会跨测试保留每个测试用的都是干净的对象。在测试执行过程中每调用一次mock的方法Mockito会记录这次调用的参数和方法名。这样在测试末尾调用verify时Mockito才能在“交互记录册”里查到这次调用是否存在。如果不调用verifymock方法被调用多少次都不会影响测试结果。如果你既没打桩也没verifymock对象其实就是一个“安静的替身”只会返回默认值。有一点需要特别记住when(userService.getUserLevel(userId)).thenReturn(1)和verify(userService).getUserLevel(userId)是两件不同的事。前者是“打桩”在测试之前预设mock的行为后者是“验证”在测试之后确认哪些方法被调过。我在指导新人时经常看到他们把这两者混在一起其实分清楚之后Mockito的用法基本就掌握了一半。4. JaCoCo覆盖率报告从配置到精准解读4.1 加入JaCoCo插件并生成报告Mockito把测试逻辑写好了但这只完成了一半工作。你需要知道这些测试到底覆盖了哪些代码路径哪些分支还是赤裸裸的“裸奔”状态。业内最常用的覆盖率工具就是JaCoCoJava Code Coverage它通过分析Java字节码来统计覆盖率完全不侵入业务代码官方文档的支持非常成熟。在Spring Boot项目的pom.xml里配置JaCoCo插件plugin groupIdorg.jacoco/groupId artifactIdjacoco-maven-plugin/artifactId version0.8.12/version executions execution goals goalprepare-agent/goal /goals /execution execution idreport/id phasetest/phase goals goalreport/goal /goals /execution /executions /plugin配置里有两个关键执行步骤。prepare-agent会在测试运行前设置一个Java agent它负责在JVM层面记录代码执行信息。report会在test阶段结束后生成覆盖率报告。运行mvn test后target/site/jacoco目录下会出现index.html用浏览器打开就是可视化报告。如果是在IDEA里跑测试简单一点可以直接右键运行测试类然后使用IDE底部的Coverage工具窗口查看不过这个方式只能看当前类或当前包的覆盖率不如JaCoCo的聚合报告完整。如果在命令行执行一般会这样操作mvn clean test命令跑完后控制台最后会输出类似这样的内容Rule violated for bundle order-service: instructions covered ratio is 0.85, but expected minimum is 0.90如果配置了覆盖率规则低于阈值时会直接让构建失败。这是强制质量门禁的方法后面会细说。4.2 怎么读覆盖率数字才算真正读懂JaCoCo报告里有几个指标刚开始接触的人容易被搞迷糊行覆盖率Line Coverage、分支覆盖率Branch Coverage、指令覆盖率Instruction Coverage、方法覆盖率Method Coverage、类覆盖率Class Coverage。我最看重的指标是行覆盖率加分支覆盖率。行覆盖率说的是代码中有多少行的字节码指令被执行过分支覆盖率说的是if/else、switch、三元表达式等分支点上有多少条分支路径被走过。很多团队的覆盖率要求只盯行覆盖率这是不够的。两个测试类如果行覆盖率都是90%一个可能把if的两个分支都走到了另一个可能永远只走了其中一个分支。后者恰恰可能是Bug藏得最深的地方。比如我们上面的例子testCreateOrder_withUserLevel1_shouldApply95PercentDiscount覆盖了userLevel1的分支testCreateOrder_withUserLevel2_shouldApply90PercentDiscount覆盖了userLevel2的分支testCreateOrder_withUnsupportedLevel_shouldNotApplyDiscount覆盖了else分支。没有这三个测试的完整组合某个折扣分支永远会漏掉。看完JaCoCo报告之后你要做的是反推“这个黄/红的分支为什么没走到”而不是简单地把数字堆到80%、90%就算交差。4.3 覆盖率规则阈值与质量门禁覆盖率报告不仅仅是看一眼就结束它还可以作为代码合入的门禁。在jacoco-maven-plugin的配置里加上一个check执行execution idcheck/id goals goalcheck/goal /goals configuration rules rule elementBUNDLE/element limits limit counterINSTRUCTION/counter valueCOVEREDRATIO/value minimum0.80/minimum /limit limit counterBRANCH/counter valueCOVEREDRATIO/value minimum0.70/minimum /limit /limits /rule /rules /configuration /execution这里的规则表示整个项目的指令覆盖率不能低于80%分支覆盖率不能低于70%否则构建失败。你可以根据团队实际情况调整比例。网上也有人喜欢用排除策略把DTO、配置类、启动类从覆盖率统计中排除掉。这个做法有争议但从实际效果来看排除掉那些没有业务逻辑的模板类能让报告更真实地反映核心代码的覆盖情况团队讨论时也不容易被少数行数拉低整体数字而带偏方向。对于排除逻辑在插件里配置excludes即可configuration excludes exclude**/dto/**/exclude exclude**/entity/**/exclude exclude**/*Application.class/exclude exclude**/config/**/exclude /excludes /configuration需要注意的是这个排除要在prepare-agent和report两个执行里都保持一致不然会出现“统计看了一眼没排除报告里排除了”这种不一致现象。这个情况我遇到过排查了好一会才反应过来是两处配置没有同步。5. 单元测试实施中的常见踩坑与排查方式5.1 Mockito的“打桩不生效”级联问题用Mockito最常遇到的坑就是“我明明when了为什么返回的还是null/0”。这种情况出现的原因大多不是Mockito失效了而是你打桩时传入的参数和实际调用时的参数不一致。比如下面的代码when(userService.getUserLevel(1001L)).thenReturn(1); long userId 1001L; // 但实际调用时传的是另一个变量比如从别的地方计算来的 int level userService.getUserLevel(userId);如果userId等于1001L没问题。但如果userId是从请求里解析出来的个Long对象值相同但equals和hashCode却没问题打桩本该生效。真正的坑在于你打桩用的参数用了new Long(1001)或者Long.valueOf(1001)调用时用的是拆箱后的基本类型longMockito在匹配参数时会用equals而不是所以理论上不该失效。一旦失效排查思路就是先在打桩的参数处打个断点看看实际值是什么。另外还有一类情况被测代码里调用了final方法、静态方法或private方法。Mockito默认不支持mock这些如果你用的版本是Mockito 2以上的话需要额外配置mock-maker-inline并在src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker文件里写入mock-maker-inline。不过这个问题在Spring Boot项目里相对少见因为Spring Boot的依赖版本已经把这些兼容性处理得比较好了。5.2 InjectMocks注入失败或注入错对象当你发现测试类里某个Mock对象能在测试中正常打桩但被测类里的那个同名依赖还是null或者不是同一个对象基本就是注入失败了。常见原因有两个被测类有多个构造器Mockito不知道选哪个或者被测类既支持构造器注入又支持setter注入Mockito的注入顺序是先构造器再setter再字段顺序和你预期不一致。解决办法很简单别把所有注入方式混在一起统一用构造器注入。被测类里保证只有一个构造器InjectMocks的执行路径就清晰了。我在重构老代码时经常会把字段注入改成构造器注入这不仅仅是测试的需要对代码的可读性和不可变性也有很大帮助本质上是一种值得推广的“架构之美”。5.3 Spring Boot环境下Mockito与Spring Bean的边界有一种情况经常把人绕晕某个Service里注入了另一个Service单元测试里我们Mock了它但被测代码走到某个方法时仍然调了真实逻辑——这通常是不是因为Mockito没生效而是被测类通过ApplicationContext.getBean的方式自己获取了依赖。通过字段注入或者构造器注入的依赖mock掉是容易的但藏在代码里的容器主动查找是很难mock的。这也是我推崇构造器注入的另一个原因依赖路径显式可见mock起来一目了然。最好的实践是让所有依赖都通过构造器注入进来禁止ApplicationContext.getBean这种方式从容器里直接拿Bean。这不只是为了Mockito更是为了整个项目依赖关系的清晰度。5.4 排查工具推荐与定位效率排查Mockito问题不能全靠猜我平时会优先看异常堆栈和Mockito自带的调试信息。Mockito提供了Mockito.validateMockitoUsage()可以在测试基类的AfterEach或BeforeEach里调用帮助检查是否有打桩没被使用的情况。在JUnit 5里也可以使用MockitoSettings(strictness Strictness.STRICT_STUBS)来让Mockito在打桩未被调用时直接报错这对清除无用打桩非常有帮助。还有一个很直接的调试手段用mockito-inline的thenAnswer来打印实际传入的参数。when(userService.getUserLevel(any())).thenAnswer(invocation - { System.out.println(getUserLevel called with: invocation.getArgument(0)); return 1; });这种方式虽然土但定位问题特别快。等确认好参数后再换回精确的when写法测试代码就干净了。5.5 写测试时容易忽略的边界场景我观察过团队里很多人写单元测试时会忽略一些不起眼但又特别值得测的边界场景。比如BigDecimal判空金额为null时会怎么走异常分支的verify正常路径测试里save了但异常路径里不应该save你verify never了吗极端值金额为负、用户ID为0、折扣计算后小数位多于2位时四舍五入是否符合预期重复调用同一个mock方法被调两次前后返回值不一致怎么办Mockito的thenReturn可以连续指定多个返回值如when(mock.method()).thenReturn(a, b)第一次调用返回a第二次返回b。这在模拟“第一次查询返回缓存未命中第二次返回缓存命中”这种场景时非常实用。前面的测试Demo里只覆盖了四个典型场景如果你自己扩展练习可以把空指针场景也补进去用when(userService.getUserLevel(null)).thenThrow(NullPointerException.class)打桩再用assertThatThrownBy断言有异常抛出。补全这些边界场景后JaCoCo报告里的分支覆盖率才会真正接近一个健康的值。6. 覆盖率从60%到90%的实战提升过程6.1 从报告反向定位未覆盖代码我刚开始给项目补测试时覆盖率大概在60%左右看着也不是特别低但我知道其中很多分支是没走到的。于是我把JaCoCo的HTML报告打开按包名一层层点进去看到那些标黄的代码行心里就大致清楚哪些场景没补。常见的漏测原因有这么几类异常分支漏测比如方法开头那些if (xxx null)的判断循环分支漏测for/while内部只有一次迭代或者从没迭代过Optional分支漏测orElse和orElseGet里只测了一个路径还有catch块漏测这是最容易藏Bug也是最容易被忽略的。针对这些漏测原因去补测试比盲目加几个用例要高效得多。用上面的Demo来说如果只测正常流程覆盖率大概到60%。这时报告里amount null || amount.compareTo(BigDecimal.ZERO) 0这个if很可能只覆盖了amount为0的情况null的情况没有分支被走到。补上null的输入行覆盖率和分支覆盖率都会立刻提升。这看起来不起眼但null分支往往是线上空指针异常的来源早点发现比上了线再被用户投诉要幸福得多。6.2 维护覆盖率基线与增量控制覆盖率不是一个静态数字。项目在不停演进每次新增代码如果没有配套测试整体覆盖率会慢慢下降。有些团队干脆就不看整体覆盖率了只看“增量覆盖率”即本次改动新增代码的覆盖率是否达标。这个做法在持续集成里很有价值JaCoCo官方提供了diff能力不过配置相对复杂一些。简单团队可以这样做合并代码前在本地跑mvn test把本次改动涉及的类报告的覆盖率拉出来过一遍确认新增逻辑的测试是到位的。如果你想防止团队里覆盖率“一夜回到解放前”可以在CI pipeline里把覆盖率阈值作为硬性检查条件也可以把JaCoCo报告上传到SonarQube这类平台做趋势追踪。趋势曲线比某一天的快照更有参考价值。如果某天报告突然断崖式下跌开发历史里一定发生了什么顺藤摸瓜就能找到是哪个模块的新增逻辑没有测试。6.3 我的实测数据参考在一个模拟订单系统中我用上面这套方法补齐了Service层全部方法最终JaCoCo报告显示的指令覆盖率接近92%分支覆盖率在85%左右。Service层的行覆盖率基本接近95%剩下的几个行主要是catch块——那些异常在现实中极难模拟我不想为了覆盖率硬加无意义测试那会在和项目目标不一致的地方浪费大量精力。我认为覆盖率95%以上不是不能做到但边际成本会急剧上升。有些测试是写来安慰数字的比如把某个方法的所有参数暴力试一遍完全没有业务语义有些测试是为了卡住回归Bug而写的价值很高。团队制定覆盖率目标时要结合自己的实际场景不要为了一个好看的数字去“刷覆盖”。质量保证的核心是测试是否真的能在逻辑出错时拉响警报而不是测完之后你敢不敢拍胸脯说代码没问题。7. 几个真实案例帮你避开团队协作中的大坑7.1 案例一改了业务逻辑测试全绿但线上出Bug这个案例是我在某个项目中真实经历过的。同事修改了优惠券计算的逻辑把折扣从固定的95折改成了等级越高折扣越低。他改完之后跑了之前的相关测试发现全部通过就放心提交了。结果上线后用户发现部分订单越贵的反而越便宜造成了不小的问题。后来追查原因原来的测试用Mockito mock掉了一个计算服务没有验证新的计算参数是否真的传对了。也就是说测试全绿但测试完全没测到“新逻辑”。这就是典型的“测试覆盖了但只覆盖了旧行为”。之后我们在Code Review时对这种业务规则变更类改动有个不成文的约定必须新增一个与该需求直接对应的测试用例并且测试里要明确断言新规则的边界值。这个约定看起来很小但救了不少次线上事故。这个案例给我的启发是覆盖率报告只能告诉你“代码被执行了”不能告诉你“代码的正确性验证了”。我们要做的是每个核心逻辑都配备一组有业务含义的断言而不是单纯追求行数。7.2 案例二mock了太多东西测试变成摆设还有一种极端情况把被测类内部创建的对象也给mock掉了。比如被测方法里new EmailSender()或者new DiscountCalculator()有人为了省事把这些类的对象也全用Mockito mock掉。这样测试运行确实快但它没有测到任何真实逻辑一旦DiscountCalculator内部的计算公式错了测试依然全绿。Mockito的使用原则应该是mock被测对象的邻居而不是mock被测对象自己。被测对象内部零散new出来的那些辅助类如果业务逻辑比较复杂最好通过构造器传进来否则测试时你没法控制它们的真实行为。这个约束会倒逼设计变成依赖注入的模式从另一个角度验证了良好架构的价值。7.3 案例三盲目追求覆盖率指标测试成形式主义某项目刚开始推行单元测试时管理层定了一个硬性指标“测试覆盖率必须达到80%”。结果半个月后覆盖率确实到80%了但代码质量并没有显著提升。原因是不少人用各种方式“刷”覆盖率把所有方法都测到但不加任何有效断言在测试类里调用被测方法但不验证返回值甚至把if/else的方块用数据驱动挨个走一遍但业务规则一个都没校验。这种数字游戏没有任何意义反而让人对测试产生反感。我认可的质量观是核心业务逻辑必须有测试异常分支必须有测试协作对象交互必须有验证。覆盖率可以作为一个参考指标但绝不应该是唯一的目标。数字好看的本质原因是你的测试设计合理、业务模型清晰这个因果顺序不能被颠倒。8. 从测试代码反推设计质量的两种观察写了足够多的单元测试后你会发现一个很有意思的现象如果被测类很难被Mockito测试大概率不是Mockito的问题而是设计的问题。一个类有十几个依赖、方法里有几十行if/else、每次测试要打十几行when这说明类的职责过于臃肿。这时候比起硬着头皮继续加测试更该做的事是把类拆小让每个类只负责一件清晰的业务。比如上面的OrderService它既负责校验参数又负责调用用户中心又负责计算折扣又负责组装订单还负责持久化。如果业务继续膨胀这个类会越来越难测。更好的做法是把折扣计算抽成一个独立的DiscountPolicy接口或者类把订单组装抽成OrderFactoryOrderService只负责编排。测试时DiscountPolicy和OrderFactory可以单独测试OrderService里只需要mock掉它们即可。每个类的测试都会变得又短又清晰。另一个观察点是依赖的方向。如果你发现一个Service依赖了另一个Service而那个Service又依赖了一堆基础设施测试时要把整条链路全部mock掉才能开始写测试那就要考虑是否违反了依赖倒置原则。依赖抽象的方向应该是越往上层越稳定越往下层越细节。Mockito把依赖层层mock掉的痛苦程度其实是在给你报警你的依赖方向可能需要重新审视了。这两个观察不会立刻体现在覆盖率数字上但长期维护时带来的收益是实实在在的。好设计和好测试是相互成就的好的测试会让好设计体现得更明显而糟糕的设计在测试面前也会原形毕露。9. 测试代码本身的可读性维护Mockito写多了之后代码里会出现大量when(...).thenReturn(...)的重复测试方法越写越长最后和被测代码一样难读。我团队里有一些维护得比较好的经验值得分享。首先给每个测试方法命名要像给生产代码命名一样认真。testUserLevel1这种名字完全看不出业务意图我更喜欢testCreateOrder_withUserLevel1_shouldApply95PercentDiscount这种命名风格。虽然在很多公司里这会被认为太长但它读起来几乎就是一句自然语言。测试失败时光看测试名字就能知道是哪个业务场景出了问题不用再点开代码一行行猜。其次把通用的打桩逻辑抽取到私有方法或BeforeEach里。比如创建一条基础订单数据的逻辑每个测试都要用抽成一个buildBaseOrder()。这样每个测试里只需要关注它自己特有的条件一目了然。另外用BDDMockito的given/willReturn替代when/thenReturn也是一种提高可读性的做法。这两套API底层是一样的但BDD风格更贴近“给定某个条件然后执行然后验证”的叙述顺序对于业务测试来说表达力更强。最后给测试类也做分层。简单的工具类测试可以一锅端但Service层的测试我习惯把测试拆成几个嵌套类Nested class CreateOrderNested class CancelOrder。这样做的好处是每个业务场景一组测试对账方便新同学接手时找起测试来也很快。10. 建议直接抄走的Mockito最佳实践清单如果你现在准备给Spring Boot项目补单元测试我建议你带着下面这份清单去写实践下来可以少踩很多坑被测类统一使用构造器注入避免字段注入和setter注入混用。测试类统一用ExtendWith(MockitoExtension.class)启动不要手动调用MockitoAnnotations.openMocks。打桩参数优先使用any()、anyLong()、anyString()等宽松匹配器只有在确实需要精确定位参数时才用具体值。验证交互时使用verify、verify(..., never())、verify(..., times(n))别只验证结果不验证交互。对于异步回调或Future结果的验证使用Awaitility或Mockito的verify结合超时参数避免时间竞态导致的偶发性失败。BigDecimal断言统一用isEqualByComparingTo。每次写完测试运行mvn test后打开JaCoCo报告至少检查一次分支覆盖率。在持续集成里配好覆盖率阈值后同时配备增量覆盖率的检查机制防止新增代码裸奔。单元测试里不要真实访问Redis、MySQL、Kafka等基础设施那些内容交给Testcontainers或集成测试来覆盖。每一个测试都必须有断言没有断言的测试只是“能跑”不是“能测”。这份清单不是教科书上抄来的是我在各种项目里被坑过之后攒下来的。与其什么都追不如把这几条先落地至少你能在每天下班前清楚地知道“今天的代码是被测试保护着的”。11. 最后的个人经验分享写了几年Mockito单元测试我最大的感受是Mockito本身不难学真正难的是在你的团队里建立起“写测试是写代码的一部分”这种意识。很多时候投入测代码的时间不会立刻体现在功能交付速度上但一旦项目开始持续迭代有测试的模块和没测试的模块之间的差异会越来越大。有测试的模块重构就像换灯泡拆了旧的装新的就行没测试的模块重构就像拆炸弹剪哪根线都要犹豫半天。Mockito Mock配合Spring Boot、JaCoCo这套组合我用了很久稳定性和实用性都非常可靠。这一篇的代码虽然用了一个简化的订单场景但把它替换成你们项目里的用户模块、支付模块、库存模块整个方法论是完全通用的。如果你之前从来没有给Service层写过单元测试今天就可以挑一个相对独立的方法试一下感受一下几百毫秒跑完测试的清爽感。下一篇我大概率会聊聊怎么写Controller层的切片测试以及它和Mockito单元测试的边界划分。