Java单元测试实战:Mockito与JUnit 5核心用法与最佳实践
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在“红”和“绿”阶段扮演着关键角色。
- 红(编写失败测试): 当你为一个尚不存在的类或方法编写测试时,你可以先定义其依赖的接口(这本身就是一种设计行为),并用Mockito创建这些接口的Mock。然后编写测试,调用待实现的方法,并验证它与Mock的交互。此时运行测试当然是失败的。
- 绿(实现功能): 以实现最简单的代码让测试通过为目标。你可以利用Mockito定义的桩行为,专注于实现核心逻辑,而不用真的去实现那些依赖(比如数据库访问)。这让你能快速推进。
- 重构: 在测试的保护下,安全地改进代码结构。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对象没有正确初始化或注入。
-
未使用
@ExtendWith(MockitoExtension.class): 这是最最常见的原因。如果你使用了@Mock、@InjectMocks注解,但测试类上没有加@ExtendWith,那么这些注解不会被处理,字段值就是null。- 解决: 确保测试类上有
@ExtendWith(MockitoExtension.class)。
- 解决: 确保测试类上有
-
在
@BeforeAll方法中访问@Mock字段:@BeforeAll是静态方法,无法访问非静态的实例字段。@Mock字段是在每个测试实例中初始化的。- 解决: 将初始化逻辑移到
@BeforeEach中,或者在该静态方法内手动创建Mock(mock(...))。
- 解决: 将初始化逻辑移到
-
待测类没有合适的注入点:
@InjectMocks会尝试通过构造函数、setter或字段反射来注入依赖。如果你的待测类只有带参数的构造函数,但Mockito找不到匹配的Mock对象(类型不匹配或数量不够),注入会失败,导致字段为null。- 解决: 检查待测类的构造方法或setter方法。确保Mock对象的类型能匹配上。对于复杂情况,可以考虑在
@BeforeEach中手动构造待测对象。
- 解决: 检查待测类的构造方法或setter方法。确保Mock对象的类型能匹配上。对于复杂情况,可以考虑在
-
Spy真实对象时,真实对象为null: 使用
@Spy注解时, 必须初始化这个字段 ,因为Spy是基于一个已有对象创建的。- 错误:
@Spy private List myList;(myList为null,无法Spy) - 正确:
@Spy private List myList = new ArrayList();
- 错误:
7.2 桩行为不生效或验证失败
有时候,你觉得打了桩,但Mock对象并没有按预期返回;或者你觉得方法应该被调用,但验证却失败了。
-
参数不匹配: Mockito的桩匹配是非常严格的。
when(mock.someMethod("exact")).thenReturn(...)只会在参数完全等于"exact"时生效。如果你调用someMethod("Exact")(首字母大写),桩就不会起作用,Mock会返回默认值(如null)。- 解决: 使用参数匹配器
anyString(),eq("value"),contains("str")等来增加灵活性。但要注意,一旦一个参数使用了匹配器, 所有参数都必须使用匹配器 。
- 解决: 使用参数匹配器
-
在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); // 不会调用真实方法
- 错误:
-
验证时使用了错误的参数匹配: 和打桩一样,验证时也要注意参数匹配。
verify(mock).method("expected")要求调用时的参数必须是"expected"。- 技巧: 使用
ArgumentCaptor来捕获实际参数,并在测试失败时打印出来,看看实际传了什么。
- 技巧: 使用
-
在严格存根模式下误调用: Mockito 3.0+引入了更严格的“严格存根”理念(通过
MockitoExtension默认开启)。在这种模式下,如果你为Mock对象打了桩,但在测试中从未调用过这个桩,测试结束时Mockito会报告“不必要的存根”警告(并不会失败)。更重要的是, 它不允许你验证未打桩的交互 (除非使用lenient()标记)。这其实是一个好特性,能帮你发现无用的测试代码。- 现象: 测试通过,但控制台有“Unnecessary stubbings detected”警告。
- 解决: 检查并移除那些确实不需要的桩。如果某个交互确实不需要打桩但需要验证,可以使用
verify(mock, times(0)).method(...)来明确验证它未被调用,或者使用lenient()标记非严格存根。
7.3 调试技巧:让问题无所遁形
当测试行为不符合预期时,别急着改代码,先好好调试。
-
开启详细日志: Mockito可以打印出详细的交互日志,这对调试复杂调用链非常有帮助。
@Mock(answer = Answers.RETURNS_DEEP_STUBS, verbose = Verbose.MOCKITO_VERBOSE) private ComplexService complexService;或者在创建Mock时指定:
SomeMock verboseMock = mock(SomeClass.class, withSettings().verboseLogging());运行测试时,控制台会输出Mock对象上发生的所有方法调用、参数和返回值。
-
使用
Mockito.mockingDetails(): 这个工具方法可以让你在运行时检查一个对象是否是Mock、是哪种Mock、有哪些桩等信息。MockingDetails details = mockingDetails(mockObject); System.out.println("Is mock? " + details.isMock()); System.out.println("Stubbings: " + details.getStubbings()); -
在IDE中设置断点: 这似乎是最基本的,但别忘了在测试方法、甚至在被Mock的真实类的方法里(如果是Spy)设置断点。观察程序的执行流程,看是否按你预想的路径走。
-
编写小而专注的测试: 这是预防问题的根本。一个庞大的测试方法出了问题,你很难定位。而一个只测一个分支的小测试,一旦失败,原因通常非常明显。
最后,记住单元测试的初衷是提升代码质量和开发信心。Mockito是一个强大的工具,但切忌滥用。如果你的测试里充满了复杂的Mock设置和验证,以至于难以理解,那可能是一个信号:你的生产代码耦合度太高了,需要考虑重构,降低依赖的复杂度,让测试变得更简单、更直接。好的测试和好的代码设计,永远是相辅相成的。
更多推荐
所有评论(0)