
写业务层单元测试这事我太有感触了。刚工作那两年我一直觉得给Service层写单测是件“投入产出比极低”的事——Controller层可以Mock掉Service来测接口DAO层有MyBatis逆向工程生成的代码也不用怎么测偏偏中间的业务层依赖一堆Mapper、Redis、MQ生产者每次想写个测试都得先new一串Mock对象等到终于跑通一个用例半天时间已经没了。直到后来接手一个老项目代码覆盖率被卡在30%上不去领导要求核心业务模块必须达到70%我才被迫开始系统性地梳理业务层的测试套路。结果发现这东西真的没有我想象中那么玄乎只要把流程捋顺了大部分业务方法就是“搭积木”。今天这篇把我这几年沉淀下来的一套Java业务层单元测试编写流程完整整理出来以Junit4 Mockito这套组合为例从环境搭建到常见场景写法再到那些不跑一遍根本发现不了的坑一次性讲清楚。1. 业务层单元测试到底卡在哪先解决“为什么写不动”先聊点实在的。很多人不写业务层单测不是懒是真的不知道从哪下手。1.1 业务层的测试困境比想象中更普遍一个典型的Service方法长什么样它可能要调三个Mapper查数据要根据结果决定要不要发一条MQ消息要往Redis里写缓存遇到库存不足还要抛异常。最要命的是这些外部依赖全是Spring容器在管理你单独在测试类里new一个Service实例根本跑不起来——Mapper是nullRedisTemplate是null一执行就到空指针。这就是业务层测试劝退大多数人的第一个原因依赖太重。但换个角度想恰恰是因为依赖重才更值得测。Controller层大多数时候只是参数校验和数据透传DAO层的CRUD是框架生成的真正的业务规则、分支判断、异常处理全都堆在Service层。这里要是逻辑错了接口测再充分也拦不住。1.2 很多人的“单元测试”其实是带数据库的集成测试还有个常见误区我见过不少团队所谓的单元测试实际上是起了Spring容器、连了测试库、跑完再回滚数据的“伪单测”。这种测试不是不能写但它根本就不是单元测试——它属于集成测试的范畴。为什么要区分这两者因为集成测试慢、不稳定、依赖环境。一次SpringBootTest启动快则三秒慢则十秒一个类里的测试方法一多跑一遍就是几十秒天天跑谁也受不了。而且它依赖数据库里的数据哪怕同一个测试方法执行两次结果可能都不一样这类“时好时坏”的测试最后往往被开发者一注释了之。真正的单元测试应该是不启动Spring容器、不连数据库、纯JVM环境通过Mockito把外部依赖全部替换成可控的假对象让测试在毫秒级内稳定跑完。这一点想通了后面整套流程就顺了。你不是在“想办法把Service测起来”而是在“想办法把Service从它依赖的环境中剥离出来”。1.3 剥离依赖这件事Mockito就是为它而生的用Mockito去剥离依赖其实就三个操作mock()创建一个假的依赖对象它不使用真实逻辑。when()...thenReturn()规定这个假对象在被调用时返回什么。verify()验证这个假对象的某个方法到底有没有被调用、调了几次、用什么参数调的。这三个操作拼起来就能让被测的Service在一个“完全可控”的环境中运行所有外部依赖的返回值都由你来指定。这样测试的就不是“Service加Mapper融合得怎么样”而是“Service这条业务规则本身写得对不对”。后面整个编写流程本质上就是围绕这三个动作展开的。2. 搭建最小测试骨架Junit4与Mockito的配合方式先把环境搞定。很多人卡在第一步是因为项目里Junit版本太杂网上教程各说各话照着配完跑不起来。2.1 依赖引入版本搭配建议直接照抄我用的是Junit4 Mockito的组合Maven依赖就这几个dependencies !-- 单元测试框架 -- dependency groupIdjunit/groupId artifactIdjunit/artifactId version4.13.2/version scopetest/scope /dependency !-- Mockito 核心 -- dependency groupIdorg.mockito/groupId artifactIdmockito-core/artifactId version3.12.4/version scopetest/scope /dependency /dependencies不需要额外引入spring-boot-starter-test那个里面东西太杂但不是必须的。单测做到纯JVM跑反而能逼着你把测试和Spring解耦。如果项目用了Spring Boot 2.x引入spring-boot-starter-test会带一个mockito-junit-jupiter那是给Junit5用的在Junit4场景下反而容易出兼容问题。我建议单独引入上面的依赖干净省心。2.2 测试类的三种写法从最推荐到不推荐推荐写法一MockitoJUnitRunner MockRunWith(MockitoJUnitRunner.class) public class OrderServiceTest { Mock private OrderMapper orderMapper; Mock private StockService stockService; InjectMocks private OrderService orderService; // 测试方法... }这是最经典、代码量最少的写法。InjectMocks会把这俩Mock对象自动注入到OrderService里不需要手写setter。推荐写法二JUnit4原生Runner MockitoAnnotationspublic class OrderServiceTest { Mock private OrderMapper orderMapper; Mock private StockService stockService; InjectMocks private OrderService orderService; Before public void setUp() { MockitoAnnotations.initMocks(this); } // 测试方法... }这种写法适合那些还需要同时使用PowerMock比如要Mock静态方法的项目因为PowerMock的Runner跟MockitoJUnitRunner只能二选一搭配时就得用原生Runner配合注解初始化。注意MockitoAnnotations.initMocks(this)在Mockito 3.x里已经标记为废弃官方推荐用MockitoAnnotations.openMocks(this)功能一样只是返回值类型不同建议直接用新的。不推荐写法手动new MockOrderMapper orderMapper Mockito.mock(OrderMapper.class); StockService stockService Mockito.mock(StockService.class); OrderService orderService new OrderService(orderMapper, stockService);这种写法也不是不能用只是当依赖多了以后代码会变得特别啰嗦而且InjectMocks做构造注入时还能帮你在构造器参数顺序变化时省去修改测试代码的麻烦。2.3 关于误用SpringBootTest的反思我再多说一句别一上来就在类上写SpringBootTest。先不说环境准备和启动时间的问题一旦用了这个注解你就要考虑数据库连接、Redis连接、MQ中间件单测就变成了一座“微型生产环境”测试稳定性直接取决于这些外部组件的状态。真实的业务项目里单元测试的最佳实践是宁可多写几个Mock对象也不要去动Spring容器。等单元测试覆盖出稳定逻辑了再针对几条关键链路单独写几个SpringBootTest的集成测试来验证真实环境里的运行效果两者职责完全不同。3. 一套可复用的Service层测试流程模板我最早觉得写测试难是因为面对一个几百行的Service方法不知道从哪下手。后来带新人我发现只要给出一套固定的拆解流程任何人照着走都能把测试写出来。这套流程我给起了个名字叫“四步走”简单说就是拆依赖、搭桩、调方法、验结果。3.1 第一步把方法里的依赖调用画清楚写测试之前先把被测方法从头到尾读一遍。创建一个新Order它可能需要调userService.checkUserExists(userId)校验用户是否存在调orderMapper.insert(order)把订单写入数据库调stockService.deductStock(productId, count)扣减库存调mqProducer.send(orderCreatedEvent)发出事件。把这些依赖调用按顺序列出来每个依赖就是你要Mock的对象每个方法就是你要打桩的点。这里有个很实用的经验先列依赖再列每个依赖在本次测试里要被调用的方法签名的返回值类型。这样你就能快速知道需要写几个when操作的桩。这一步做扎实了后面基本就是体力活。3.2 第二步根据行为分支确定需要哪些测试用例一个方法里最常见的逻辑就是if-else分支判断。比如上面那个创建订单的流程可能会有用户不存在应该抛出UserNotExistsException库存不足应该抛出StockNotEnoughException一切正常订单插入成功后发送了MQ消息。一条分支对应一个测试方法这是硬标准。分支覆盖率就是这么一点点堆出来的。我一般建议一个正常流程happy path加每个异常分支起步至少是“1 N个if”个用例。方法里还有循环、有返回值计算逻辑的还要再补边界值用例。3.3 第三步按“Given-When-Then”结构写测试方法我写测试统一用这种三段式结构别人看我的测试代码能秒懂我看别人的测试代码也不用猜Test public void createOrder_normalFlow_orderCreatedAndMessageSent() { // Given准备前置数据打好桩 long userId 1L; long productId 10L; int count 2; when(userService.checkUserExists(userId)).thenReturn(true); when(stockService.deductStock(productId, count)).thenReturn(true); // When调用被测方法 Order order orderService.createOrder(userId, productId, count); // Then验证结果 assertNotNull(order); assertEquals(CREATED, order.getStatus()); verify(orderMapper, times(1)).insert(any(Order.class)); verify(mqProducer).send(any(OrderCreatedEvent.class)); }Given做数据准备和打桩When执行目标方法Then做断言和验证。这个方法名也是约定俗成的格式方法名_场景_期望结果光看名字就知道这个用例想干什么后期维护成本极低。3.4 第四步别忽略不关心调用的verify验证很多新手写到这里就停了——方法跑通了结果断言对了看起来没啥问题。但实际上有时候测试通过恰恰掩盖了问题的存在。我举个例子创建订单这个流程如果预期是“用户不存在时必须抛异常且不能调用Mapper”那么测试里除了断言异常之外还应该补一句verify(orderMapper, never()).insert(any(Order.class));这种“反向验证”的作用是确保异常分支真的是“早返回了”而不是“调用完再抛异常”。不同写法在出错时的影响面完全不同。比如上面那个场景如果真的在Mapper插入之后才抛UserNotExistsException那数据库里就会多出一条脏数据这种问题是普通异常断言完全发现不了的。这套流程走下来一个Service方法总能被拆得干干净净测试写起来跟流水线一样顺畅。用顺手了之后我唯一的烦恼反而是Mockito的API用得不熟经常查方法名所以下节整理几个高频用法。4. 几个高频的Mockito用法写测试时闭着眼睛也要能写出来Mockito的核心API总共就十几个业务层测试翻来覆去用的更是只有几个。我把最常用的整理成了一张表方便查阅。使用场景标准写法说明指定方法返回指定值when(obj.method(args)).thenReturn(value)最基础打桩指定方法抛出异常when(obj.method(args)).thenThrow(new RuntimeException())模拟异常分支不关心具体参数when(obj.method(any(Long.class))).thenReturn(value)参数匹配器无返回值方法打桩doNothing().when(obj).method(any())不写也行但写出来更明确无返回值方法抛异常doThrow(new RuntimeException()).when(obj).method(any())注意写法与thenThrow不同验证调用次数verify(obj, times(2)).method(args)精确验证某方法被调用两次验证从未调用verify(obj, never()).method(any())结合异常分支使用验证调用顺序InOrder inOrder inOrder(mapper); inOrder.verify(mapper).insert(...)少用但关键流程有用4.1 thenThrow和doThrow怎么选一个经验thenThrow和doThrow最关键的区分点是被Mock的方法有返回值时用thenThrow是void方法时用doThrow。// 有返回值的方法 when(stockService.deductStock(productId, count)).thenThrow(new StockNotEnoughException()); // void方法 doThrow(new RuntimeException()).when(mqProducer).send(any(Message.class));但这里有个容易踩的坑如果你在when里面写了一个会真实执行的方法调用比如when(stockService.deductStock(anyLong(), anyInt())).thenReturn(true);deductStock虽然是Mock对象的方法但在when调用时Mockito会先记录这次调用的参数然后才返回桩值。只要逻辑不复杂一般不炸。可一旦遇到那种在方法内部对参数做了复杂计算、或者有泛型丢失的场景就直接改用doReturn(true).when(stockService).deductStock(anyLong(), anyInt())这种写法不会提前执行方法安全性更高。4.2 参数匹配器的一个大坑全匹配或全不匹配写when和verify的时候如果某个参数用了any()、eq()这类匹配器那所有参数都必须用匹配器不能一个用匹配器一个用真实值。// 错误示范第一个参数用了any第二个参数用了真实值 when(orderMapper.insert(any(Order.class), 1L)).thenReturn(1); // 正确写法 when(orderMapper.insert(any(Order.class), eq(1L))).thenReturn(1);这个错很隐蔽因为编译不报错、运行也不报错只会提示“Invalid use of argument matchers”很多新手第一次遇到基本都是一脸懵。记住了混用匹配器和真实值是个红线。4.3 verify里的times和atLeast一旦配合错就测了个寂寞verify家族里有times(1)、atLeastOnce()、atLeast(2)、atMost(3)这类方法。我的习惯是能用times(1)的就不用atLeastOnce因为越精确的断言对测试的保护越强。有的测试方法里写了verify(mapper).insert(any())这种默认其实是times(1)但如果后续代码里在循环里多调了一次这个验证依然通过问题就被漏掉了。真正需要atLeast/dontCare的场景基本是那种异步回调、并发执行、或者内部有重试机制的方法这些情况对次数本身就具备不可预测性。4.4 懒人技巧使用默认答案减轻打桩负担如果有几个方法返回一个空集合就行Mockito默认返回的就是空集合所以when(userMapper.selectByCondition(any())).thenReturn(Collections.emptyList())这一句直接省略成when(userMapper.selectByCondition(any())).thenReturn(emptyList())或者干脆不写桩也是空集合。这里有个浑水摸鱼的小技巧Mockito对返回List的方法默认打桩值就是一个空ArrayList对返回Optional的方法默认返回Optional.empty()。你不写桩它也不会NPE。只有那些需要返回具体值的场景才需要显式写when。提示Mockito默认对返回值为引用类型的Mock方法返回的是“默认值”即null或者空集合。所以你可以在测试前先想清楚哪些桩必须写哪些桩不写也行能省不少行数。5. 核心案例实战从订单服务到用户注册的完整拆解光讲API用法还不够我直接上两个真实场景完整走一遍从拆解到落地的全过程。5.1 案例一订单创建服务——多依赖协作这是一个比较典型的订单创建流程场景牵扯到用户校验、库存扣减、订单插入、消息发送四个环节。Service public class OrderService { private final UserService userService; private final StockService stockService; private final OrderMapper orderMapper; private final MqProducer mqProducer; public OrderService(UserService userService, StockService stockService, OrderMapper orderMapper, MqProducer mqProducer) { this.userService userService; this.stockService stockService; this.orderMapper orderMapper; this.mqProducer mqProducer; } public Order createOrder(long userId, long productId, int count) { if (!userService.checkUserExists(userId)) { throw new UserNotExistsException(用户不存在); } if (!stockService.deductStock(productId, count)) { throw new StockNotEnoughException(库存不足); } Order order new Order(); order.setUserId(userId); order.setProductId(productId); order.setCount(count); order.setStatus(CREATED); orderMapper.insert(order); mqProducer.send(new OrderCreatedEvent(order)); return order; } }按照四步走流程来测试第一步拆依赖一共四个userService、stockService、orderMapper、mqProducer。第二步数分支有两个if判断所以至少三个用例——用户不存在异常、库存不足异常、正常流程。第三步和第四步直接上代码RunWith(MockitoJUnitRunner.class) public class OrderServiceTest { Mock private UserService userService; Mock private StockService stockService; Mock private OrderMapper orderMapper; Mock private MqProducer mqProducer; InjectMocks private OrderService orderService; Test public void createOrder_userNotExists_throwExceptionAndNoDependencyCalled() { // Given when(userService.checkUserExists(1L)).thenReturn(false); // When UserNotExistsException exception assertThrows( UserNotExistsException.class, () - orderService.createOrder(1L, 10L, 2) ); // Then assertEquals(用户不存在, exception.getMessage()); verify(stockService, never()).deductStock(anyLong(), anyInt()); verify(orderMapper, never()).insert(any(Order.class)); verify(mqProducer, never()).send(any()); } Test public void createOrder_stockNotEnough_throwExceptionAndNoInsert() { // Given when(userService.checkUserExists(1L)).thenReturn(true); when(stockService.deductStock(10L, 2)).thenReturn(false); // When StockNotEnoughException exception assertThrows( StockNotEnoughException.class, () - orderService.createOrder(1L, 10L, 2) ); // Then assertEquals(库存不足, exception.getMessage()); verify(orderMapper, never()).insert(any(Order.class)); verify(mqProducer, never()).send(any()); } Test public void createOrder_normalFlow_orderCreatedAndMessageSent() { // Given when(userService.checkUserExists(1L)).thenReturn(true); when(stockService.deductStock(10L, 2)).thenReturn(true); // When Order order orderService.createOrder(1L, 10L, 2); // Then assertNotNull(order); assertEquals(CREATED, order.getStatus()); assertEquals(2, order.getCount()); verify(orderMapper, times(1)).insert(any(Order.class)); verify(mqProducer, times(1)).send(any(OrderCreatedEvent.class)); } }这里有个细节想提醒一下在“用户不存在”的用例里我不仅断言了抛异常还把后面三步的verify全写了never()。这看起来有点“啰嗦”实际作用却是保护业务规则——如果以后有人把checkUserExists放到库存扣减之后执行这个测试直接挂掉提醒你业务语义被改变了。assertThrows是Junit4.13开始有的API如果你还在用老版本需要改成try-catch方式try { orderService.createOrder(1L, 10L, 2); fail(Expected UserNotExistsException); } catch (UserNotExistsException e) { assertEquals(用户不存在, e.getMessage()); }建议直接把Junit升级到4.13.2老写法太丑了。5.2 案例二带缓存的用户注册逻辑——验证并发场景要注意的细节再看一个带缓存的场景。这个服务的特点是先查缓存缓存没有再查数据库然后写回缓存。public class UserService { private final UserMapper userMapper; private final RedisTemplateString, String redisTemplate; private static final String USER_KEY_PREFIX user:; public UserService(UserMapper userMapper, RedisTemplateString, String redisTemplate) { this.userMapper userMapper; this.redisTemplate redisTemplate; } public User getUserById(long userId) { String cacheKey USER_KEY_PREFIX userId; String cacheValue redisTemplate.opsForValue().get(cacheKey); if (cacheValue ! null) { return JSON.parseObject(cacheValue, User.class); } User user userMapper.selectByPrimaryKey(userId); if (user ! null) { redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(user)); } return user; } }测试这个方法的第一个坑redisTemplate.opsForValue()返回的是ValueOperations这个对象本身也需要Mock。如果你直接when(redisTemplate.opsForValue()).thenReturn(...)需要先准备一个ValueOperations的Mock对象Mock private ValueOperationsString, String valueOperations; Before public void setUp() { when(redisTemplate.opsForValue()).thenReturn(valueOperations); }注意这个桩不能在字段初始化时写必须在Before里写因为Mock注解的valueOperations要先完成创建才能被引用。然后写三个用例——缓存命中、缓存未命中且数据库有数据、缓存未命中且数据库无数据Test public void getUserById_cacheHit_returnFromCache() { // Given when(valueOperations.get(user:1)).thenReturn({\id\:1,\name\:\张三\}); // When User user userService.getUserById(1L); // Then assertNotNull(user); assertEquals(张三, user.getName()); verify(userMapper, never()).selectByPrimaryKey(anyLong()); verify(valueOperations, never()).set(anyString(), anyString()); } Test public void getUserById_cacheMissAndDbHit_cacheAndReturnUser() { // Given when(valueOperations.get(user:2)).thenReturn(null); User dbUser new User(); dbUser.setId(2L); dbUser.setName(李四); when(userMapper.selectByPrimaryKey(2L)).thenReturn(dbUser); // When User user userService.getUserById(2L); // Then assertEquals(李四, user.getName()); verify(valueOperations, times(1)).set(eq(user:2), anyString()); } Test public void getUserById_cacheMissAndDbMiss_returnNull() { // Given when(valueOperations.get(user:3)).thenReturn(null); when(userMapper.selectByPrimaryKey(3L)).thenReturn(null); // When User user userService.getUserById(3L); // Then assertNull(user); verify(valueOperations, never()).set(anyString(), anyString()); }这个案例想表达的核心是测试时不要只盯着Service方法本身它依赖的那些“间接对象”比如ValueOperations也要纳入打桩范围。你Mock了一个RedisTemplate但没Mock它返回的ValueOperations那get方法默认返回null所有的缓存命中场景就永远测不到。5.3 关于构造器注入的实践心得上面两个例子的Service用的都是构造器注入我特别推荐测试优先的情况下选择这种注入方式。原因很简单构造器注入让InjectMocks可以非常方便地自动完成组装而且如果依赖是必须的编译器就把这种约束表达出来了Service类根本没法在没有依赖的情况下new出来。如果项目里是Autowired字段注入构建测试类也还能用但会出现一种诡异情况某个字段被Autowired但实例化时没被注入InjectMocks用反射往里塞的时候因为字段是private final无法修改导致测试直接起不来。这种问题排查起来特别心累不如早点换成构造器注入。6. 覆盖率、断言质量与测试的可维护性别让测试成为负担写完测试还远没有结束测试代码本身也是一份长期维护的资产。很多团队测试写了不少覆盖率也好看但一次需求改动让几十个测试集体亮红灯从此测试就成了众矢之的。6.1 覆盖率不是越高越好重点看核心分支我见过有人晒99%的行覆盖率的这种数据只有在团队闲得慌、代码又特别简单的情况下才拿得到。实际业务里很多配置类、启动类、DTO的getter/setter根本没必要测。我更推荐关注核心业务链路的分支覆盖率。如果一个方法有五个if分支测试只覆盖了两个分支覆盖率是40%这个值就该警惕。分支覆盖率比行覆盖率更有参考价值它能直接反映哪些业务逻辑没有测试保护。用JaCoCo做覆盖率扫描时重点看两个指标行覆盖率不低于70%~80%就行分支覆盖率核心业务规则所在类尽量到80%以上。如果某个复杂方法分支覆盖率特别难看不用急着硬补先评估一下这个方法是否值得测、能不能拆成几个更小的方法来降低测试成本。有时候方法的可测性差恰恰说明设计上不够清晰。6.2 断言质量写得烂的断言和没写没区别我评审过不少测试代码最常看到的“无效断言”是这两种第一种断言的不是业务结果而是“Mock对象的调用次数”。比如前面例子里的verify(mqProducer, times(1)).send(any())如果被测方法里根本没有发MQ而只是自己写日志这个断言就毫无意义因为验证的都是我们自己打桩的内容。第二种断言太宽松。比如assertNotNull(order.getStatus());这个断言在任何情况下都能通过因为它只验证了非null而状态到底是不是“CREATED”根本没检查。这种断言写一百个也白搭覆盖率看着挺高实际防护力为零。一个实用的做法是拿掉所有Mock桩删除测试看哪些代码会失去保护。如果一个测试方法里所有断言都是在验证Mock交互而不是真实业务结果那这个测试要么写错了要么被测方法本身太“虚”了。6.3 测试代码同样要维护命名、结构与话术说到测试可维护性我最头疼的是看一些老项目的测试方法名全是test1、test2根本看不懂在测什么。后来我定的规矩是方法名必须能表达三层语义被测方法、测试场景、预期行为。// 推荐 getUserById_cacheMissAndDbHit_cacheAndReturnUser createOrder_userNotExists_throwExceptionAndNoDependencyCalled // 不推荐 test1 testUser createOrderTest方法名长了点但好处是巨大的。测试失败时你光看测试报告里的方法名就知道是哪块逻辑出问题了根本不用点开失败的详情排查效率直接翻倍。另外测试类里按业务模块拆几组Nested内部类或者空行分组都比把几百个测试方法平铺在一个类里要清晰得多。Junit4对Nested支持不如Junit5可以用空行注释分区来替代。6.4 重构目标让“新写业务代码顺带写测试”成为习惯前面所有内容讲完最后想聊一个很现实的问题为什么很多程序员宁愿项目延期也不愿意写单测我的观察是并不是他们不想写而是业务代码本身就不好测。方法写了四百行、一个方法里调了十个外部依赖、逻辑全部藏在私有方法里——这种代码谁看着都头大。所以与其逼着自己“先写代码后补测试”不如在写业务代码的时候就想着“这个逻辑将来要测”顺手把方法拆小、把依赖通过构造器暴露、把副作用比如发消息后置。代码写得可测了测试自然就写得顺了。这个正向循环一旦建立起来写单测真的没那么痛苦。7. 那些网上教程从来不会告诉你的坑最后这部分我把这些年踩过的、问过别人的、帮别人排查过的坑集中列一下每一个都是真实场景里能让你卡半天的问题。7.1 私有方法、静态方法怎么测Junit4 Mockito的组合测不了静态方法、私有方法、构造函数。这是Mockito的边界不是你的用法错了。常见的应对方案有三个私有方法通过public方法间接覆盖它的左右两个分支静态方法提取成Spring Bean注入或者用PowerMock、Mockito的mockStatic需要Mockito 3.4以上并且额外引入mockito-inline构造函数抽一个Factory接口把创建逻辑包进去测试时Mock这个Factory。需要说明的是Mockito.mockStatic用起来比想象中要敏感它会污染整个线程的静态方法定义如果多个测试类并发跑就会串。能不用尽量别用除非那段静态方法所属的类是第三方工具类、实在改不了。7.2InjectMocks注入失败字段类型模糊当Service里有两个同类型的依赖比如Resource private UserMapper userMapper; Resource private LogMapper logMapper;如果logMapper也继承了某个共同的接口InjectMocks在反射注入时可能按字段名去匹配合适的Bean匹配不到就安静的置null——它不报错就是字段为null实测一跑NPE。应对方式也很简单能改代码就建议用Qualifier加上限定名改不了就退回手动构造OrderService orderService new OrderService(userMapper, logMapper, otherService);这种场景下兜底的手动构造反而最可靠别迷信InjectMocks它不是万能的。7.3 异步逻辑verify之前先等一下业务方法里如果开了异步线程去发消息、写日志测试里直接verify大概率失败——因为异步线程还没执行完Mockito就验证完了。我常用的处理方式有两种方案一引入一个CountDownLatch或Awaitility在验证前等待异步完成方案二如果团队对这个异步调用的时序要求不严格可以在单元测试里只验证“异步任务被提交了”用verify(executorService, times(1)).execute(any(Runnable.class))代替对异步内部逻辑的验证。至于异步内部逻辑本身的正确性更适合单独针对那个Runnable写一个纯单元测试不要塞在对外方法的测试里。7.4 时间相关的测试别傻傻等真实时间如果业务代码里有System.currentTimeMillis()、LocalDate.now()这类跟当前时间强相关的逻辑测试起来非常痛苦。我的做法是在Service里注入一个Clock测试时给它一个固定的时间点Test public void expireTime_withFixedClock_returnsExpectedValue() { Clock fixedClock Clock.fixed(Instant.parse(2024-01-01T00:00:00Z), ZoneId.systemDefault()); ExpireService service new ExpireService(fixedClock); // 接下来被测方法里的时间就固定了 }这种方式比用Mockito去mock静态方法干净得多而且对业务侵入很小。现在很多项目里用LocalDateTime.now()顺手惯了真要测试时就发现根本没法控制时间提前设计一个Clock依赖能少很多麻烦。7.5 Lombok与Mockito的组合问题如果被测类里有Lombok生成的Builder和AllArgsConstructorMockito的InjectMocks会优先选择最大的构造函数注入逻辑有时候会跟预期不一致。最典型的坑是一个类既有全参构造又有无参构造但无参构造是NoArgsConstructorInjectMocks就可能把依赖注入到全参构造里属性顺序跟你测试类里字段声明顺序不对应导致注入错位。遇到这种情况没有太好的银弹我的处理方案就是放弃InjectMocks改为手动构造。既然依赖错位不可控不如交给最显式的方式测试代码多一点但心理踏实。另外多说一个问题如果你用的Lombok版本跟JDK版本不匹配会出现“Lombok生成的代码没有生效但编译不报错”的情况测试跑到一半报各种奇怪的NullPointerException。排查方式很简单把编译后的class反编译一下看一眼或者换开发工具重新编译一次。写在最后业务层单元测试这件事说到底不是技术难题而是思维方式的转变。从“这代码怎么测”到“这代码当初怎么设计成这样才能好测”中间隔着的就是一次完整的项目历练。我个人的体会是Mockito这套工具最大的价值不是帮你把测试写出来而是强迫你把一个Service方法拆成“依赖什么、做什么判断、产生什么副作用”三个维度去看待。当你能把业务逻辑用这种视角清晰描述出来的时候写单测只是顺带的事而对业务本身的理解也会上一个台阶。如果你正准备在自己的项目里开始补单元测试不用急着一天把所有类都写完。挑一个最核心的Service按上面这套流程写三五个用例跑通了、看覆盖率上去了你就会发现原来业务层的单测并没有想象中那么难。代码这东西真跑起来才算数。去写吧。