Mockito实战:核心API解析与单元测试避坑指南
2026/9/9 14:06:48 网站建设 项目流程

上个项目尾声的时候,组里一个同事跑过来问我:为什么我本地跑测试全绿,一上CI就挂?我过去一看,他那条测试链路里居然连了真实数据库,还在测试里往库里插了一条数据,跑完也不清理。CI环境一并发,数据相互污染,测试自然就挂了。他一脸无辜地说:不连数据库,那我Service层怎么测?

这就是典型的不理解单元测试该怎么写。他需要的不是数据库,而是一个Mock——把外部依赖替换成一个可控的替身,让测试只关心当前类自己的逻辑。这个场景,Mockito就是最顺手的工具。

这篇文章我就从实战角度聊透Mockito。不堆概念,直接从代码出发,讲清楚Mockito每个核心API的语义、常见用法背后的逻辑,以及我在真实项目里踩过的坑。无论你是刚接触单元测试的新手,还是写了一段时间但总觉得Mockito用起来别扭的老手,这篇都能给你点实在的东西。

1. Mockito到底解决了什么痛点——从一段真实“测试地狱”说起

1.1 没有Mock的时候,单元测试为什么那么难写

先回想一下没有Mock框架的日子。你写了一个OrderService,它内部调用了PaymentClient发起支付、调UserRepository查用户信息、调MessageProducer发通知。现在你想测OrderService.createOrder(),你会发现整个测试根本没法写:

  • PaymentClient,就走真实支付流程,测试一次扣一次钱;
  • UserRepository,就得连数据库,还得提前准备测试数据;
  • MessageProducer,消息可能真的发到MQ里,下游服务跟着遭殃。

你只能在测试里拼命构造一堆真实对象,然后祈祷环境是好的、数据是存在的、网络是通的。真实项目里,依赖往往不止三层,一个Service可能依赖四五个组件,每个组件又各自有依赖。这种依赖链带来的结果是:写一个测试的时间比写业务代码还长,跑一次测试要等好几秒甚至几十秒,而且动不动就因为环境问题挂掉。

1.2 替身演员思维:测试真正想验证的是什么

Mock的核心思想其实特别朴素:你的被测类只负责自己的逻辑,外部依赖的返回结果是它无能为力的事情,所以测试里要做的就是把这些依赖换成替身,并且规定替身的行为。

这里面有一个特别关键的认知转变:单元测试的对象是“单元”,不是“链路”。不少人把单元测试写成了集成测试——连上数据库、调起Redis、发个HTTP请求,一整条链路全跑通才敢说测试通过。这看起来“更真实”,但恰恰违背了单元测试的初衷。单元测试要验证的是:给定输入,当前这段代码逻辑是否正确,分支是否覆盖全,异常是否被处理。至于依赖方返回什么、有没有bug,那是它们自己单测要管的事。

打个比方,你要测一个厨师做菜的水平,没必要把整个后厨的食材供应商都拉来现场供货,你只需要告诉厨师“这是上好的五花肉”,然后看他怎么处理这块肉。供应商供没供对货,那是供应商的问题。Mock就是那个“告诉他是什么肉”的人。

1.3 为什么是Mockito,而不是其他方案

Java生态里Mock工具其实不少,老一代的EasyMock,新一代的MockK(Kotlin专用)、PowerMock、JMockit,都有人用。Mockito能成为事实标准,核心就三点:

  1. 语法自然,贴近手写替身的直觉。Mockito的when(x).thenReturn(y)读起来几乎就是英语句子,学习成本极低。你可以很快从“写不了测试”过渡到“能写出大多数测试”。
  2. 社区活跃,和JUnit、Spring Boot生态配合极好。Spring Boot的spring-boot-starter-test里直接内置了Mockito和AssertJ,开箱即用。大部分Java项目的单测栈就是JUnit 5 + Mockito + AssertJ,这一套组合的资料、问答、踩坑记录都极其丰富,遇到问题基本能搜到答案。
  3. 功能覆盖足够日常使用。Mockito其实比很多人以为的强得多——不光能Mock接口和类,还支持静态方法Mock、final类Mock、回调式Answer、Spy真实对象等。90%的测试场景用Mockito一个就够了,不需要再叠加PowerMock那种重武器。

但也必须诚实地说,Mockito不是万能的,比如私有方法它就Mock不了(这其实是好事,私有方法应该通过公有方法的行为间接验证)。后面我会专门讲它做不到什么,以及怎么绕开。

2. 核心API基本功:mock、when、verify的准确语义

2.1 mock():创建一个假的依赖对象

先看最基础的操作——创建一个Mock对象:

import static org.mockito.Mockito.*; public class OrderServiceTest { @Test void mockUsage() { // 创建一个假的PaymentClient PaymentClient paymentClient = mock(PaymentClient.class); // 创建被测对象,把Mock传给它的构造函数 OrderService orderService = new OrderService(paymentClient); } }

这一步干了什么?mock(PaymentClient.class)会在运行时动态生成一个PaymentClient的子类(Mockito用ByteBuddy做的字节码增强),这个假对象的所有方法默认都不会走真实逻辑,而是返回“默认值”:

  • 返回int的方法返回0
  • 返回boolean的方法返回false
  • 返回引用类型的方法返回null
  • 返回ListSetMap等集合的方法返回空的集合(不是null);
  • 返回Optional的方法返回Optional.empty()
  • void方法什么都不做。

这就有意思了。很多Mockito新手在第一步就会踩坑:userRepository.findByUsername("alice")返回的是null,结果被测代码里user.getName()直接抛NullPointerException。别急,这正是when要解决的问题——你需要在Mock上“规定”它什么场景返回什么。

2.2 when().thenReturn():给替身设定剧本

@Test void createOrder_shouldSuccess() { UserRepository userRepository = mock(UserRepository.class); PaymentClient paymentClient = mock(PaymentClient.class); User alice = new User(); alice.setId(1L); alice.setBalance(new BigDecimal("100.00")); // 规定:当调用findById(1L)时,返回alice这个假用户 when(userRepository.findById(1L)).thenReturn(alice); // 规定:当调用pay(any())时,返回true表示支付成功 when(paymentClient.pay(any())).thenReturn(true); OrderService orderService = new OrderService(userRepository, paymentClient); Order order = orderService.createOrder(1L, "item-001", new BigDecimal("50.00")); assertThat(order).isNotNull(); assertThat(order.getStatus()).isEqualTo(OrderStatus.PAID); }

这里需要注意一个语义细节:when(userRepository.findById(1L))这句里的findById(1L),在Mock对象上调用时不会真的执行任何逻辑,它只是在“记录”这次调用的参数。Mockito内部会把这个调用记录到一个匹配器状态里,紧接着的thenReturn就是给这个“参数匹配 => 返回值”的映射关系绑定规则。

所以,写when时的参数相当关键。Mockito默认用的是equals来比较参数,也就是说,when(userRepository.findById(1L))只匹配参数恰好是Long 1的调用。如果你测试里传的是1L,代码里用的是从别处算出来的一个long,数值相同就没问题;但如果参数是复杂对象,你又没重写equals,那大概率匹配不上,Mock就会返回默认值。

2.3 verify():验证调用发生过没有

when管的是“Mock返回什么”,verify管的是“被测代码到底跟协作对象说了什么”。这是Mockito里和when同样重要、但经常被新手忽略的API。

@Test void createOrder_shouldCallPaymentAndNotify() { // mock两个依赖 PaymentClient paymentClient = mock(PaymentClient.class); MessageProducer messageProducer = mock(MessageProducer.class); when(paymentClient.pay(any())).thenReturn(true); OrderService orderService = new OrderService(paymentClient, messageProducer); orderService.createOrder(1L, "item-001", new BigDecimal("50.00")); // 验证:pay()被调用了,而且只被调用了一次 verify(paymentClient, times(1)).pay(any()); // 验证:消息发送被调用了一次 verify(messageProducer, times(1)).send(anyString()); }

verify的语义是断言:如果测试执行完,paymentClient.pay()没有被调用,或者被调用了两次,verify就会抛TooFewActualInvocationsTooManyActualInvocations,测试直接失败。这给了你一个有力的工具去验证行为,而不只是验证返回值。

常用的验证次数参数有:

  • times(n):恰好n次;
  • atLeastOnce():至少1次;
  • atLeast(n):至少n次;
  • atMost(n):最多n次;
  • never():从不调用。

我自己的习惯是:除非业务上明确要求某个方法不能被调用(比如幂等判断里不该重复通知),否则一律用times(1)而不是atLeastOnce()。因为“至少一次”往往掩盖了被测代码里多写了一个循环导致重复调用的问题,而这类问题恰恰是单测最适合抓的。

2.4 无返回值方法的三个陷阱:doThrow、doAnswer、doNothing

void方法不能用when(mock.doSomething()).thenReturn(x)来设置行为,因为when里面必须有一个返回值,void没有。Mockito针对无返回值方法有一组doXxx方法:

// 让send()抛异常 doThrow(new RuntimeException("MQ down")).when(messageProducer).send(anyString()); // 让send()什么都不干(默认就是什么都不干,加这个纯粹是显式表达意图) doNothing().when(messageProducer).send(anyString()); // 让send()执行自定义逻辑,比如捕获参数 doAnswer(invocation -> { String message = invocation.getArgument(0); capturedMessage = message; return null; }).when(messageProducer).send(anyString());

这里有一个非常容易踩的坑:when(messageProducer.send(anyString()))这种写法编译都过不了(因为when需要接收一个带返回值的方法调用),但有些人会写成when(messageProducer.send(anyString()).thenReturn(...))——这其实是把代码写在了when外面。一旦写成这样,Mock对象上的send()方法会被真的执行一次(虽然是Mock的无副作用实现),看起来好像没问题,语义却完全错了。正确做法就是直接用doXxx().when(mock).voidMethod(any())

3. 注解驱动与Spring集成:@Mock、@InjectMocks的正确打开方式

3.1 注解三板斧:@Mock、@InjectMocks、@Spy

看了上面手写mock(PaymentClient.class)的写法,有人可能觉得还好。但真实项目里一个被测Service往往有三四个依赖,每个都手动创建、手动构造,测试类的setup区域会很啰嗦。Mockito提供了注解风格,配合JUnit 5可以大幅度简化代码:

@ExtendWith(MockitoExtension.class) // JUnit 5 集成 class OrderServiceTest { @Mock private UserRepository userRepository; @Mock private PaymentClient paymentClient; @Mock private MessageProducer messageProducer; @InjectMocks private OrderService orderService; @BeforeEach void setUp() { when(userRepository.findById(1L)).thenReturn(new User()); } }

各注解的作用:

  • @Mock:等价于mock(Xxx.class),在测试类实例化后自动完成Mock对象的创建和注入;
  • @InjectMocks:这是关键。它会创建一个OrderService的真实实例,然后把当前类里所有的@Mock对象按“构造器注入 > Setter注入 > 字段注入”的顺序尝试注入进去。大多数情况下,如果OrderService只有一个构造函数,直接就能注入成功;
  • @Spy:创建的是“真实对象”的代理,默认走真实方法,但你可以在特定方法上覆盖行为。这个后面会细讲。

3.2 @InjectMocks 的注入顺序和陷阱

@InjectMocks的注入逻辑有一个容易让新人懵的点:它优先选构造器。如果被测类有多个构造器,Mockito会选参数最多的那个尝试注入。如果构造器注入失败(比如参数类型找不到对应的Mock),才会退回Setter注入和字段注入。

这意味着什么?假设OrderService有两个构造器:一个接受两个参数,一个接受三个参数,而你只Mock了其中两个依赖,那么Mockito会优先选三个参数的构造器,然后发现第三个参数匹配不上,就注入失败,甚至可能干脆给你注入null。此时的报错往往很隐晦——测试一跑就NPE,你排查半天才反应过来是@InjectMocks注入问题。

所以我的建议是:被测类的构造器保持单一、清晰。让一个Service的依赖都通过构造器传进来,既利于Spring的构造器注入,也利于Mockito的自动注入,两全其美。

3.3 为什么不推荐@MockBean

在Spring Boot项目里,还有一个常见的写法是@MockBean

@SpringBootTest @MockBean private PaymentClient paymentClient;

@MockBean会把Mock对象塞进Spring的ApplicationContext中,替换掉原来的Bean。这个写法在“只想Mock一个组件但其他Bean都走真实链路”的集成测试里是有用的。但它有两个明显的坑:

  1. @SpringBootTest会启动整个Spring容器,一个测单个Service逻辑的单元测试,启动容器就花了好几秒,完全违背了单元测试要快、要轻的初衷。
  2. Mock状态容易跨测试泄漏。Spring容器在测试类之间是复用的,@MockBean的Mock对象也会被缓存复用。如果你在测试A里when(...).thenReturn(...),没有在@AfterEach里重置,测试B里可能还在用测试A设置的Mock行为,导致测试之间有隐性依赖,顺序一变就挂。

一句话总结:测Service的单体逻辑,用@ExtendWith(MockitoExtension.class)+@Mock+@InjectMocks,让测试在毫秒级跑完;只有当你确实要验证Bean装配、切面、过滤器这些容器级行为时,才考虑@SpringBootTest配合@MockBean

3.4 一个完整的测试类长什么样

下面给一个综合示例,把上面讲的都串起来:

@ExtendWith(MockitoExtension.class) class OrderServiceTest { @Mock private UserRepository userRepository; @Mock private PaymentClient paymentClient; @Mock private MessageProducer messageProducer; @InjectMocks private OrderService orderService; private User alice; @BeforeEach void setUp() { alice = new User(); alice.setId(1L); alice.setBalance(new BigDecimal("100.00")); } @Test void createOrder_余额足够_支付成功_返回已支付订单() { when(userRepository.findById(1L)).thenReturn(alice); when(paymentClient.pay(new BigDecimal("50.00"))).thenReturn(true); Order order = orderService.createOrder(1L, "item-001", new BigDecimal("50.00")); assertThat(order.getStatus()).isEqualTo(OrderStatus.PAID); assertThat(order.getTotalAmount()).isEqualByComparingTo("50.00"); verify(paymentClient, times(1)).pay(new BigDecimal("50.00")); verify(messageProducer, times(1)).send(eq("order.created")); } @Test void createOrder_余额不足_支付失败_抛出业务异常() { when(userRepository.findById(1L)).thenReturn(alice); when(paymentClient.pay(new BigDecimal("150.00"))).thenReturn(false); assertThatThrownBy(() -> orderService.createOrder(1L, "item-001", new BigDecimal("150.00"))) .isInstanceOf(BusinessException.class) .hasMessageContaining("insufficient balance"); verify(paymentClient, times(1)).pay(new BigDecimal("150.00")); verify(messageProducer, never()).send(anyString()); } }

注意这里的assertThatThrownBy来自AssertJ,配合Mockito用起来非常顺手。测试方法名我习惯用中文描述行为——“余额不足_支付失败_抛出业务异常”一眼就能看懂这条用例测的什么场景,这比testCreateOrder2这种命名可读性强太多。

4. 进阶玩法:参数匹配、Answer回调与静态方法Mock

4.1 ArgumentMatcher:when(any())到底意味着什么

简单的场景里,when(paymentClient.pay(...))可以直接传真实参数值,Mockito用equals匹配。但真实项目里,参数往往不是常量,而是被测代码临时计算出来的。比如下单金额是itemPrice * quantity - couponAmount这种,你在测试里很难精确算出最终传给pay的值,这时候就要用参数匹配器:

// 匹配任何BigDecimal when(paymentClient.pay(any(BigDecimal.class))).thenReturn(true); // 匹配大于0的金额 when(paymentClient.pay(argThat(amount -> amount.compareTo(BigDecimal.ZERO) > 0))) .thenReturn(true); // 匹配特定的用户ID和任何字符串 when(messageProducer.send(eq("order.created"), anyString())).thenReturn(true);

使用参数匹配器时,同一方法的所有参数都必须用匹配器。举个例子,如果方法签名是send(String topic, String message),你写when(messageProducer.send(eq("order.created"), "hello"))就是错的,因为"hello"是原始值而不是匹配器,Mockito会抛InvalidUseOfMatchersException。正确写法是when(messageProducer.send(eq("order.created"), anyString()))

为什么?因为Mockito的匹配器本质上是往一个栈里记录匹配条件,when声明式地读取这个栈里的条件来建立匹配规则。如果一部分参数用匹配器入栈、一部分用原始值直接比较,栈的状态就对不上了。这是Mockito新手最容易遇到的异常之一,报错信息虽然长,但其实只要记住“要么全用匹配器,要么全用原始值”就完了。

4.2 thenAnswer:当返回值需要根据输入动态计算

thenReturn适合返回固定值的场景。但如果Mock方法的返回值需要根据入参动态计算,就要用thenAnswer。举个真实场景:

// 模拟一个累加的金额计算器,每次调用根据传入的参数动态返回 when(priceCalculator.calculate(any(Item.class))).thenAnswer(invocation -> { Item item = invocation.getArgument(0); return item.getPrice().multiply(item.getQuantity()); });

在回调里,invocation.getArgument(0)拿到的是实际传入的参数,你可以基于它做任何计算。这比把所有可能的入参都枚举一遍然后thenReturn优雅得多。

还有一个常见用途:模拟耗时操作或重试逻辑。比如被测代码里有一个Thread.sleep(1000),你不想真的等1秒,就可以用doAnswer配合TimeUnit.MILLISECONDS.sleep(10)来缩短;甚至可以直接Rethrow,测试重试次数。

4.3 Mockito-inline:静态方法、final类的Mock

Mockito 3.4.0之前,静态方法Mock需要借助PowerMock或Mockito的额外扩展。3.4.0之后,官方提供了mockito-inline模块,让静态方法Mock成为第一等公民。在spring-boot-starter-test里其实默认不带mockito-inline,需要手动引入:

<dependency> <groupId>org.mockito</groupId> <artifactId>mockito-inline</artifactId> <version>5.x.x</version> <scope>test</scope> </dependency>

引入后就可以这样Mock静态方法:

@Test void testStaticMethod() { try (MockedStatic<OrderNumberGenerator> mockedStatic = mockStatic(OrderNumberGenerator.class)) { mockedStatic.when(() -> OrderNumberGenerator.generate()).thenReturn("ORDER-001"); String orderNo = orderService.generateOrderNumber(); assertThat(orderNo).isEqualTo("ORDER-001"); mockedStatic.verify(() -> OrderNumberGenerator.generate(), times(1)); } }

这里有个极其重要的细节:MockedStatic必须放在try-with-resources块里,因为静态方法的Mock是全局的,Mockito在MockedStatic.close()时会恢复原来的静态方法实现。如果你忘了关闭,这个Mock会影响同进程里的其他测试,导致“这个测试类单独跑没问题,和别的类一起跑就挂”的诡异现象。

同样的,final类在Mockito 2.x时代也Mock不了,但从2.1.0开始mockito-inline默认支持final类Mock。所以如果你用的是Spring Boot 2.1+(内置的Mockito通常都高于这个版本),直接mock(FinalClass.class)就行,不需要额外配置。

4.4 lenient()与严格模式:如何应对多余的Stubbing

Mockito的JUnit 5集成(MockitoExtension)默认开启严格stubbing:如果你在测试里设置了某个when(...).thenReturn(...),但实际执行时这个Mock方法从未被调用,测试就会被判失败,报UnnecessaryStubbingException

严格模式的本意是好的——逼你清理没用的stubbing,避免测试维护者误以为某个依赖被用了。但有些场景确实会导致误报:比如一个基类测试类里给多个测试方法准备了公共的Mock,某个测试只用到了其中一部分。这时候有几种处理方式:

  1. 在不需要的stubbing上显式加lenient()
lenient().when(userRepository.findById(1L)).thenReturn(alice);
  1. 在测试方法内把公共stubbing和具体场景分开,用私有方法构造不同的mock设置;
  2. 如果确实需要一个“全局都生效”的默认行为,用@BeforeEach里设置,然后在需要的测试里覆盖。

我个人更倾向于:公共Mock设置尽量只设那些所有测试都真的会用到的东西,具体场景的Mock就在每个测试方法里单独设。这样每个测试自己就能说明它依赖什么,读起来反而清晰。过度抽公共stubbing,测试类是短了,但可读性直线下降。

5. 实战踩坑:这些Mockito的“暗坑”我基本都趟过了

5.1 when()和doReturn()到底选哪个

这是Mockito社区的一个经典问题。大多数人默认写when(mock.method()).thenReturn(value),但某些场景下when会出问题,必须用doReturn().when(mock).method()

  • 当被Mock的方法返回voidwhen不能用,只能用doXxx
  • 当Mock对象是spy且真实方法会在when(...)调用时抛出异常:比如你when(spy.someMethod()).thenReturn(x),这时候spy.someMethod()会先执行真实方法,如果真实方法抛异常,when还没机会设置返回值就挂了。用doReturn就不会执行真实方法,可以直接设置行为。

最典型的例子是@Spy一个类,它的某个方法内部调用了外部服务,而外部服务异常了:

// 错误写法:spy的真实方法会被执行,可能抛异常 when(orderServiceSpy.calulateTotal()).thenReturn(new BigDecimal("10")); // 正确写法:不会触发真实方法 doReturn(new BigDecimal("10")).when(orderServiceSpy).calulateTotal();

所以一个重要原则:对真正的Mock对象,用when没问题;对@Spy或可能抛异常的真实对象,优先用doReturn

5.2 Mock对象默认返回null,直接导致NPE

前面提过,Mock对象上没设置行为的方法,返回引用类型时默认是null。这在实践中经常变成NPE的源头。

举个例子,你的被测代码是:

User user = userRepository.findById(userId); String email = user.getEmail(); // 这里如果user是null,直接NPE

测试里你只写了when(userRepository.findById(userId)).thenReturn(user),但user对象本身如果没完全初始化,比如user.getEmail()返回null,后面拼接字符串时就炸了。这类NPE非常难排查,因为Mockito本身不报错,报错的是业务代码。

应对办法:

  1. 测试数据要尽量完整构造,能set的都set上,不要偷懒只set业务会用到的字段;
  2. 如果Mock对象的某个方法确实不会被调用,但又担心返回null引发连锁NPE,可以设置RETURNS_DEEP_STUBS或默认返回Optional.empty()、空集合的Mock配置
// 创建Mock时指定默认答案,返回空的Optional UserRepository userRepository = mock(UserRepository.class, RETURNS_DEEP_STUBS);

但我的经验是,RETURNS_DEEP_STUBS这种“链式自动Mock”用起来有点魔幻,容易让测试离真实逻辑越来越远,新手慎用。更稳妥的做法是老老实实把要用的Mock返回值都设置清楚。

5.3 测试之间Mock状态互相污染

Mock对象本身在测试方法结束后就会被丢弃,但如果你用了@BeforeEach里设置的when,而某个测试方法里又对这个Mock做了新的when设置,Mockito的规则是后设置的覆盖先设置的。这就出现了一个现象:测试A先跑,它设置的Mock行为是在@BeforeEach基础上的,所以没问题;测试B后跑,B自己在方法里改了同一个Mock的行为,A如果再次执行(比如IDE里单独跑A),行为却不再是@BeforeEach里定义的那个了。

这种测试间的隐式依赖非常坑。我见过一个项目,测试类里跑了半年都全绿,某天有人往@BeforeEach里加了一行when,结果四个测试同时挂,找到根因花了大半天。解法是:

  • 每个测试方法要完全自包含@BeforeEach只放所有测试都一致的基础数据,不放具体的行为设置;
  • 如果某个Mock的行为被多个测试共用,抽成一个私有方法,每个测试显式调用它,而不是靠@BeforeEach悄悄共享。

5.4 静态方法Mock的关闭问题

mockStatic必须放在try-with-resources里这件事,我真的在CI上栽过一回。那个测试类里我用了mockStatic(OrderNumberGenerator.class),但忘记关,结果同一批跑的另一个测试类里,OrderNumberGenerator.generate()一直被Mock着,返回了固定的ORDER-001,所有订单号重复,一堆断言过不去。当时日志里一点提示都没有,纯粹是“那个测试类单独跑全绿,整个模块跑全红”的经典症状。

排查方法也分享给你:把测试调成按字母序跑一遍,再反序跑一遍,如果结果不一样,基本可以断定是Mock状态泄漏。这时候逐个检查是不是有MockedStaticMockedConstruction没关闭。

5.5 过度Mock导致测试失效

还有一类坑不是技术上的,而是方法论上的:Mock太多,把被测代码的真实逻辑也Mock掉了一大半。比如有人测Service的逻辑时,把orderRepository.save()的返回值也Mock成null,然后被测代码里用了这个返回值做下一步判断,测试自己都不知道自己到底在测什么。

判断标准很简单:测试里Mock的应该是“协助者”,不是“被测者”。被测类的内部计算、分支判断、业务规则,都应该在测试里真实执行。如果为了省事把被测类内部调用的私有方法也想办法Mock掉(比如继承覆盖、反射注入),那这个测试的防护价值就大打折扣了。遇到这种情况,我更倾向于调整被测代码的拆分粒度,让逻辑更容易测试,而不是硬Mock。

6. 与Spring Boot项目的整合思路:让单测跑得又快又稳

6.1 项目里常见的分层边界与Mock策略

说几个常见的整合场景。在Spring Boot Web项目里,Controller、Service、Mapper/Repository三层边界最清晰,单测的Mock策略每层不太一样:

层级被测对象Mock的方向测试重点
ControllerXxxControllerMock Service参数绑定、状态码、响应结构、异常处理
ServiceXxxServiceMock Mapper/Repository/外部Client业务规则、分支覆盖、事务边界、异常抛出
MapperXxxMapper一般不需要Mock(走真实MyBatis测试或H2内存库)SQL正确性、映射关系

Service层是Mockito使用频率最高的地方,因为Service里的业务逻辑最复杂、最需要单测保护。Controller层的单测往往依赖MockMvc,同时把Service Mock掉,这样测的是“HTTP语义”而不是业务逻辑。

6.2 WebMvcTest中怎么用Mockito

Spring Boot里测Controller,最常用的组合是@WebMvcTest+@MockBean。但前面我说过@MockBean不推荐用于Service单测。在Controller层,它的应用场景是合理的,因为@WebMvcTest只切片加载Web层相关Bean,不会启动全容器,速度可控。

@WebMvcTest(OrderController.class) class OrderControllerTest { @Autowired private MockMvc mockMvc; @MockBean private OrderService orderService; @Test void createOrder_返回201和订单号() throws Exception { when(orderService.createOrder(anyLong(), anyString(), any(BigDecimal.class))) .thenReturn(new Order("ORDER-001")); mockMvc.perform(post("/orders") .contentType(MediaType.APPLICATION_JSON) .content("{\"userId\":1,\"itemId\":\"item-001\",\"amount\":50.00}")) .andExpect(status().isCreated()) .andExpect(jsonPath("$.orderNo").value("ORDER-001")); } }

这个场景里,@MockBean的Mock对象会被放进Web层测试的上下文里,替代真实OrderServiceBean,不会影响其他测试类。用起来是安全的。

6.3 事务与异常场景的测试思路

单元测试里最怕的就是“对象内部自己调自己”或者“事务控制逻辑”没法测。Mockito对这两类场景主要有两个应对思路:

  1. 测试异常传播路径:通过doThrow让Mapper抛DataAccessException,验证Service把异常包装成业务异常后抛出,而不是吞掉。
doThrow(new DataAccessException("db down") {}).when(orderMapper).insert(any(Order.class)); assertThatThrownBy(() -> orderService.createOrder(...)) .isInstanceOf(BusinessException.class) .hasMessageContaining("create order failed");
  1. 验证事务回滚:事务是Spring容器在管理,单测里不好真实验证,但可以验证“异常发生后,后续代码不再执行”。比如:
when(paymentClient.pay(any())).thenThrow(new PaymentException("timeout")); // 如果Service里catch了PaymentException,那么verify一个后面的调用应该被跳过 verify(orderRepository, never()).save(any());

这比直接测试“事务会不会回滚”更稳妥,毕竟那是@Transactional注解和Spring事务管理器的事,单测不必越俎代庖。

6.4 测试数据构造:不要每行都set一遍

最后分享一个很实际的技巧。测试里最烦的就是反复构造测试对象,尤其是一个User要set七八个字段才能走到目标逻辑。我的习惯是:

  • 用测试工厂方法或Builder模式组织测试数据;
  • ReflectionTestUtils.setField填充那些没有公开setter但测试需要设置的字段;
  • 一组相关测试共用的基础数据放在@BeforeEach里,具体场景的数据差异在测试方法内覆盖。
private User buildUser(long id, BigDecimal balance) { User u = new User(); u.setId(id); u.setBalance(balance); return u; }

建议组内统一测试数据构造方式,别一个团队里有人用buildUser、有人直接new User()然后逐个set。测试的可读性和可维护性,很多时候就藏在这些小细节里。

7. 什么场景不该用Mockito,以及我最后想说的

7.1 过度依赖Mock反而让测试失去价值

我见过一个极端的例子:同事为了测一个工具类,把工具类依赖的DateTimeFormatter也Mock了,还Mock了System.currentTimeMillis()(用mockStatic)。当时看起来“够隔离”,但后来业务把工具类改成内部不用DateTimeFormatter了,测试还在跑,Mock设置全部变成无效stubbing,严格模式直接挂。这种Mock就是在“给测试加锁”,而不是“给业务加保护”。

一个经验判断:如果一个测试需要Mock三个以上层次才能跑通,先停下来看看被测代码是不是设计得太耦合了。单元测试的最高境界是“测试代码写起来也像业务代码一样自然”,如果写测试时每个Mock都要思考半天,很可能被测代码本身就需要重构了。

7.2 真实项目里单测依赖链的治理

Mockito只是工具,单测能不能写好,更多取决于被测代码的依赖设计。这里给几个可落地的建议:

  1. 优先构造器注入,别用字段注入@Autowired字段注入对Mockito测试来说,你得用@InjectMocks去反射注入,不如构造器注入干净。
  2. 高层依赖接口而不是具体类。依赖接口Mock起来没有障碍;如果依赖的是具体类,而类里有final方法或私有方法,Mockito会受限。
  3. 面向边界而不是面向实现。Service依赖的应该是“支付网关”这个概念接口,而不是某一个具体SDK的Client类。这样测试时可以轻松Mock接口,业务代码也不会被SDK绑架。

7.3 一点个人体会

把Mockito用好的核心,其实不在API熟练度,而在于对“单元”的边界感。你清楚当前这个测试要验证哪一段逻辑,自然就知道该Mock掉哪些、不该Mock掉哪些;你清楚Mock对象只是替身,自然就不会试图让替身去做真身该做的事。

如果你刚上手,别急着追求连static、final都能Mock的全能技巧,先把mockwhenverify这几个基础API练到条件反射,然后再慢慢往参数匹配、Answer回调、静态Mock这些进阶能力上走。顺序反了,很容易被一堆高级API绕晕,反而写不出可靠的测试。

最后分享一个日常小技巧:写完一个测试类后,你可以故意在业务代码里改错一个细节,比如把一个分支条件的>改成>=,然后跑一下测试,看看有没有用例能抓出来。如果抓不出来,说明这个测试的保护力还不够,值得再补一条。Mockito给了你快速写测试的能力,但“测试是否有效”这件事,还得靠你对自己代码的理解来兜底。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询