Mockito 模拟框架:隔离被测单元
Mockito 模拟框架:隔离被测单元
为什么需要模拟框架
在单元测试中,我们希望将测试焦点严格限制在被测单元(通常是一个类的方法)上。但现实中的对象往往依赖数据库连接、网络服务、文件系统或其他复杂组件。如果直接使用真实依赖,测试会变得缓慢、脆弱且难以控制。为了让单元测试真正“单元”化,我们需要一种方式来替换这些外部依赖,创造出可控、可预测的测试环境。这正是 Mockito 擅长的领域。
Mockito 是 Java 生态中最流行的模拟框架。它允许你用简洁的 API 创建模拟对象(Mock),设定这些对象的行为,并验证被测代码与它们之间的互动。通过隔离被测单元,你可以获得:
- 快速执行:无网络、磁盘 I/O,毫秒级完成测试。
- 确定性:排除外部服务不稳定造成的随机失败。
- 精确控制:强制依赖返回指定值或抛出异常,全面覆盖边界情况。
- 交互验证:确保被测代码以预期的方式调用依赖。
环境准备
Mockito 的使用非常简单,只需添加相应依赖即可。对于 Maven 项目,在 pom.xml 中加入:
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.11.0</version>
<scope>test</scope>
</dependency>
如果使用 Gradle:
testImplementation 'org.mockito:mockito-core:5.11.0'
Mockito 5.x 完全支持 JUnit 5,并与 JUnit 4 兼容。建议搭配 JUnit 5 使用以获得最佳体验。
快速入门:第一个模拟对象
让我们从一个典型的场景开始:UserService 依赖 UserRepository 从数据库获取用户。测试 UserService 时,我们不想连接真实数据库。
// 依赖接口
public interface UserRepository {
User findById(Long id);
}
// 被测服务
public class UserService {
private final UserRepository repository;
public UserService(UserRepository repository) {
this.repository = repository;
}
public String getUserName(Long id) {
User user = repository.findById(id);
return user != null ? user.getName() : "Unknown";
}
}
测试代码:
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;
class UserServiceTest {
@Test
void shouldReturnUserName() {
// 1. 创建模拟对象
UserRepository mockRepo = mock(UserRepository.class);
// 2. 设定模拟行为:当调用 findById(1L) 时,返回一个预设用户
User fakeUser = new User(1L, "John");
when(mockRepo.findById(1L)).thenReturn(fakeUser);
// 注:对于 void 方法,使用 doReturn(...).when(...) 或 doThrow(...)
// 3. 将被测对象与模拟对象绑定
UserService service = new UserService(mockRepo);
// 4. 执行被测方法
String name = service.getUserName(1L);
// 5. 验证结果
assertEquals("John", name);
// 6. 验证交互(可选):确保 findById(1L) 被调用了一次
verify(mockRepo).findById(1L);
}
}
这个例子中,mock() 创建了一个 UserRepository 的模拟实例。when(mockRepo.findById(1L)) 定义了一个存根(stub)——告诉 Mockito 当碰到这个调用时应返回什么。随后执行业务逻辑,最后用断言验证返回值,并用 verify() 确认模拟对象上的方法确实被调用了。
核心 API 详解
创建模拟对象
mock(Class<T> classToMock):最常用的方式,为类或接口创建模拟对象。@Mock注解:结合MockitoExtension(JUnit 5)或MockitoJUnitRunner(JUnit 4)自动创建。
@ExtendWith(MockitoExtension.class) // JUnit 5 推荐
class UserServiceTest {
@Mock
UserRepository repository; // 自动注入模拟对象
@InjectMocks
UserService service; // 自动将 @Mock 对象注入到该实例中
}
@InjectMocks 会尝试通过构造器、Setter 或字段注入将模拟对象注入到标注的实例中,省去手动创建的代码。
设定行为:when/thenReturn 与 stub 系列
when(...).thenReturn(...) 是最基础的 stub 方法。对于有返回值的方法,它定义调用特定参数时返回什么。
when(repository.findById(1L)).thenReturn(new User(1L, "Alice"));
when(repository.findById(2L)).thenReturn(null);
// 连续调用可返回不同值
when(repository.findById(3L)).thenReturn(new User(3L, "Bob"), new User(3L, "Charlie"));
// 第一个调用返回 Bob,第二个调用返回 Charlie
对于 void 方法,由于语法限制,需使用 doReturn(...).when(mock).voidMethod(...) 形式的存根,或者使用 doThrow() 来模拟异常。
doThrow(new RuntimeException("network error")).when(repository).save(any());
验证交互:verify
验证某个模拟对象的方法是否以预期的参数被调用,以及调用的次数。
verify(mockRepo).findById(1L); // 至少调用一次(默认)
verify(mockRepo, times(2)).findById(1L); // 精确调用 2 次
verify(mockRepo, never()).findById(999L); // 从未调用
verify(mockRepo, atLeastOnce()).findById(1L);
verify(mockRepo, atLeast(2)).findById(1L);
verify(mockRepo, atMost(3)).findById(1L);
验证顺序?可以用 InOrder:
InOrder inOrder = inOrder(mockRepo);
inOrder.verify(mockRepo).findById(1L);
inOrder.verify(mockRepo).findById(2L);
参数匹配器
如果不需要精确指定参数值,可以使用参数匹配器(Argument Matchers)来放宽条件。
when(repository.findById(anyLong())).thenReturn(defaultUser);
verify(repository).save(any(User.class));
常用匹配器:any(), anyInt(), anyString(), eq(), startsWith(), contains() 等。关键规则:如果其中一个参数使用了匹配器,所有参数都必须使用匹配器。
// 错误:混合使用
when(service.process("real", anyString())).thenReturn(x); // 抛出异常
// 正确:全部使用匹配器
when(service.process(eq("real"), anyString())).thenReturn(x);
模拟异常
测试失败路径时,让模拟方法抛出异常。
// 对有返回值的方法
when(repository.findById(0L)).thenThrow(new IllegalArgumentException("Invalid ID"));
// 对 void 方法
doThrow(new DataAccessException("DB down")).when(repository).save(any());
高级主题
部分模拟:spy
有时你希望保留对象的真实行为,只修改部分方法。这时使用 spy(间谍)。
List<String> list = new ArrayList<>();
List<String> spyList = spy(list);
// 真实行为
spyList.add("one");
System.out.println(spyList.get(0)); // 输出 "one"
// 修改部分行为
when(spyList.size()).thenReturn(100);
System.out.println(spyList.size()); // 输出 100
// 验证调用
verify(spyList).add("one");
注意:对 spy 使用 when(...).thenReturn(...) 会调用真实方法,这可能带来副作用。更安全的方式是使用 doReturn():
doReturn(100).when(spyList).size();
参数捕获器:ArgumentCaptor
当需要断言传递给模拟对象方法的复杂参数时,用 ArgumentCaptor 捕获实际参数。
ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
verify(repository).save(captor.capture());
User savedUser = captor.getValue();
assertEquals("Alice", savedUser.getName());
修改无参方法的返回值
对于已创建好的模拟对象,如果你要修改某个无参方法的默认返回值(无参方法默认返回类型默认值:0、false、null),可以使用 Mockito.when(mock.someMethod())。但如果方法返回基本类型,还可以使用特定的便捷方法:
when(mock.isActive()).thenReturn(true);
模拟静态方法
从 Mockito 3.4 开始,支持模拟静态方法。这需要 mockito-inline 模块(5.x 中已内置)。
try (MockedStatic<ClassName> mocked = mockStatic(ClassName.class)) {
mocked.when(ClassName::staticMethod).thenReturn("mocked");
// 执行测试代码
}
静态模拟会修改全局状态,务必在 try-with-resources 中使用,确保测试后恢复原状。
重置模拟对象
虽然不推荐过度重置(可能是测试设计不良的信号),但可以使用 reset(mock) 来清空所有存根和调用记录。
最佳实践与常见陷阱
- 只为被测单元创建模拟,不要模拟值对象(DTO、实体)或被测类本身。通常模拟接口或依赖的服务。
- 使用
@InjectMocks与@Mock的组合,它让测试代码更干净。但要确保注入路径清晰,推荐使用构造器注入。 - 避免过度指定:不要对每个调用都验证,只验证对业务关键的行为。
verify应验证有意义的副作用。 - 不要模拟类型边界不清晰的类:例如 Java 标准库的类(List 除外。通常用真实对象更好)。
- 始终与 JUnit 5 生命周期结合:使用
@ExtendWith(MockitoExtension.class)自动处理@Mock和@InjectMocks。 - 注意存根参数匹配的完整性:如果存根未覆盖真实调用参数,将返回默认值(null/0/false),可能导致测试遗忘或误报。
- 静态方法、私有方法、final 类模拟需谨慎:它们往往暗示设计需要改进。Mockito 支持这些,但应作为最后手段。
- 清理模拟影响:使用静态模拟或 spy 后,确保测试隔离。JUnit 的
@AfterEach或MockitoExtension会自动处理,但静态模拟需要 try-with-resources。 - 使用
BDDMockito风格:在行为驱动开发(BDD)中,使用given(...).willReturn(...)替代when(...).thenReturn(...),使测试意图更清晰。
总结
Mockito 通过模拟外部依赖,让单元测试真正聚焦于被测单元的逻辑。核心流程清晰:创建模拟 → 定义行为 → 执行被测代码 → 验证结果与交互。随着项目的复杂化,@Mock、@InjectMocks、参数匹配器、验证模式和部分模拟等工具将帮助你构建健壮且可维护的测试套件。
记住,Mockito 不仅是工具,更是设计反馈器。如果发现难以模拟,通常意味着代码耦合过高,应当重构。拥抱隔离,让你的测试如同其被测代码一样清晰可靠。