JUnit 4 与 Mockito 3.12 实战:5个典型场景下的Mock策略与代码覆盖率提升

单元测试是保障代码质量的重要防线,而Mock技术则是单元测试中的关键利器。本文将深入探讨如何结合JUnit 4和Mockito 3.12框架,在5种典型场景下实施高效的Mock策略,并通过JaCoCo工具提升代码覆盖率。无论你是刚接触单元测试的开发者,还是希望优化现有测试套件的工程师,这些实战技巧都能为你提供直接可用的解决方案。

1. 环境准备与基础配置

在开始Mock实战之前,我们需要搭建好基础环境。假设你使用的是Maven项目,首先在pom.xml中添加以下依赖:

<dependencies>
    <!-- JUnit 4 -->
    <dependency>
        <groupId>junit</groupId>
        <artifactId>junit</artifactId>
        <version>4.13.2</version>
        <scope>test</scope>
    </dependency>
    
    <!-- Mockito Core -->
    <dependency>
        <groupId>org.mockito</groupId>
        <artifactId>mockito-core</artifactId>
        <version>3.12.4</version>
        <scope>test</scope>
    </dependency>
    
    <!-- JaCoCo for code coverage -->
    <dependency>
        <groupId>org.jacoco</groupId>
        <artifactId>jacoco-maven-plugin</artifactId>
        <version>0.8.7</version>
    </dependency>
</dependencies>

配置JaCoCo插件以生成代码覆盖率报告:

<build>
    <plugins>
        <plugin>
            <groupId>org.jacoco</groupId>
            <artifactId>jacoco-maven-plugin</artifactId>
            <version>0.8.7</version>
            <executions>
                <execution>
                    <goals>
                        <goal>prepare-agent</goal>
                    </goals>
                </execution>
                <execution>
                    <id>report</id>
                    <phase>test</phase>
                    <goals>
                        <goal>report</goal>
                    </goals>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>

提示:Mockito 3.x版本需要Java 8或更高版本。如果项目仍在使用Java 7,需要降级到Mockito 2.x。

基础测试类结构示例:

import org.junit.*;
import static org.mockito.Mockito.*;

public class UserServiceTest {
    
    @Before
    public void setUp() {
        // 初始化操作
    }
    
    @After
    public void tearDown() {
        // 清理操作
    }
    
    // 测试方法将在这里添加
}

2. 场景一:模拟外部服务调用

在实际项目中,我们经常需要调用外部服务(如REST API、SOAP服务等)。这些外部依赖会显著降低测试执行速度,并可能引入不确定性。Mockito可以帮助我们模拟这些外部服务。

典型问题 :假设我们有一个 WeatherService 接口,用于获取某城市的天气信息:

public interface WeatherService {
    String getWeather(String city);
}

public class TravelPlanner {
    private WeatherService weatherService;
    
    public TravelPlanner(WeatherService weatherService) {
        this.weatherService = weatherService;
    }
    
    public String planTrip(String city) {
        String weather = weatherService.getWeather(city);
        if ("Sunny".equals(weather)) {
            return "Enjoy your trip to " + city;
        } else {
            return "Consider postponing your trip to " + city;
        }
    }
}

Mock解决方案

@Test
public void testSunnyWeatherTrip() {
    // 创建WeatherService的mock对象
    WeatherService mockWeatherService = mock(WeatherService.class);
    
    // 设置mock行为 - 当调用getWeather("Paris")时返回"Sunny"
    when(mockWeatherService.getWeather("Paris")).thenReturn("Sunny");
    
    TravelPlanner planner = new TravelPlanner(mockWeatherService);
    String result = planner.planTrip("Paris");
    
    assertEquals("Enjoy your trip to Paris", result);
    
    // 验证getWeather方法确实被调用了一次
    verify(mockWeatherService, times(1)).getWeather("Paris");
}

@Test
public void testRainyWeatherTrip() {
    WeatherService mockWeatherService = mock(WeatherService.class);
    when(mockWeatherService.getWeather("London")).thenReturn("Rainy");
    
    TravelPlanner planner = new TravelPlanner(mockWeatherService);
    String result = planner.planTrip("London");
    
    assertEquals("Consider postponing your trip to London", result);
}

代码覆盖率提升技巧

  1. 确保测试覆盖所有条件分支(Sunny和非Sunny情况)
  2. 验证外部服务调用次数,避免过度调用
  3. 考虑边界情况,如空返回值或异常情况

3. 场景二:模拟数据库操作

数据库操作是单元测试中另一个常见的外部依赖。虽然集成测试需要真实数据库,但单元测试应该避免直接操作数据库。

典型问题 :考虑一个用户管理系统:

public interface UserRepository {
    User findById(Long id);
    void save(User user);
    void delete(Long id);
}

public class UserService {
    private UserRepository userRepository;
    
    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }
    
    public User updateUserEmail(Long userId, String newEmail) {
        User user = userRepository.findById(userId);
        if (user == null) {
            throw new IllegalArgumentException("User not found");
        }
        user.setEmail(newEmail);
        userRepository.save(user);
        return user;
    }
}

Mock解决方案

@Test
public void testUpdateUserEmailSuccess() {
    UserRepository mockRepo = mock(UserRepository.class);
    
    // 准备测试数据
    User existingUser = new User(1L, "old@example.com");
    User updatedUser = new User(1L, "new@example.com");
    
    // 设置mock行为
    when(mockRepo.findById(1L)).thenReturn(existingUser);
    when(mockRepo.save(any(User.class))).thenReturn(updatedUser);
    
    UserService service = new UserService(mockRepo);
    User result = service.updateUserEmail(1L, "new@example.com");
    
    assertEquals("new@example.com", result.getEmail());
    
    // 验证交互
    verify(mockRepo).findById(1L);
    verify(mockRepo).save(existingUser);
}

@Test(expected = IllegalArgumentException.class)
public void testUpdateNonExistingUser() {
    UserRepository mockRepo = mock(UserRepository.class);
    when(mockRepo.findById(99L)).thenReturn(null);
    
    UserService service = new UserService(mockRepo);
    service.updateUserEmail(99L, "new@example.com");
}

高级技巧

  1. 使用 ArgumentCaptor 捕获方法参数进行更详细的验证:
@Test
public void testUpdateUserEmailWithArgumentCaptor() {
    UserRepository mockRepo = mock(UserRepository.class);
    User existingUser = new User(1L, "old@example.com");
    when(mockRepo.findById(1L)).thenReturn(existingUser);
    
    UserService service = new UserService(mockRepo);
    service.updateUserEmail(1L, "new@example.com");
    
    ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class);
    verify(mockRepo).save(userCaptor.capture());
    
    User savedUser = userCaptor.getValue();
    assertEquals("new@example.com", savedUser.getEmail());
}
  1. 使用 @Mock 注解简化mock创建:
@RunWith(MockitoJUnitRunner.class)
public class UserServiceTest {
    
    @Mock
    private UserRepository mockRepo;
    
    @InjectMocks
    private UserService userService;
    
    @Test
    public void testWithAnnotations() {
        User user = new User(1L, "test@example.com");
        when(mockRepo.findById(1L)).thenReturn(user);
        
        User result = userService.updateUserEmail(1L, "new@example.com");
        
        assertEquals("new@example.com", result.getEmail());
    }
}

4. 场景三:模拟异常情况

健壮的代码需要正确处理异常情况。Mockito可以帮助我们模拟各种异常场景。

典型问题 :考虑一个支付处理服务:

public interface PaymentGateway {
    PaymentResult process(PaymentRequest request) throws PaymentException;
}

public class PaymentService {
    private PaymentGateway paymentGateway;
    
    public PaymentService(PaymentGateway paymentGateway) {
        this.paymentGateway = paymentGateway;
    }
    
    public PaymentResult makePayment(PaymentRequest request) {
        try {
            return paymentGateway.process(request);
        } catch (PaymentException e) {
            log.error("Payment failed", e);
            return PaymentResult.failed("Payment processing failed");
        }
    }
}

Mock解决方案

@Test
public void testSuccessfulPayment() throws PaymentException {
    PaymentGateway mockGateway = mock(PaymentGateway.class);
    PaymentRequest request = new PaymentRequest(/* params */);
    PaymentResult expectedResult = PaymentResult.success("12345");
    
    when(mockGateway.process(request)).thenReturn(expectedResult);
    
    PaymentService service = new PaymentService(mockGateway);
    PaymentResult result = service.makePayment(request);
    
    assertTrue(result.isSuccess());
    assertEquals("12345", result.getTransactionId());
}

@Test
public void testFailedPayment() throws PaymentException {
    PaymentGateway mockGateway = mock(PaymentGateway.class);
    PaymentRequest request = new PaymentRequest(/* params */);
    
    when(mockGateway.process(request))
        .thenThrow(new PaymentException("Insufficient funds"));
    
    PaymentService service = new PaymentService(mockGateway);
    PaymentResult result = service.makePayment(request);
    
    assertFalse(result.isSuccess());
    assertEquals("Payment processing failed", result.getErrorMessage());
}

异常测试进阶

  1. 验证异常属性:
@Test
public void testExceptionProperties() {
    SomeService mockService = mock(SomeService.class);
    when(mockService.doSomething(anyString()))
        .thenThrow(new BusinessException("ERROR_CODE_123", "Error message"));
    
    try {
        mockService.doSomething("input");
        fail("Expected BusinessException");
    } catch (BusinessException e) {
        assertEquals("ERROR_CODE_123", e.getCode());
        assertEquals("Error message", e.getMessage());
    }
}
  1. 使用JUnit的 ExpectedException 规则(JUnit 4):
@Rule
public ExpectedException exceptionRule = ExpectedException.none();

@Test
public void testWithExceptionRule() throws PaymentException {
    PaymentGateway mockGateway = mock(PaymentGateway.class);
    PaymentRequest request = new PaymentRequest(/* params */);
    
    exceptionRule.expect(PaymentException.class);
    exceptionRule.expectMessage("Invalid card");
    
    when(mockGateway.process(request))
        .thenThrow(new PaymentException("Invalid card"));
    
    PaymentService service = new PaymentService(mockGateway);
    service.makePayment(request);
}

5. 场景四:验证方法调用行为

有时我们不仅需要验证方法的返回值,还需要验证方法是否以正确的参数、正确的次数被调用。

典型问题 :考虑一个缓存服务:

public interface Cache {
    void put(String key, Object value);
    Object get(String key);
    void evict(String key);
}

public class DataService {
    private Cache cache;
    private DataRepository repository;
    
    public DataService(Cache cache, DataRepository repository) {
        this.cache = cache;
        this.repository = repository;
    }
    
    public Data getData(String id) {
        Data data = (Data) cache.get(id);
        if (data == null) {
            data = repository.findById(id);
            cache.put(id, data);
        }
        return data;
    }
    
    public void updateData(String id, Data newData) {
        repository.save(id, newData);
        cache.evict(id);
    }
}

Mock验证解决方案

@Test
public void testGetDataWithCacheMiss() {
    Cache mockCache = mock(Cache.class);
    DataRepository mockRepo = mock(DataRepository.class);
    Data expectedData = new Data("123", "Test Data");
    
    when(mockCache.get("123")).thenReturn(null);
    when(mockRepo.findById("123")).thenReturn(expectedData);
    
    DataService service = new DataService(mockCache, mockRepo);
    Data result = service.getData("123");
    
    assertEquals(expectedData, result);
    
    // 验证缓存确实被查询了一次
    verify(mockCache).get("123");
    // 验证数据确实从仓库加载
    verify(mockRepo).findById("123");
    // 验证数据被放入了缓存
    verify(mockCache).put("123", expectedData);
    // 确保没有其他交互
    verifyNoMoreInteractions(mockCache, mockRepo);
}

@Test
public void testGetDataWithCacheHit() {
    Cache mockCache = mock(Cache.class);
    DataRepository mockRepo = mock(DataRepository.class);
    Data cachedData = new Data("123", "Cached Data");
    
    when(mockCache.get("123")).thenReturn(cachedData);
    
    DataService service = new DataService(mockCache, mockRepo);
    Data result = service.getData("123");
    
    assertEquals(cachedData, result);
    
    // 验证缓存被查询
    verify(mockCache).get("123");
    // 验证仓库没有被访问
    verify(mockRepo, never()).findById(anyString());
    // 验证缓存没有被更新
    verify(mockCache, never()).put(anyString(), any());
}

@Test
public void testUpdateData() {
    Cache mockCache = mock(Cache.class);
    DataRepository mockRepo = mock(DataRepository.class);
    Data newData = new Data("123", "New Data");
    
    DataService service = new DataService(mockCache, mockRepo);
    service.updateData("123", newData);
    
    // 验证保存操作
    verify(mockRepo).save("123", newData);
    // 验证缓存失效操作
    verify(mockCache).evict("123");
    // 确保没有其他交互
    verifyNoMoreInteractions(mockCache, mockRepo);
}

验证技巧进阶

  1. 验证调用顺序:
@Test
public void testMethodCallOrder() {
    List<String> mockList = mock(List.class);
    
    mockList.add("first");
    mockList.add("second");
    
    InOrder inOrder = inOrder(mockList);
    inOrder.verify(mockList).add("first");
    inOrder.verify(mockList).add("second");
}
  1. 验证超时:
@Test
public void testTimeout() {
    AsyncService mockService = mock(AsyncService.class);
    when(mockService.getResult()).thenReturn("done");
    
    TestThread thread = new TestThread(mockService);
    thread.start();
    
    // 验证在500ms内getResult被调用
    verify(mockService, timeout(500)).getResult();
}

6. 场景五:模拟静态方法和final类

虽然Mockito主要设计用于实例方法,但结合PowerMock可以模拟静态方法、构造函数和final类。

注意:现代测试实践建议尽量避免使用静态方法和final类,因为它们会使代码难以测试。只有在处理遗留代码时才考虑使用PowerMock。

典型问题 :考虑一个使用静态工具类的场景:

public class OrderProcessor {
    public static double calculateDiscount(Order order) {
        return DiscountCalculator.getDiscount(order);
    }
}

public final class DiscountCalculator {
    public static double getDiscount(Order order) {
        // 复杂的折扣计算逻辑
        return 0.0;
    }
}

PowerMock解决方案

首先添加PowerMock依赖:

<dependency>
    <groupId>org.powermock</groupId>
    <artifactId>powermock-module-junit4</artifactId>
    <version>2.0.9</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.powermock</groupId>
    <artifactId>powermock-api-mockito2</artifactId>
    <version>2.0.9</version>
    <scope>test</scope>
</dependency>

测试类配置:

@RunWith(PowerMockRunner.class)
@PrepareForTest({DiscountCalculator.class, OrderProcessor.class})
public class OrderProcessorTest {
    
    @Test
    public void testCalculateDiscount() {
        // 准备静态mock
        mockStatic(DiscountCalculator.class);
        
        Order testOrder = new Order(/* params */);
        when(DiscountCalculator.getDiscount(testOrder)).thenReturn(0.1);
        
        double discount = OrderProcessor.calculateDiscount(testOrder);
        
        assertEquals(0.1, discount, 0.001);
        
        // 验证静态方法调用
        verifyStatic(DiscountCalculator.class);
        DiscountCalculator.getDiscount(testOrder);
    }
}

PowerMock其他用途

  1. 模拟构造函数:
@Test
public void testMockConstructor() throws Exception {
    File mockFile = mock(File.class);
    when(mockFile.exists()).thenReturn(true);
    
    whenNew(File.class).withArguments("/path/to/file").thenReturn(mockFile);
    
    FileService service = new FileService();
    boolean exists = service.checkFileExists("/path/to/file");
    
    assertTrue(exists);
}
  1. 模拟final方法:
public final class FinalClass {
    public final String finalMethod() {
        return "original";
    }
}

@Test
public void testFinalMethod() {
    FinalClass mock = mock(FinalClass.class);
    when(mock.finalMethod()).thenReturn("mocked");
    
    assertEquals("mocked", mock.finalMethod());
}

7. 代码覆盖率分析与优化

使用JaCoCo生成代码覆盖率报告后,我们需要分析报告并优化测试用例。执行测试后,JaCoCo会在 target/site/jacoco 目录下生成HTML报告。

覆盖率指标解读

指标 说明 目标值
指令覆盖率 被执行的字节码指令比例 ≥80%
分支覆盖率 被执行的代码分支比例 ≥70%
行覆盖率 被执行的代码行比例 ≥80%
方法覆盖率 被执行的方法比例 ≥90%
类覆盖率 被执行的类比例 100%

覆盖率优化策略

  1. 识别未覆盖的代码 :查看JaCoCo报告中的红色部分,这些是完全没有被测试覆盖的代码。

  2. 添加边界条件测试 :对于条件判断,确保测试覆盖所有可能的分支。

// 原始代码
public String categorize(int value) {
    if (value < 0) {
        return "negative";
    } else if (value == 0) {
        return "zero";
    } else if (value > 0 && value <= 100) {
        return "small positive";
    } else {
        return "large positive";
    }
}

// 测试用例应覆盖所有分支
@Test
public void testCategorizeNegative() {
    assertEquals("negative", categorizer.categorize(-5));
}

@Test
public void testCategorizeZero() {
    assertEquals("zero", categorizer.categorize(0));
}

@Test
public void testCategorizeSmallPositive() {
    assertEquals("small positive", categorizer.categorize(50));
}

@Test
public void testCategorizeLargePositive() {
    assertEquals("large positive", categorizer.categorize(200));
}
  1. 测试异常处理路径 :确保try-catch块中的异常处理逻辑被覆盖。
@Test
public void testProcessThrowsException() {
    Processor mockProcessor = mock(Processor.class);
    when(mockProcessor.process(any())).thenThrow(new ProcessingException());
    
    Service service = new Service(mockProcessor);
    Result result = service.handleRequest("input");
    
    assertEquals(Result.ERROR, result.getStatus());
}
  1. 使用参数化测试覆盖多种输入 :JUnit 4的 Parameterized runner可以帮助减少重复代码。
@RunWith(Parameterized.class)
public class CalculatorParamTest {
    
    @Parameters
    public static Collection<Object[]> data() {
        return Arrays.asList(new Object[][] {
            { 0, 0, 0 },
            { 1, 1, 2 },
            { -1, 1, 0 },
            { Integer.MAX_VALUE, 1, Integer.MIN_VALUE }
        });
    }
    
    private int a;
    private int b;
    private int expected;
    
    public CalculatorParamTest(int a, int b, int expected) {
        this.a = a;
        this.b = b;
        this.expected = expected;
    }
    
    @Test
    public void testAdd() {
        Calculator calc = new Calculator();
        assertEquals(expected, calc.add(a, b));
    }
}
  1. 避免过度追求100%覆盖率 :有些代码(如简单的getter/setter、日志语句)不值得专门编写测试。应该关注业务逻辑和复杂算法的覆盖率。

8. Mock最佳实践与常见陷阱

最佳实践

  1. 明确测试目标 :每个测试应该只验证一个特定行为,避免测试多个不相关的功能。

  2. 保持测试独立 :测试之间不应该有依赖关系,可以以任意顺序运行。

  3. 使用有意义的命名 :测试方法名应该清楚地表达测试的意图,如 shouldReturnNullWhenUserNotFound

  4. 遵循Given-When-Then模式

    • Given:设置测试前提和mock行为
    • When:执行被测方法
    • Then:验证结果和交互
  5. 适度使用mock :只mock必要的依赖,过度使用mock会使测试变得脆弱。

常见陷阱

  1. 验证过多实现细节 :只验证方法的结果和必要的交互,避免过度验证内部实现。
// 不好 - 过度验证实现细节
verify(mockService, times(1)).step1();
verify(mockService, times(1)).step2();
verify(mockService, times(1)).step3();

// 更好 - 只验证最终结果
assertTrue(result.isSuccessful());
  1. 忽略异常测试 :确保测试错误处理和异常场景。

  2. 脆弱的测试 :避免依赖于不相关的细节(如集合顺序、精确的时间戳等)。

  3. mock真实对象 :不要mock你正在测试的类,只mock它的依赖。

  4. 忽略测试维护 :随着代码演进,及时更新测试以反映新的行为。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐