Java 软件测试(四):Mockito提升代码覆盖率的实用技巧
写单元测试的时候,经常会遇到一个问题:覆盖率总是上不去。明明写了很多测试,但还是有很多代码分支没有被执行到。
其实这个问题很常见,特别是在处理复杂业务逻辑的时候。代码覆盖率不仅仅是一个数字,它反映了你的测试到底测了多少代码。
1. 代码覆盖率到底有什么用
覆盖率的真正意义
代码覆盖率说白了就是告诉你:测试代码执行了多少源代码。包括代码行、分支、路径这些维度。
高覆盖率确实能帮你发现一些潜在的bug,让代码质量更好。但是要注意一点:覆盖率高不等于测试质量高。
有些人为了追求100%覆盖率,写了一堆没意义的测试。这样做其实是本末倒置了。
覆盖率的局限性
覆盖率只能告诉你哪些代码被执行了,但不能告诉你测试是否有效。比如你可能执行了某个方法,但没有验证返回值是否正确。
所以在关注覆盖率的同时,更要关注测试的有效性和合理性。
2. Mockito在提升覆盖率中的作用
隔离依赖的威力
写测试最头疼的就是各种依赖。数据库、网络请求、第三方服务,这些东西都会让测试变得复杂。
Mockito的作用就是帮你把这些依赖都"假装"掉。通过创建Mock对象,你可以专注于测试核心逻辑,不用担心外部服务的影响。
比如测试用户登录功能,你不需要真的去连数据库,只需要Mock一个UserRepository就行了。
模拟各种复杂场景
现实中有很多场景很难重现。比如网络超时、数据库连接失败、第三方服务返回异常数据等等。
用Mockito可以轻松模拟这些情况。你可以让Mock对象抛出异常,返回特定的值,甚至模拟延迟响应。
这样就能覆盖那些平时很难测试到的代码分支了。
保证测试的独立性
每个测试用例都应该是独立的,不应该依赖其他测试的结果。Mockito让你可以精确控制依赖的行为,确保每个测试都在一个干净的环境中运行。
这样不仅能提高覆盖率的精确度,还能让测试更加稳定可靠。
3. 提升覆盖率的实战技巧
3.1 全面打桩策略
打桩就是给Mock对象"预设台词"。Mockito提供了很多打桩的方法,合理使用这些方法能让你覆盖更多的代码分支。
多态返回是一个很有用的技巧。同一个方法可以根据不同的参数返回不同的结果,这样就能测试不同的执行路径了。
public class EmailService {
public String sendEmail(String to, String content) {
if (to.endsWith("@work.com")) {
return sendWorkEmail(content);
} else {
return sendPersonalEmail(content);
}
}
private String sendWorkEmail(String content) {
// 发送工作邮件的逻辑
return "Work email sent: " + content;
}
private String sendPersonalEmail(String content) {
// 发送个人邮件的逻辑
return "Personal email sent: " + content;
}
}
@Test
void testSendEmailDifferentTypes() {
EmailService emailService = new EmailService();
// 测试工作邮箱分支
String workResult = emailService.sendEmail("user@work.com", "工作内容");
assertTrue(workResult.contains("Work email sent"));
// 测试个人邮箱分支
String personalResult = emailService.sendEmail("user@gmail.com", "个人内容");
assertTrue(personalResult.contains("Personal email sent"));
}
条件打桩可以让你模拟更复杂的场景。比如根据不同的输入参数返回不同的结果,或者在特定条件下抛出异常。
@Test
void testEmailServiceWithMock() {
EmailService emailService = mock(EmailService.class);
// 工作邮箱返回成功
when(emailService.sendEmail(contains("@work.com"), anyString()))
.thenReturn("工作邮件发送成功");
// 个人邮箱返回成功
when(emailService.sendEmail(not(contains("@work.com")), anyString()))
.thenReturn("个人邮件发送成功");
// 空邮箱抛出异常
when(emailService.sendEmail(eq(""), anyString()))
.thenThrow(new IllegalArgumentException("邮箱不能为空"));
// 测试各种情况
assertEquals("工作邮件发送成功", emailService.sendEmail("test@work.com", "内容"));
assertEquals("个人邮件发送成功", emailService.sendEmail("test@gmail.com", "内容"));
assertThrows(IllegalArgumentException.class, () -> {
emailService.sendEmail("", "内容");
});
}
3.2 深度模拟与验证
有时候你不想完全Mock一个对象,只想Mock其中的某些方法。这时候可以用@Spy或者Mockito.spy()。
Spy对象会保留原有的行为,只对你指定的方法进行模拟。这在测试复杂对象时特别有用。
public class UserService {
public User getUser(String id) {
User user = fetchFromDatabase(id);
if (user != null) {
user = enrichUserData(user);
}
return user;
}
protected User fetchFromDatabase(String id) {
// 从数据库获取用户的逻辑
return new User(id, "defaultName");
}
private User enrichUserData(User user) {
// 丰富用户数据的逻辑
user.setLastLoginTime(new Date());
return user;
}
}
@Test
void testUserServiceWithSpy() {
UserService userService = new UserService();
UserService spyUserService = spy(userService);
// 只模拟数据库查询方法,其他方法保持原有逻辑
User mockUser = new User("123", "张三");
doReturn(mockUser).when(spyUserService).fetchFromDatabase("123");
// 调用getUser方法,会执行真实的enrichUserData逻辑
User result = spyUserService.getUser("123");
assertEquals("张三", result.getName());
assertNotNull(result.getLastLoginTime()); // 验证enrichUserData被执行了
// 验证fetchFromDatabase被调用了
verify(spyUserService).fetchFromDatabase("123");
}
精确验证也很重要。不仅要验证方法被调用了,还要验证调用的次数、顺序等等。
@Test
void testMethodCallTimes() {
List<String> mockList = mock(List.class);
// 调用几次add方法
mockList.add("第一次");
mockList.add("第二次");
mockList.add("第三次");
// 验证add方法被调用了3次
verify(mockList, times(3)).add(anyString());
// 验证特定参数的调用次数
verify(mockList, times(1)).add("第一次");
// 验证clear方法从未被调用
verify(mockList, never()).clear();
}
3.3 参数匹配与捕获
参数匹配器是Mockito的一个强大功能。通过any()、eq()、argThat()等方法,你可以灵活地匹配各种参数。
这样可以让你的测试覆盖更广泛的输入情况。
@Test
void testParameterMatching() {
UserService userService = mock(UserService.class);
// 匹配任何字符串参数
when(userService.getUser(anyString())).thenReturn(new User("default"));
// 匹配特定值
when(userService.getUser(eq("admin"))).thenReturn(new User("管理员"));
// 自定义匹配条件
when(userService.getUser(argThat(id -> id.length() > 5)))
.thenReturn(new User("长ID用户"));
// 测试各种情况
assertEquals("管理员", userService.getUser("admin").getName());
assertEquals("长ID用户", userService.getUser("123456").getName());
assertEquals("default", userService.getUser("123").getName());
}
参数捕获在验证复杂对象时特别有用。你可以捕获方法调用时的参数,然后详细验证参数的内容。
public class NotificationService {
public void sendNotification(Notification notification) {
// 发送通知的逻辑
System.out.println("发送通知: " + notification.getMessage());
}
}
public class OrderService {
private NotificationService notificationService;
public OrderService(NotificationService notificationService) {
this.notificationService = notificationService;
}
public void processOrder(Order order) {
// 处理订单逻辑
if (order.getAmount() > 1000) {
Notification notification = new Notification(
"高额订单",
"订单金额: " + order.getAmount() + "元"
);
notificationService.sendNotification(notification);
}
}
}
@Test
void testOrderProcessingWithCaptor() {
NotificationService notificationService = mock(NotificationService.class);
OrderService orderService = new OrderService(notificationService);
// 创建一个高额订单
Order order = new Order("ORD001", 1500.0);
orderService.processOrder(order);
// 捕获发送的通知
ArgumentCaptor<Notification> captor = ArgumentCaptor.forClass(Notification.class);
verify(notificationService).sendNotification(captor.capture());
// 验证通知内容
Notification capturedNotification = captor.getValue();
assertEquals("高额订单", capturedNotification.getType());
assertTrue(capturedNotification.getMessage().contains("1500.0"));
}
4. 实际业务场景案例
4.1 用户登录功能测试
用户登录是一个典型的复杂业务场景。涉及用户查询、密码验证、账号状态检查等多个步骤。
通过Mockito,我们可以轻松测试各种情况:正常登录、密码错误、账号被锁定、用户不存在等等。
public class UserService {
private UserRepository userRepository;
private PasswordEncoder passwordEncoder;
public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
public LoginResult login(String username, String password) {
// 查找用户
Optional<User> userOpt = userRepository.findByUsername(username);
if (!userOpt.isPresent()) {
return new LoginResult(false, "用户不存在");
}
User user = userOpt.get();
// 检查账号状态
if (!user.isActive()) {
return new LoginResult(false, "账号已被锁定");
}
// 验证密码
if (!passwordEncoder.matches(password, user.getPassword())) {
return new LoginResult(false, "密码错误");
}
return new LoginResult(true, "登录成功");
}
}
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository userRepository;
@Mock
private PasswordEncoder passwordEncoder;
@InjectMocks
private UserService userService;
@Test
void testLoginSuccess() {
// 模拟用户存在且状态正常
User user = new User("testUser", "hashedPassword", true);
when(userRepository.findByUsername("testUser")).thenReturn(Optional.of(user));
// 模拟密码验证成功
when(passwordEncoder.matches("password", "hashedPassword")).thenReturn(true);
// 执行登录
LoginResult result = userService.login("testUser", "password");
// 验证结果
assertTrue(result.isSuccess());
assertEquals("登录成功", result.getMessage());
// 验证方法调用
verify(userRepository).findByUsername("testUser");
verify(passwordEncoder).matches("password", "hashedPassword");
}
@Test
void testLoginUserNotFound() {
// 模拟用户不存在
when(userRepository.findByUsername("nonexistent")).thenReturn(Optional.empty());
LoginResult result = userService.login("nonexistent", "password");
assertFalse(result.isSuccess());
assertEquals("用户不存在", result.getMessage());
// 确保没有调用密码验证
verifyNoInteractions(passwordEncoder);
}
@Test
void testLoginAccountLocked() {
// 模拟用户存在但账号被锁定
User lockedUser = new User("lockedUser", "hashedPassword", false);
when(userRepository.findByUsername("lockedUser")).thenReturn(Optional.of(lockedUser));
LoginResult result = userService.login("lockedUser", "password");
assertFalse(result.isSuccess());
assertEquals("账号已被锁定", result.getMessage());
// 确保没有调用密码验证
verifyNoInteractions(passwordEncoder);
}
@Test
void testLoginWrongPassword() {
User user = new User("testUser", "hashedPassword", true);
when(userRepository.findByUsername("testUser")).thenReturn(Optional.of(user));
// 模拟密码验证失败
when(passwordEncoder.matches("wrongPassword", "hashedPassword")).thenReturn(false);
LoginResult result = userService.login("testUser", "wrongPassword");
assertFalse(result.isSuccess());
assertEquals("密码错误", result.getMessage());
}
}
4.2 订单处理流程测试
订单处理是另一个复杂的业务场景。通常涉及库存检查、价格计算、支付处理、库存扣减等多个步骤。
每个步骤都可能出现异常情况,通过Mockito可以逐一测试这些场景。
public class OrderService {
private InventoryService inventoryService;
private PaymentService paymentService;
private NotificationService notificationService;
public OrderService(InventoryService inventoryService,
PaymentService paymentService,
NotificationService notificationService) {
this.inventoryService = inventoryService;
this.paymentService = paymentService;
this.notificationService = notificationService;
}
public OrderResult processOrder(OrderRequest request) {
try {
// 检查库存
if (!inventoryService.checkStock(request.getProductId(), request.getQuantity())) {
return new OrderResult(false, "库存不足");
}
// 计算总价
double totalAmount = request.getPrice() * request.getQuantity();
// 处理支付
PaymentResult paymentResult = paymentService.processPayment(
new PaymentRequest(request.getUserId(), totalAmount)
);
if (!paymentResult.isSuccess()) {
return new OrderResult(false, "支付失败: " + paymentResult.getErrorMessage());
}
// 扣减库存
inventoryService.reduceStock(request.getProductId(), request.getQuantity());
// 发送通知
notificationService.sendOrderConfirmation(request.getUserId(), request.getProductId());
return new OrderResult(true, "订单处理成功");
} catch (Exception e) {
return new OrderResult(false, "系统异常: " + e.getMessage());
}
}
}
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
private InventoryService inventoryService;
@Mock
private PaymentService paymentService;
@Mock
private NotificationService notificationService;
@InjectMocks
private OrderService orderService;
@Test
void testProcessOrderSuccess() {
OrderRequest request = new OrderRequest("USER001", "PROD001", 2, 100.0);
// 模拟库存充足
when(inventoryService.checkStock("PROD001", 2)).thenReturn(true);
// 模拟支付成功
PaymentResult paymentResult = new PaymentResult(true, "TXN123", null);
when(paymentService.processPayment(any(PaymentRequest.class))).thenReturn(paymentResult);
// 执行订单处理
OrderResult result = orderService.processOrder(request);
// 验证结果
assertTrue(result.isSuccess());
assertEquals("订单处理成功", result.getMessage());
// 验证各个服务都被正确调用
verify(inventoryService).checkStock("PROD001", 2);
verify(inventoryService).reduceStock("PROD001", 2);
verify(paymentService).processPayment(argThat(req ->
req.getUserId().equals("USER001") && req.getAmount() == 200.0
));
verify(notificationService).sendOrderConfirmation("USER001", "PROD001");
}
@Test
void testProcessOrderInsufficientStock() {
OrderRequest request = new OrderRequest("USER001", "PROD001", 5, 100.0);
// 模拟库存不足
when(inventoryService.checkStock("PROD001", 5)).thenReturn(false);
OrderResult result = orderService.processOrder(request);
assertFalse(result.isSuccess());
assertEquals("库存不足", result.getMessage());
// 确保没有调用后续服务
verifyNoInteractions(paymentService);
verifyNoInteractions(notificationService);
verify(inventoryService, never()).reduceStock(anyString(), anyInt());
}
@Test
void testProcessOrderPaymentFailed() {
OrderRequest request = new OrderRequest("USER001", "PROD001", 1, 100.0);
when(inventoryService.checkStock("PROD001", 1)).thenReturn(true);
// 模拟支付失败
PaymentResult paymentResult = new PaymentResult(false, null, "余额不足");
when(paymentService.processPayment(any(PaymentRequest.class))).thenReturn(paymentResult);
OrderResult result = orderService.processOrder(request);
assertFalse(result.isSuccess());
assertTrue(result.getMessage().contains("支付失败"));
// 确保没有扣减库存和发送通知
verify(inventoryService, never()).reduceStock(anyString(), anyInt());
verifyNoInteractions(notificationService);
}
@Test
void testProcessOrderSystemException() {
OrderRequest request = new OrderRequest("USER001", "PROD001", 1, 100.0);
// 模拟系统异常
when(inventoryService.checkStock(anyString(), anyInt()))
.thenThrow(new RuntimeException("数据库连接失败"));
OrderResult result = orderService.processOrder(request);
assertFalse(result.isSuccess());
assertTrue(result.getMessage().contains("系统异常"));
assertTrue(result.getMessage().contains("数据库连接失败"));
}
}
5. 总结
不要过度Mock
虽然Mock很好用,但不要什么都Mock。对于简单的值对象、数据传输对象,直接创建真实对象往往更简单。
Mock应该用在那些难以构造、执行缓慢或者有副作用的依赖上。比如数据库访问、网络请求、文件操作等等。
保持测试的可读性
测试代码也是代码,同样需要保持可读性。给测试方法起个好名字,让人一看就知道在测试什么。
测试的结构要清晰:准备数据、执行操作、验证结果。每个部分都要简洁明了。
测试要有意义
不要为了提高覆盖率而写无意义的测试。每个测试都应该验证一个明确的业务逻辑或者边界条件。
好的测试不仅能发现bug,还能作为代码的活文档,帮助其他开发者理解业务逻辑。
及时维护测试代码
当业务逻辑发生变化时,相应的测试也需要更新。不要让测试代码成为技术债务。
定期review测试代码,删除那些过时的、重复的测试,保持测试套件的健康。
Mockito是一个强大的工具,合理使用它可以让你的测试覆盖率大幅提升。但记住,覆盖率只是手段,不是目的。真正的目标是写出高质量、可维护的代码。
- 点赞
- 收藏
- 关注作者
评论(0)