1. 项目概述:为什么我们需要Mockito与JUnit 5的组合?

如果你写过Java单元测试,尤其是涉及到数据库、网络请求或者复杂对象依赖的测试,那你一定经历过这种痛苦:为了测试一个简单的业务方法,你需要先启动整个Spring容器,连接上真实的数据库,或者等待一个缓慢的外部服务响应。这不仅让测试运行得像蜗牛一样慢,更糟糕的是,测试变得极其脆弱——数据库里的一条无关数据、网络的一次抖动,都可能导致你的测试失败,而你根本不知道问题出在业务逻辑还是外部环境。这种测试,与其说是“单元测试”,不如说是“集成测试”甚至“系统测试”,它失去了单元测试最核心的价值:快速、独立地验证一小段代码的逻辑正确性。

Mockito的出现,就是为了解决这个核心痛点。它允许你创建“模拟对象”(Mock Object),来替代那些真实、笨重、不可控的依赖。你可以精确地控制这个模拟对象的行为:当调用某个方法时,它应该返回什么值?应该抛出什么异常?甚至,你可以验证这个方法是否被以预期的参数、预期的次数调用了。这样一来,你的测试就完全聚焦于待测类本身的逻辑,与外部世界彻底隔离。测试速度从分钟级提升到秒级,稳定性也大大增强。

那么,JUnit 5又扮演什么角色呢?它是测试的“运行框架”和“组织者”。JUnit 5提供了编写测试用例的注解(如 @Test )、生命周期管理( @BeforeEach , @AfterEach )、断言( Assertions )等核心能力。而Mockito则是JUnit 5的“最佳拍档”,专门负责处理依赖的模拟。在JUnit 5的测试环境中,我们可以通过注解(如 @ExtendWith(MockitoExtension.class) )将Mockito无缝集成进来,让创建和注入模拟对象变得异常简单。这个组合,构成了现代Java单元测试的基石。

所以,这个教程的目标非常明确:不是让你死记硬背Mockito的API,而是带你掌握一种高效的单元测试思维和实战技能。从“为什么需要Mock”这个根本问题出发,一步步深入到如何用Mockito优雅地解决各种复杂的测试场景,最终让你能写出运行快、覆盖全、易维护的高质量单元测试。无论你是刚接触测试的新手,还是想系统梳理Mockito高级用法的老手,这篇教程都将提供一条清晰的路径。

2. 环境准备与项目搭建

2.1 依赖配置:Maven与Gradle选型指南

开始之前,我们得先把“武器”准备好。对于Java项目,依赖管理无非Maven和Gradle两大阵营。这里我分别给出配置,你可以根据项目情况选择。

Maven配置 ( pom.xml ): 这是最经典的方式。你需要将以下依赖添加到你的 pom.xml 文件的 <dependencies> 节点中。注意版本号,我推荐使用较新且稳定的版本组合。

<dependencies>
    <!-- JUnit 5 Jupiter API (编写测试) -->
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter-api</artifactId>
        <version>5.10.0</version>
        <scope>test</scope>
    </dependency>
    <!-- JUnit 5 Jupiter Engine (运行测试) -->
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter-engine</artifactId>
        <version>5.10.0</version>
        <scope>test</scope>
    </dependency>
    <!-- Mockito Core -->
    <dependency>
        <groupId>org.mockito</groupId>
        <artifactId>mockito-core</artifactId>
        <version>5.7.0</version>
        <scope>test</scope>
    </dependency>
    <!-- Mockito对JUnit 5的扩展支持(关键!) -->
    <dependency>
        <groupId>org.mockito</groupId>
        <artifactId>mockito-junit-jupiter</artifactId>
        <version>5.7.0</version>
        <scope>test</scope>
    </dependency>
</ dependencies>

为什么需要 mockito-junit-jupiter 这个依赖至关重要。它提供了 MockitoExtension 这个JUnit 5扩展。有了它,你才能使用 @ExtendWith(MockitoExtension.class) 注解,让Mockito自动帮你完成模拟对象的创建和注入(通过 @Mock , @InjectMocks 注解),这是现代Mockito与JUnit 5集成的最佳实践,能极大简化代码。

Gradle配置 ( build.gradle build.gradle.kts ): 对于使用Gradle的项目,配置同样简洁。在 dependencies 块中添加如下内容。

dependencies {
    testImplementation(platform("org.junit:junit-bom:5.10.0")) // 使用BOM管理JUnit版本
    testImplementation("org.junit.jupiter:junit-jupiter")
    testImplementation("org.mockito:mockito-core:5.7.0")
    testImplementation("org.mockito:mockito-junit-jupiter:5.7.0")
}

使用BOM(Bill of Materials)可以确保JUnit相关的多个构件版本一致,避免潜在的兼容性问题,是推荐的做法。

2.2 IDE支持与测试目录结构

依赖加好后,你的IDE(如IntelliJ IDEA或Eclipse)通常会自动识别并下载。确保你的项目有标准的源目录结构:

your-project/
├── src/
│   ├── main/
│   │   └── java/      (你的业务代码)
│   └── test/
│       └── java/      (你的测试代码)
└── pom.xml 或 build.gradle

测试类应该放在 src/test/java 下,并且包名最好与对应的主类包名一致。这样无论是IDE还是构建工具,都能自动发现并运行测试。

注意: 有些老教程或项目可能还在用JUnit 4和Mockito的旧集成方式(比如 @RunWith(MockitoJUnitRunner.class) )。在新项目中,请务必使用JUnit 5 + MockitoExtension 这套组合,它是更现代、更灵活的标准。

3. Mockito核心概念与基础API精讲

3.1 理解Mock、Spy与真实对象

在深入API之前,我们必须厘清Mockito提供的几种测试替身(Test Double)的核心区别。理解它们,是正确选用工具的前提。

Mock(模拟对象): 这是Mockito最核心的功能。当你创建一个类的Mock对象时,你得到的是一个“空壳”。这个对象的所有方法,默认都不会执行真实的逻辑。对于返回值为void的方法,调用它什么都不会发生;对于有返回值的方法,默认会返回“空值”(如null, 0, false等,取决于返回类型)。Mock对象的全部行为,都需要由你通过 when(...).thenReturn(...) 等方式来显式定义。 它适用于替代那些你完全不关心内部实现,只关心其交互的依赖 。例如,测试一个邮件发送服务时,你可以Mock一个 EmailSender ,只验证 send 方法是否被调用,而无需真的发邮件。

Spy(间谍对象): Spy是“部分真实”的对象。它基于一个已经存在的真实对象创建。默认情况下,Spy对象的所有方法调用都会委托给这个真实对象,执行真实的逻辑。但是,你可以选择性地对Spy对象的某些方法进行“拦截”和“打桩”(Stubbing),让它们返回你指定的值或执行你定义的行为,而其他未被Stub的方法则继续执行真实逻辑。 它适用于测试那些你大部分需要真实行为,但只想修改其中一两个方法的场景 。比如,你有一个复杂的计算器类,想测试某个流程,但其中有一个方法会调用一个非常耗时的外部服务,这时你就可以Spy这个计算器,只Mock掉那个耗时方法。

真实对象注入: 这是通过 @InjectMocks 注解实现的。它不是一个替身类型,而是一种注入机制。Mockito会尝试将当前测试类中通过 @Mock @Spy 创建的字段,通过构造函数、setter方法或字段反射的方式,注入到被 @InjectMocks 标记的待测对象中。 它的目的是为了快速构建出待测对象,并自动将其依赖替换为我们准备好的Mock或Spy

为了更直观地对比,请看下表:

特性 Mock对象 Spy对象 @InjectMocks 对象
基础 类的空壳,无真实逻辑 基于真实对象的包装 待测试的真实对象实例
默认行为 所有方法默认什么也不做(或返回空值) 所有方法默认执行真实逻辑 执行自身真实逻辑
行为定制 必须显式定义(打桩)才有行为 可选择性地对部分方法打桩 其依赖的行为由注入的Mock/Spy决定
适用场景 替代完全不需真实逻辑的外部依赖 需要大部分真实逻辑,只修改少数方法的场景 作为单元测试的主体,接收模拟的依赖

3.2 创建测试替身的三种方式

了解了概念,我们来看看如何创建它们。Mockito提供了多种创建方式,适应不同场景。

1. 注解方式(最推荐,与JUnit 5集成) 这是现代单元测试的首选方式,代码最简洁。

import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;

@ExtendWith(MockitoExtension.class) // 关键:启用Mockito扩展
public class OrderServiceTest {

    @Mock
    private InventoryRepository inventoryRepo; // 创建一个Mock

    @Spy
    private EmailValidator emailValidator = new EmailValidator(); // 创建一个Spy,需要提供真实对象实例

    @InjectMocks
    private OrderService orderService; // 创建OrderService实例,并自动注入上面的inventoryRepo和emailValidator

    // ... 测试方法
}

@ExtendWith(MockitoExtension.class) 是魔法发生的地方。它告诉JUnit 5在运行这个测试类时,使用Mockito的扩展来处理 @Mock , @Spy , @InjectMocks 这些注解。

2. 静态工厂方法 如果你不想使用注解,或者在一些静态方法、初始化块中需要创建Mock,可以使用Mockito的静态方法。

import static org.mockito.Mockito.*;

public class ManualMockTest {
    // 创建Mock
    List<String> mockedList = mock(List.class);
    // 创建Spy (需要传入真实对象)
    List<String> realList = new ArrayList<>();
    List<String> spiedList = spy(realList);
    // 创建带自定义配置的Mock
    List<String> strictMock = mock(List.class, withSettings().strictness(Strictness.STRICT_STUBS));
}

这种方式更灵活,但代码量稍多,且需要手动处理依赖注入。

3. 在 @BeforeEach 中初始化 这是一种混合模式,在JUnit 5的生命周期方法中初始化Mock,适合需要对所有Mock进行统一配置的场景。

@ExtendWith(MockitoExtension.class)
public class BeforeEachTest {
    private InventoryRepository inventoryRepo;
    private OrderService orderService;

    @BeforeEach
    void setUp() {
        // 在@BeforeEach中手动创建,可以集中进行复杂配置
        inventoryRepo = mock(InventoryRepository.class, withSettings().verboseLogging());
        orderService = new OrderService(inventoryRepo); // 手动构造注入
    }
}

实操心得: 对于99%的常规测试类, 强烈推荐使用第一种注解方式 。它让代码非常清晰,将对象创建和依赖注入的“家务活”完全交给框架,你只需要关注测试逻辑本身。只有在需要非常特殊的Mock配置(如自定义Answer、设置序列化等)时,才考虑后两种方式。

4. 行为定义与验证:从打桩到断言

创建好Mock对象后,我们就要告诉它“应该怎么做”,这就是行为定义,也叫“打桩”(Stubbing)。测试执行后,我们还要验证它“是不是这么做的”,这就是行为验证。

4.1 方法打桩:控制Mock的返回值与行为

打桩的核心是 when(...).thenReturn(...) 语法链。但Mockito的功能远不止于此。

基础打桩:返回固定值

@Mock
private UserRepository userRepo;

@Test
void testFindUserById() {
    // 定义行为:当调用 userRepo.findById(100L) 时,返回一个预设的User对象
    User expectedUser = new User(100L, "Alice");
    when(userRepo.findById(100L)).thenReturn(Optional.of(expectedUser));

    // 执行测试...
    // 此时调用 userRepo.findById(100L) 将返回 Optional.of(expectedUser)
    // 调用 userRepo.findById(999L) 将返回默认值 Optional.empty() (因为没为999L打桩)
}

连续打桩:模拟多次调用的不同返回值 这在测试重试逻辑或迭代场景时非常有用。

@Test
void testConsecutiveCalls() {
    when(mockList.get(0))
        .thenReturn("first")   // 第一次调用返回"first"
        .thenReturn("second")  // 第二次调用返回"second"
        .thenThrow(new RuntimeException("No more elements")); // 第三次及以后调用抛出异常

    assertEquals("first", mockList.get(0));
    assertEquals("second", mockList.get(0));
    assertThrows(RuntimeException.class, () -> mockList.get(0));
}

模拟异常与Void方法

@Test
void testThrowException() {
    // 模拟方法抛出异常
    when(userRepo.findById(-1L)).thenThrow(new IllegalArgumentException("Invalid ID"));

    assertThrows(IllegalArgumentException.class, () -> userRepo.findById(-1L));
}

@Test
void testVoidMethod() {
    @Mock
    private AuditLogger auditLogger;

    // 对于void方法,使用 doNothing(), doThrow() 等 doXxx().when() 语法
    doThrow(new IOException("Disk full")).when(auditLogger).log(anyString());

    // 测试调用 auditLogger.log(...) 是否会触发异常
}

注意 when(...).thenThrow(...) doThrow(...).when(...) 的区别 :前者用于有返回值的方法,后者用于void方法。用反了会导致编译错误或运行时错误。

使用参数匹配器进行灵活打桩 你不可能为每一个可能的参数值都打桩。这时就需要参数匹配器(Argument Matchers)。

import static org.mockito.ArgumentMatchers.*;

@Test
void testWithArgumentMatchers() {
    // anyLong() 匹配任何Long型参数
    when(userRepo.findById(anyLong())).thenReturn(Optional.of(new User(1L, "Default")));

    // 字符串匹配器
    when(userRepo.findByEmail(endsWith("@company.com"))).thenReturn(new User(2L, "Staff"));
    when(userRepo.findByName(startsWith("Admin"))).thenReturn(null);

    // 多个参数时,如果有一个参数用了匹配器,所有参数都必须用匹配器
    // when(userRepo.update(eq(1L), anyString())).thenReturn(true); // 正确
    // when(userRepo.update(1L, anyString())).thenReturn(true); // 错误!
}

常用的匹配器有: any() , any(Class<T>) , eq(value) , isNull() , notNull() , contains(String) , argThat(Matcher) 等。 argThat 允许你使用自定义的匹配逻辑,功能最强大。

4.2 行为验证:确保交互如约发生

打桩定义了Mock的行为,而验证则检查这些行为是否按预期发生。这是单元测试中断言的一部分,但关注的是对象间的交互(Interaction),而不仅仅是状态(State)。

基础验证:验证方法是否被调用

@Test
void testMethodWasCalled() {
    // 假设我们有一个发送消息的服务
    MessageService messageService = mock(MessageService.class);
    NotificationManager manager = new NotificationManager(messageService);

    manager.notifyUser("user123", "Hello");

    // 验证:messageService的send方法是否被调用了一次,且参数是"user123"和"Hello"
    verify(messageService).send("user123", "Hello");
    // 这是最常用的验证形式
}

验证调用次数

@Test
void testCallTimes() {
    List<String> mockedList = mock(List.class);
    mockedList.add("once");
    mockedList.add("twice");
    mockedList.add("twice");
    mockedList.add("three times");
    mockedList.add("three times");
    mockedList.add("three times");

    // 验证方法被调用的具体次数
    verify(mockedList).add("once"); // 默认就是 times(1)
    verify(mockedList, times(1)).add("once"); // 同上
    verify(mockedList, times(2)).add("twice");
    verify(mockedList, times(3)).add("three times");

    // 验证从未被调用
    verify(mockedList, never()).add("never happened");

    // 验证至少、至多调用多少次
    verify(mockedList, atLeastOnce()).add("three times"); // 至少一次
    verify(mockedList, atLeast(2)).add("twice"); // 至少两次
    verify(mockedList, atMost(5)).add("three times"); // 至多五次
}

验证调用顺序 某些业务流程对调用顺序有严格要求,Mockito也能验证。

@Test
void testVerificationInOrder() {
    List firstMock = mock(List.class);
    List secondMock = mock(List.class);

    firstMock.add("was called first");
    secondMock.add("was called second");
    firstMock.add("was called again");

    // 创建一个InOrder对象,传入需要检查顺序的Mock
    InOrder inOrder = inOrder(firstMock, secondMock);

    // 验证顺序:firstMock.add先于secondMock.add被调用
    inOrder.verify(firstMock).add("was called first");
    inOrder.verify(secondMock).add("was called second");
    // 继续验证firstMock的第二次调用
    inOrder.verify(firstMock).add("was called again");
    // 如果实际调用顺序与此不符,测试将失败
}

验证零交互 有时,你需要确保在某个场景下,Mock对象完全没有被调用过。

@Test
void testZeroInteractions() {
    List mockOne = mock(List.class);
    List mockTwo = mock(List.class);
    List mockThree = mock(List.class);

    // 只使用mockOne
    mockOne.add("one");

    // 验证mockOne被调用过
    verify(mockOne).add(anyString());
    // 验证mockTwo和mockThree从未有过任何交互
    verifyNoInteractions(mockTwo, mockThree);
    // 这是一个非常强的断言,确保没有意外的副作用发生。
}

注意事项: 验证( verify )和打桩( when )是两件独立的事。打桩发生在测试的“准备(Arrange)”阶段,用于设置场景;验证发生在“断言(Assert)”阶段,用于检查结果。不要混淆。另外, 避免过度验证 。只验证那些与测试目标直接相关的、重要的交互。验证每一个Getter/Setter调用会让测试变得极其脆弱,且意义不大。

5. 高级特性与复杂场景实战

掌握了基础,我们就可以挑战更复杂的测试场景了。Mockito的高级特性能让你优雅地处理这些难题。

5.1 参数捕获:深入检查方法调用细节

有时候,仅仅知道一个方法被调用是不够的,我们还需要检查它被调用时传入的具体参数值是什么。特别是当参数是复杂对象,我们想验证其内部状态时, ArgumentCaptor 就派上用场了。

场景: 测试一个用户注册服务,它调用 EmailService.sendWelcomeEmail(User user) 。我们想验证发送的欢迎邮件里,用户的姓名和邮箱是否正确。

@Test
void testUserRegistrationSendsCorrectEmail() {
    // 1. 创建Mock和待测服务
    EmailService emailService = mock(EmailService.class);
    UserRegistrationService service = new UserRegistrationService(emailService);

    // 2. 创建参数捕获器,指定捕获的参数类型是User
    ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class);

    // 3. 执行测试动作
    User newUser = new User("John Doe", "john@example.com");
    service.register(newUser);

    // 4. 验证方法被调用,并捕获当时的参数
    verify(emailService).sendWelcomeEmail(userCaptor.capture());

    // 5. 获取捕获到的参数,并进行断言
    User capturedUser = userCaptor.getValue();
    assertEquals("John Doe", capturedUser.getName());
    assertEquals("john@example.com", capturedUser.getEmail());
    // 你还可以对capturedUser进行更复杂的断言
}

捕获多次调用的参数: 如果方法被调用了多次,可以用 captor.getAllValues() 获取一个参数列表。

verify(emailService, times(3)).sendWelcomeEmail(userCaptor.capture());
List<User> allCapturedUsers = userCaptor.getAllValues();
assertEquals(3, allCapturedUsers.size());
// 然后可以对列表中的每一个User进行断言

实操心得: ArgumentCaptor 非常强大,但要慎用。它本质上是一种“白盒测试”,因为你深入检查了待测对象与依赖的内部交互细节。这可能导致测试与实现耦合过紧——一旦内部调用方式改变(比如参数顺序变了、方法重命名了),即使功能不变,测试也会失败。优先考虑通过测试对象的最终状态或对外输出(返回值)来验证行为,仅在必要时使用参数捕获。

5.2 回答器:动态定义Mock行为

thenReturn thenThrow 是静态打桩,返回值是固定的。但有些场景下,我们希望Mock的行为能根据输入参数动态决定。这时就需要使用 Answer 接口。

场景: 模拟一个ID生成器,每次调用 generateId() 都返回一个不同的、递增的ID。

@Test
void testAnswerInterface() {
    IdGenerator idGenerator = mock(IdGenerator.class);

    // 使用AtomicLong来模拟一个递增的计数器
    AtomicLong counter = new AtomicLong(1000L);

    // 定义Answer:当generateId被调用时,执行一段代码来生成返回值
    when(idGenerator.generateId()).thenAnswer(invocation -> {
        // invocation 包含了本次调用的所有信息(方法、参数等)
        // 这里我们简单地返回一个递增的数字
        return counter.getAndIncrement();
    });

    assertEquals(1000L, idGenerator.generateId());
    assertEquals(1001L, idGenerator.generateId());
    assertEquals(1002L, idGenerator.generateId());
    // 每次调用都返回不同的值
}

Answer 允许你访问方法调用的上下文( InvocationOnMock ),你可以根据参数 ( invocation.getArguments() )、方法名等来定制返回值。这在模拟回调、异步接口或需要复杂逻辑的依赖时非常有用。

另一个常见场景:模拟一个保存方法,返回带ID的实体。

@Test
void testSaveEntityWithGeneratedId() {
    UserRepository repo = mock(UserRepository.class);
    when(repo.save(any(User.class))).thenAnswer(invocation -> {
        User userToSave = invocation.getArgument(0); // 获取第一个参数
        User savedUser = new User(userToSave); // 假设有拷贝构造函数
        savedUser.setId(ThreadLocalRandom.current().nextLong(1000)); // 模拟生成ID
        return savedUser;
    });
}

5.3 测试静态方法与构造方法

长期以来,Mockito无法直接模拟静态方法和构造方法,这是它的一个局限。但在实际项目中,我们难免会遇到需要调用 System.currentTimeMillis() Collections.sort() 或者 new File(path) 这样的代码。从Mockito 3.4.0开始,通过 mockito-inline 构件,我们可以模拟这些了。

1. 模拟静态方法 首先,需要添加 mockito-inline 依赖(如果用了 mockito-core ,它可能已经包含在内,但显式声明版本更稳妥)。

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

然后,在测试中使用 MockedStatic

import org.mockito.MockedStatic;

@Test
void testStaticMethod() {
    // 模拟一个工具类的静态方法
    try (MockedStatic<MyUtilityClass> mockedStatic = mockStatic(MyUtilityClass.class)) {
        // 定义静态方法的行为
        mockedStatic.when(MyUtilityClass::getConfigValue).thenReturn("mocked-value");

        // 在try-with-resources块内,所有对MyUtilityClass.getConfigValue()的调用都会返回"mocked-value"
        assertEquals("mocked-value", MyUtilityClass.getConfigValue());

        // 也可以验证静态方法的调用
        mockedStatic.verify(MyUtilityClass::getConfigValue);
    } // 块结束后,静态方法的模拟自动关闭,恢复原状
}

关键点: MockedStatic 对象必须用 try-with-resources @BeforeEach / @AfterEach 来管理生命周期,确保模拟在测试结束后被关闭,避免影响其他测试。

2. 模拟构造方法 模拟构造方法的使用场景相对较少,通常用于阻止待测代码创建某些难以控制的对象(如网络连接、文件句柄)。

@Test
void testConstructor() throws Exception {
    try (MockedConstruction<ExpensiveDatabaseConnection> mockedConstruction =
             mockConstruction(ExpensiveDatabaseConnection.class,
                 (mock, context) -> {
                     // 这里可以配置mock对象的行为
                     when(mock.isValid()).thenReturn(true);
                 })) {

        // 在try块内,任何 `new ExpensiveDatabaseConnection(...)` 的调用
        // 都不会真的创建对象,而是返回我们上面配置的mock
        MyService service = new MyService();
        service.doSomething(); // 这个方法内部会new ExpensiveDatabaseConnection()

        // 验证构造方法被调用了一次
        assertEquals(1, mockedConstruction.constructed().size());
        ExpensiveDatabaseConnection mockInstance = mockedConstruction.constructed().get(0);
        verify(mockInstance).connect();
    }
}

重要警告: 模拟静态方法和构造方法是一种“终极手段”,它会破坏代码的可测试性设计。频繁使用它们,往往意味着你的业务代码依赖了太多难以模拟的全局状态或具体类,违反了依赖倒置原则。在考虑使用这些功能前,首先应该思考:能否通过重构代码,将静态方法调用包装到一个实例方法中?能否通过依赖注入将对象创建的责任移出去? 重构代码以提高可测试性,永远比使用高级Mock技巧更优先。

6. 与JUnit 5的深度集成与最佳实践

Mockito和JUnit 5的集成不仅仅是加个注解那么简单。理解它们的生命周期和最佳配合方式,能让你的测试代码更健壮、更清晰。

6.1 生命周期注解与Mockito的协作

JUnit 5提供了丰富的生命周期注解来控制测试方法的执行顺序。理解它们与Mock对象的关系很重要。

  • @BeforeAll / @AfterAll : 在所有测试方法执行前/后运行一次,方法必须是 static 注意: 由于方法是静态的,它们无法直接访问由 @Mock @InjectMocks (这些注解依赖于实例字段)创建的Mock对象。如果需要在 @BeforeAll 中使用Mock,必须手动创建(使用 Mockito.mock() )。
  • @BeforeEach / @AfterEach : 在每个 @Test 方法执行前/后运行。这是 最常见 的初始化/清理场所。 @Mock @InjectMocks 注解的字段会在每个测试方法前被重新初始化。你可以在这里为Mock对象打一些公共的桩(Stub),或者执行一些通用的验证。
  • @Test : 测试方法本身。在这里执行具体的测试逻辑和断言。

一个典型的结构如下:

@ExtendWith(MockitoExtension.class)
class OrderServiceIntegrationTest {

    @Mock
    private InventoryService inventoryService;
    @Mock
    private PaymentGateway paymentGateway;
    @InjectMocks
    private OrderService orderService;

    private Order testOrder;

    @BeforeEach
    void setUp() {
        // 每个测试方法运行前都会执行
        // 创建一些公共的测试数据
        testOrder = new Order("order-123", Arrays.asList("item1", "item2"));
        // 为Mock对象打一些所有测试都可能需要的桩
        when(inventoryService.isAvailable(anyString())).thenReturn(true);
        // 注意:过于通用的桩可能会掩盖特定测试的需求,要谨慎。
    }

    @AfterEach
    void tearDown() {
        // 每个测试方法运行后都会执行
        // 可以用来验证一些“总是应该发生”的交互
        verifyNoMoreInteractions(paymentGateway); // 确保没有与paymentGateway发生意外的交互
        // 或者清理一些资源(但Mock对象通常不需要)
    }

    @Test
    void successfulOrder() {
        // 特定测试:覆盖通用桩,定义更具体的行为
        when(paymentGateway.charge(any(BigDecimal.class))).thenReturn(new PaymentResult(true, "success"));
        // 执行测试
        OrderResult result = orderService.placeOrder(testOrder);
        // 断言
        assertTrue(result.isSuccess());
        // 验证特定交互
        verify(paymentGateway).charge(new BigDecimal("199.99"));
    }

    @Test
    void orderFailsWhenInventoryUnavailable() {
        // 这个测试需要不同的库存行为,覆盖通用桩
        when(inventoryService.isAvailable("item1")).thenReturn(false);
        // 执行和断言...
        assertThrows(InventoryException.class, () -> orderService.placeOrder(testOrder));
    }
}

6.2 测试驱动开发中的Mockito应用

TDD(测试驱动开发)的核心循环是“红-绿-重构”。Mockito在“红”和“绿”阶段扮演着关键角色。

  1. 红(编写失败测试): 当你为一个尚不存在的类或方法编写测试时,你可以先定义其依赖的接口(这本身就是一种设计行为),并用Mockito创建这些接口的Mock。然后编写测试,调用待实现的方法,并验证它与Mock的交互。此时运行测试当然是失败的。
  2. 绿(实现功能): 以实现最简单的代码让测试通过为目标。你可以利用Mockito定义的桩行为,专注于实现核心逻辑,而不用真的去实现那些依赖(比如数据库访问)。这让你能快速推进。
  3. 重构: 在测试的保护下,安全地改进代码结构。Mockito的测试会告诉你,你的重构是否改变了对象间的契约。

示例:TDD开发一个简单的任务分配器 假设我们要开发一个 TaskAssigner ,它从一个 TaskQueue 获取任务,然后分配给一个 Worker

// 步骤1:定义接口(设计)
public interface TaskQueue {
    Task getNextTask();
}
public interface Worker {
    boolean perform(Task task);
}

// 步骤2:编写测试(红)
@Test
void shouldAssignTaskToWorkerWhenAvailable() {
    TaskQueue queue = mock(TaskQueue.class);
    Worker worker = mock(Worker.class);
    TaskAssigner assigner = new TaskAssigner(queue, worker); // 类还不存在

    Task mockTask = new Task("test");
    when(queue.getNextTask()).thenReturn(mockTask);
    when(worker.perform(mockTask)).thenReturn(true);

    boolean result = assigner.assignNextTask(); // 方法还不存在

    assertTrue(result);
    verify(queue).getNextTask();
    verify(worker).perform(mockTask);
}
// 运行测试,会编译失败(因为类和方法不存在)。

// 步骤3:实现最简单代码(绿)
public class TaskAssigner {
    private final TaskQueue queue;
    private final Worker worker;
    // 构造函数...
    public boolean assignNextTask() {
        Task task = queue.getNextTask(); // 依赖接口
        return worker.perform(task); // 依赖接口
    }
}
// 现在测试应该通过了。我们只实现了核心流转逻辑。

// 步骤4:重构与增强
// 接下来可以写更多测试:队列为空时怎么办?Worker执行失败时怎么办?
// 然后逐步完善TaskAssigner的逻辑。

通过这个过程,Mockito帮助你 面向接口而非实现进行设计 ,并且让测试成为设计的先行者和守护者。

6.3 保持测试的单一性与清晰度

这是单元测试的黄金法则,Mockito用得好,能极大地帮助实现这一点。

  • 一个测试方法只测一件事: 每个 @Test 方法应该对应一个具体的、明确的场景或路径(例如:“用户密码正确时登录成功”、“库存不足时下单失败”)。避免在一个测试方法里验证多个不相关的分支。
  • 清晰的“准备-执行-断言”三段式: 这是经典的测试结构。在JUnit中,通常对应为:
    • 准备 (Arrange): 创建测试数据,设置Mock对象的行为(打桩)。这部分应该简洁明了。
    • 执行 (Act): 调用待测方法。
    • 断言 (Assert): 验证结果(返回值、状态)和交互(Mock方法调用)。
  • 使用有意义的测试方法名: 方法名应该清晰地表达测试的意图。可以用 should_When_ Given_When_Then 的格式,例如: shouldReturnUser_WhenUserIdIsValid
  • 避免在Mockito验证中使用 any() 过于宽泛: 虽然 any() 很方便,但过度使用会让验证失去意义。尽量使用具体的值或更有针对性的匹配器(如 eq() , startsWith() )。例如,验证发送邮件时,用 verify(emailService).send(eq("user@example.com"), anyString()) 就比两个 any() 要好,因为它至少验证了收件人是对的。

7. 常见陷阱、问题排查与调试技巧

即使经验丰富,在使用Mockito时也难免踩坑。这里总结了一些常见问题和解决方法。

7.1 NullPointerException的常见原因

NPE是Mockito新手最常遇到的错误,通常源于Mock对象没有正确初始化或注入。

  1. 未使用 @ExtendWith(MockitoExtension.class) 这是最最常见的原因。如果你使用了 @Mock @InjectMocks 注解,但测试类上没有加 @ExtendWith ,那么这些注解不会被处理,字段值就是 null

    • 解决: 确保测试类上有 @ExtendWith(MockitoExtension.class)
  2. @BeforeAll 方法中访问 @Mock 字段: @BeforeAll 是静态方法,无法访问非静态的实例字段。 @Mock 字段是在每个测试实例中初始化的。

    • 解决: 将初始化逻辑移到 @BeforeEach 中,或者在该静态方法内手动创建Mock( mock(...) )。
  3. 待测类没有合适的注入点: @InjectMocks 会尝试通过构造函数、setter或字段反射来注入依赖。如果你的待测类只有带参数的构造函数,但Mockito找不到匹配的Mock对象(类型不匹配或数量不够),注入会失败,导致字段为 null

    • 解决: 检查待测类的构造方法或setter方法。确保Mock对象的类型能匹配上。对于复杂情况,可以考虑在 @BeforeEach 中手动构造待测对象。
  4. Spy真实对象时,真实对象为null: 使用 @Spy 注解时, 必须初始化这个字段 ,因为Spy是基于一个已有对象创建的。

    • 错误: @Spy private List myList; (myList为null,无法Spy)
    • 正确: @Spy private List myList = new ArrayList();

7.2 桩行为不生效或验证失败

有时候,你觉得打了桩,但Mock对象并没有按预期返回;或者你觉得方法应该被调用,但验证却失败了。

  1. 参数不匹配: Mockito的桩匹配是非常严格的。 when(mock.someMethod("exact")).thenReturn(...) 只会在参数完全等于 "exact" 时生效。如果你调用 someMethod("Exact") (首字母大写),桩就不会起作用,Mock会返回默认值(如null)。

    • 解决: 使用参数匹配器 anyString() , eq("value") , contains("str") 等来增加灵活性。但要注意,一旦一个参数使用了匹配器, 所有参数都必须使用匹配器
  2. 在Spy对象上错误地使用 when(...) 对于Spy对象,如果你试图对它的某个方法打桩,而该方法有真实逻辑,直接使用 when(spy.realMethod()).thenReturn(...) 会导致真实方法先被调用一次(在 when 阶段),这可能引发异常或副作用。

    • 错误: when(spyList.get(0)).thenReturn("fake"); // 如果spyList是空的, get(0) 会先抛 IndexOutOfBoundsException
    • 正确: 对Spy对象打桩,使用 doReturn(...).when(spy).method(...) 语法。
      doReturn("fake").when(spyList).get(0); // 不会调用真实方法
      
  3. 验证时使用了错误的参数匹配: 和打桩一样,验证时也要注意参数匹配。 verify(mock).method("expected") 要求调用时的参数必须是 "expected"

    • 技巧: 使用 ArgumentCaptor 来捕获实际参数,并在测试失败时打印出来,看看实际传了什么。
  4. 在严格存根模式下误调用: Mockito 3.0+引入了更严格的“严格存根”理念(通过 MockitoExtension 默认开启)。在这种模式下,如果你为Mock对象打了桩,但在测试中从未调用过这个桩,测试结束时Mockito会报告“不必要的存根”警告(并不会失败)。更重要的是, 它不允许你验证未打桩的交互 (除非使用 lenient() 标记)。这其实是一个好特性,能帮你发现无用的测试代码。

    • 现象: 测试通过,但控制台有“Unnecessary stubbings detected”警告。
    • 解决: 检查并移除那些确实不需要的桩。如果某个交互确实不需要打桩但需要验证,可以使用 verify(mock, times(0)).method(...) 来明确验证它未被调用,或者使用 lenient() 标记非严格存根。

7.3 调试技巧:让问题无所遁形

当测试行为不符合预期时,别急着改代码,先好好调试。

  1. 开启详细日志: Mockito可以打印出详细的交互日志,这对调试复杂调用链非常有帮助。

    @Mock(answer = Answers.RETURNS_DEEP_STUBS, verbose = Verbose.MOCKITO_VERBOSE)
    private ComplexService complexService;
    

    或者在创建Mock时指定:

    SomeMock verboseMock = mock(SomeClass.class, withSettings().verboseLogging());
    

    运行测试时,控制台会输出Mock对象上发生的所有方法调用、参数和返回值。

  2. 使用 Mockito.mockingDetails() 这个工具方法可以让你在运行时检查一个对象是否是Mock、是哪种Mock、有哪些桩等信息。

    MockingDetails details = mockingDetails(mockObject);
    System.out.println("Is mock? " + details.isMock());
    System.out.println("Stubbings: " + details.getStubbings());
    
  3. 在IDE中设置断点: 这似乎是最基本的,但别忘了在测试方法、甚至在被Mock的真实类的方法里(如果是Spy)设置断点。观察程序的执行流程,看是否按你预想的路径走。

  4. 编写小而专注的测试: 这是预防问题的根本。一个庞大的测试方法出了问题,你很难定位。而一个只测一个分支的小测试,一旦失败,原因通常非常明显。

最后,记住单元测试的初衷是提升代码质量和开发信心。Mockito是一个强大的工具,但切忌滥用。如果你的测试里充满了复杂的Mock设置和验证,以至于难以理解,那可能是一个信号:你的生产代码耦合度太高了,需要考虑重构,降低依赖的复杂度,让测试变得更简单、更直接。好的测试和好的代码设计,永远是相辅相成的。

更多推荐