Java单元测试中的Mock技术详解与实践

为什么需要单元测试?

在软件开发过程中,单元测试是确保代码质量的第一道防线。它是指对软件中的最小可测试单元(通常是方法或类)进行检查和验证的过程。单元测试的重要性体现在:

  1. 早期发现问题:在开发阶段就能发现并修复问题,降低后期修复成本
  2. 提高代码质量:迫使开发者编写更模块化、低耦合的代码
  3. 文档作用:测试用例本身就是如何使用代码的示例
  4. 重构保障:确保重构不会破坏现有功能
  5. 快速反馈:开发者可以立即知道修改是否破坏了现有功能

然而,在真实项目中,单元测试面临诸多挑战:

  • 被测代码依赖外部系统(数据库、网络服务等)
  • 测试环境难以搭建或维护成本高
  • 某些场景难以模拟(如异常情况)

这正是Mock技术要解决的问题。

Mock技术简介

Mock(模拟)是一种单元测试技术,它通过创建虚拟对象来替代真实依赖项,从而使测试能够专注于当前单元的功能验证。

Mock的核心价值

  1. 隔离测试环境:消除外部依赖对测试的影响
  2. 模拟复杂场景:轻松构造各种测试场景(包括异常情况)
  3. 验证交互行为:检查被测对象是否正确调用了依赖对象
  4. 提升测试速度:避免真实IO操作,测试执行更快

Mock vs Stub vs Spy

  • Mock:完全虚拟的对象,可以验证交互行为
  • Stub:提供预设响应的简单测试替身
  • Spy:部分真实对象,可以监视真实调用同时覆盖特定方法

常用Mock框架

  1. Mockito:Java领域最流行的Mock框架,API简洁强大
  2. EasyMock:较早的Mock框架,语法略显繁琐
  3. PowerMock:扩展Mockito/EasyMock,可以Mock静态方法等
  4. JMock:基于特定领域的Mock框架

本文重点介绍Mockito,因为它是目前Java生态中最主流的Mock框架。

Mockito核心功能详解

前期准备

  1. 引入依赖
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>5.8.28</version>
        </dependency>
  1. 创建实体类
@Data
@AllArgsConstructor
@NoArgsConstructor
public class User {

    private Long id;
    private String username;
    private String email;
}
  1. 创建interface方法
public interface AccountService {
    BigDecimal getBalance(String account);

    void deduct(String account, BigDecimal amount);

    void add(String account, BigDecimal amount);
}

public interface TransactionLog {
    void log(String sourceAccount, String targetAccount, BigDecimal amount);
}

public interface TransactionService {
    boolean checkBalance(String account, BigDecimal amount);

    void deductAmount(String account, BigDecimal amount);

    void addAmount(String account, BigDecimal amount);

    void logTransaction(String sourceAccount, String targetAccount, BigDecimal amount);

    void transfer(String sourceAccount, String targetAccount, BigDecimal amount) throws RuntimeException;
}

public interface HelloService {
    User profile(User user);
}
  1. 实现方法
public class UserService {
    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public User getUserById(Long id) {
        return userRepository.findById(id);
    }

    public User createUser(String username, String email) {
        User user = new User();
        user.setUsername(username);
        user.setEmail(email);
        return userRepository.save(user);
    }

    public void deleteUser(Long id) {
        userRepository.delete(id);
    }
    
}

public class TransactionServiceImpl implements TransactionService {

    private final AccountService accountService;
    private final TransactionLog transactionLog;

    public TransactionServiceImpl(AccountService accountService, TransactionLog transactionLog) {
        this.accountService = accountService;
        this.transactionLog = transactionLog;
    }

    @Override
    public boolean checkBalance(String account, BigDecimal amount) {
        return accountService.getBalance(account).compareTo(amount) >= 0;
    }

    @Override
    public void deductAmount(String account, BigDecimal amount) {
        accountService.deduct(account, amount);
    }

    @Override
    public void addAmount(String account, BigDecimal amount) {
        accountService.add(account, amount);
    }

    @Override
    public void logTransaction(String sourceAccount, String targetAccount, BigDecimal amount) {
        transactionLog.log(sourceAccount, targetAccount, amount);
    }

    @Override
    public void transfer(String sourceAccount, String targetAccount, BigDecimal amount) throws RuntimeException {
        if (!checkBalance(sourceAccount, amount)) {
            throw new RuntimeException("Insufficient funds in source account");
        }
        deductAmount(sourceAccount, amount);
        addAmount(targetAccount, amount);
        logTransaction(sourceAccount, targetAccount, amount);
    }
}

@Service
public class HelloServiceImpl implements HelloService {

    // @Autowired
    // private UserApiClient userApiClient;

    @Override
    public User profile(User user) {


        // return userApiClient.profile(user);
        return new User(5L, "aaa", "aaaa@qq.com");
    }

    public User testString(String name) {
        return new User(5L, "aaa", "aaaa@qq.com");
    }
}
  1. 单元测试
@ExtendWith(MockitoExtension.class)
public class MockTest2 {


    // 使用@Mock注解创建UserRepository的mock对象
    @Mock
    private UserRepository userRepository;

    // 使用@InjectMocks注解创建UserService实例,并自动注入mock的UserRepository
    @InjectMocks
    private UserService userService;

    @Mock
    private AccountService accountServiceMock;
    @Mock
    private TransactionLog transactionLogMock;

    /**
     * 这里需要是impl实现类,才能将上面两个mock注册进到TransactionServiceImpl
     */
    @InjectMocks
    private TransactionServiceImpl transactionService;

    @Mock
    private UserApiClient userApiClient;

    @InjectMocks
    private HelloServiceImpl helloService;
}

1. 基础Mock使用

1.1 创建Mock对象
// 手动创建mock对象
UserRepository mockRepo = mock(UserRepository.class);

// 使用注解创建(需要配合MockitoExtension使用)
@Mock
private UserRepository userRepository;
1.2 设置方法返回值
// 基本用法
when(mockObject.method(parameters)).thenReturn(value);

// 示例
when(userRepository.findById(1L)).thenReturn(new User(1L, "test", "test@example.com"));

// 匹配任意参数
when(userRepository.save(any(User.class))).thenReturn(savedUser);
1.3 验证方法调用
// 验证方法是否被调用
verify(mockObject).method(parameters);

// 验证调用次数
verify(mockObject, times(n)).method(parameters);
verify(mockObject, atLeastOnce()).method(parameters);
verify(mockObject, never()).method(parameters);

2. 高级Mock技术

2.1 参数捕获
@Test
void testArgumentCaptor() {
    UserRepository mockRepo = mock(UserRepository.class);
    UserService service = new UserService(mockRepo);
    
    // 创建参数捕获器
    ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class);
    
    service.createUser("captured", "captured@example.com");
    
    // 捕获并验证参数,这里可以验证是否被修改
    verify(mockRepo).save(userCaptor.capture());
    User capturedUser = userCaptor.getValue();
    assertEquals("captured", capturedUser.getUsername());
}
2.2 验证调用顺序
@Test
void testMethodInvocationOrder() {
    List<String> mockedList = mock(List.class);
    
    mockedList.add("first");
    mockedList.add("second");
    mockedList.clear();
    
    InOrder inOrder = inOrder(mockedList);
    inOrder.verify(mockedList).add("first");
    inOrder.verify(mockedList).add("second");
    inOrder.verify(mockedList).clear();
}
2.3 模拟异常
@Test
void testThrowException() {
    when(userRepository.findById(1L))
        .thenThrow(new RuntimeException("Database error"));
    
    assertThrows(RuntimeException.class, () -> {
        userService.getUserById(1L);
    });
}
2.4 连续调用不同返回值
@Test
void testConsecutiveCalls() {
    when(mockIterator.next())
        .thenReturn("first")
        .thenReturn("second")
        .thenReturn("more");
    
    assertEquals("first", mockIterator.next());
    assertEquals("second", mockIterator.next());
    assertEquals("more", mockIterator.next());
}
2.5 部分Mock(Spy)
@Test
void testSpy() {
    HelloServiceImpl spy = spy(helloService);
    
    // 覆盖特定方法
    doReturn(new User(1L, "Mocked Title", "sss"))
        .when(spy).testString("12");
    
    User mockedResult = spy.testString("12");
    assertEquals("Mocked Title", mockedResult.getUsername());
    
    // 其他方法保持真实行为
    User realResult = spy.testString("456");
    assertEquals("Real Title for 456", realResult.getUsername());
}

3. 依赖注入与Mock

现代Java应用通常使用依赖注入(DI)框架,Mockito可以很好地与之集成:

@ExtendWith(MockitoExtension.class)
public class UserServiceTest {
    
    @Mock
    private UserRepository userRepository;
    
    @InjectMocks
    private UserService userService;
    
    // 测试方法...
}

@InjectMocks会自动将@Mock标记的对象注入到被测对象中。

实战案例解析

1. 用户服务测试

@Test
void testGetUserById() {
    // 准备测试数据
    Long userId = 1L;
    User mockUser = new User(userId, "testUser", "test@example.com");
    
    // 设置mock行为
    when(userRepository.findById(userId)).thenReturn(mockUser);
    
    // 执行测试
    User result = userService.getUserById(userId);
    
    // 验证结果
    assertEquals(mockUser, result);
    
    // 验证交互行为
    verify(userRepository).findById(userId);
}

2. 转账业务测试

@Test
public void testBankTransferOrderAndTimes() throws RuntimeException {
    // 设置mock行为
    when(accountServiceMock.getBalance(anyString()))
        .thenReturn(BigDecimal.valueOf(200));
    
    // 执行测试
    transactionService.transfer("sourceAccount", "targetAccount", 
        BigDecimal.valueOf(100));
    
    // 验证调用顺序
    InOrder inOrder = inOrder(accountServiceMock, transactionLogMock);
    inOrder.verify(accountServiceMock).getBalance("sourceAccount");
    inOrder.verify(accountServiceMock).deduct("sourceAccount", BigDecimal.valueOf(100));
    inOrder.verify(accountServiceMock).add("targetAccount", BigDecimal.valueOf(100));
    inOrder.verify(transactionLogMock).log("sourceAccount", "targetAccount", 
        BigDecimal.valueOf(100));
    
    // 验证调用次数
    verify(accountServiceMock, times(1)).getBalance(anyString());
    verify(accountServiceMock, times(1)).deduct(anyString(), any());
    verify(accountServiceMock, times(1)).add(anyString(), any());
    verify(transactionLogMock, times(1)).log(any(), any(), any());
}

完整示例

@ExtendWith(MockitoExtension.class)
public class MockTest2 {


    // 使用@Mock注解创建UserRepository的mock对象
    @Mock
    private UserRepository userRepository;

    // 使用@InjectMocks注解创建UserService实例,并自动注入mock的UserRepository
    @InjectMocks
    private UserService userService;

    @Mock
    private AccountService accountServiceMock;
    @Mock
    private TransactionLog transactionLogMock;

    /**
     * 这里需要是impl实现类,才能将上面两个mock注册进到TransactionServiceImpl
     */
    @InjectMocks
    private TransactionServiceImpl transactionService;

    @Mock
    private UserApiClient userApiClient;

    @InjectMocks
    private HelloServiceImpl helloService;

    @Test
    public void userProfile() throws Exception {
        User user = new User();
        user.setUsername("123");
        user.setEmail("1111");
        when(userApiClient.profile(any())).thenReturn(user);
        User userReturn = helloService.profile(new User());
        System.out.println(JSONUtil.toJsonStr(userReturn));


    }

    @Test
    void testGetUserById() {
        // 准备测试数据
        Long userId = 1L;
        User mockUser = new User(userId, "testUser", "test@example.com");

        // 设置mock行为:当调用findById方法并传入userId时,返回mockUser
        // when(...).thenReturn(...) 是Mockito设置方法返回值的标准写法
        when(userRepository.findById(userId)).thenReturn(mockUser);

        // 执行测试:调用被测方法
        User result = userService.getUserById(userId);
        // User result2 = userService.getUserById(userId);

        // 验证结果:确认返回的对象与预期一致
        assertEquals(mockUser, result);

        // 验证交互行为:确认在本方法里面userRepository的findById方法被调用了一次
        // verify用于验证mock对象的方法调用情况
        // times(1)表示预期调用1次,这也是默认值,可以省略
        // times(n) - 验证方法被调用恰好n次
        // verify(mock, times(1)).method() (可以简写为 verify(mock).method())
        // verify(mock, times(3)).method()
        // atLeastOnce() - 验证方法至少被调用一次
        // verify(mock, atLeastOnce()).method()
        // atLeast(n) - 验证方法至少被调用n次
        // verify(mock, atLeast(3)).method()
        // atMostOnce() - 验证方法最多被调用一次
        // verify(mock, atMostOnce()).method()
        // atMost(n) - 验证方法最多被调用n次
        // verify(mock, atMost(5)).method()
        // never() - 验证方法从未被调用
        // verify(mock, never()).method()
        verify(userRepository, times(1)).findById(userId);
    }

    @Test
    void testCreateUser() {
        // 准备测试数据
        String username = "newUser";
        String email = "new@example.com";
        User savedUser = new User(1L, username, email);

        // 设置mock行为:当save方法被调用时,无论传入什么User对象都返回savedUser
        // any(User.class) 是参数匹配器,表示匹配任何User类型的参数
        when(userRepository.save(any(User.class))).thenReturn(savedUser);

        // 执行测试
        User result = userService.createUser(username, email);

        // 验证结果
        assertEquals(savedUser, result);
        assertEquals(username, result.getUsername());
        assertEquals(email, result.getEmail());

        // 验证交互行为:确认save方法被调用了一次,且参数是User类型
        // any(User.class) 在这里用于匹配方法参数类型
        verify(userRepository, times(1)).save(any(User.class));
    }

    @Test
    void testDeleteUser() {
        Long userId = 1L;

        // 执行测试:调用删除方法
        userService.deleteUser(userId);

        // 验证行为:确认delete方法被调用了一次,且参数是userId
        // 这里没有设置when...then...因为delete方法是void返回类型
        // 对于void方法,通常只需要验证它被调用了即可
        verify(userRepository, times(1)).delete(userId);
    }

    /**
     * 验证顺序
     */
    @Test
    void testMethodInvocationOrderAndTimes() {
        // 创建一个List接口的mock对象
        List<String> mockedList = mock(List.class);

        // 使用mock对象进行一系列操作
        mockedList.add("first");
        mockedList.add("second");
        mockedList.clear();

        // 创建InOrder对象来验证调用顺序
        // InOrder可以确保方法按照特定顺序被调用
        InOrder inOrder = inOrder(mockedList);

        // 验证add("first")必须在add("second")之前被调用
        // 且clear()必须在它们之后被调用
        inOrder.verify(mockedList).add("first");
        inOrder.verify(mockedList).add("second");
        inOrder.verify(mockedList).clear();

        // 验证add方法总共被调用了2次,参数可以是任意字符串
        // times(2)表示预期调用2次
        verify(mockedList, times(2)).add(anyString());

        // 验证add("first")至少被调用了一次
        // atLeastOnce()表示至少一次
        verify(mockedList, atLeastOnce()).add("first");

        // 验证add("third")从未被调用
        // never()表示预期从未调用
        verify(mockedList, never()).add("third");
    }

    /**
     * 验证业务调用顺序
     *
     * @throws RuntimeException
     */
    @Test
    public void testBankTransferOrderAndTimes() throws RuntimeException {
        when(accountServiceMock.getBalance(anyString())).thenReturn(BigDecimal.valueOf(200));
        transactionService.transfer("sourceAccount", "targetAccount", BigDecimal.valueOf(100));

        InOrder inOrder = inOrder(accountServiceMock, transactionLogMock);

        // 排序,先获取账号余额
        inOrder.verify(accountServiceMock).getBalance("sourceAccount");
        // 减去花费金额
        inOrder.verify(accountServiceMock).deduct("sourceAccount", BigDecimal.valueOf(100));
        // 目标账户添加金额
        inOrder.verify(accountServiceMock).add("targetAccount", BigDecimal.valueOf(100));
        // 打印日志
        inOrder.verify(transactionLogMock).log("sourceAccount", "targetAccount", BigDecimal.valueOf(100));

        verify(accountServiceMock, times(1)).getBalance(anyString());
        verify(accountServiceMock, times(1)).deduct(anyString(), any());
        verify(accountServiceMock, times(1)).add(anyString(), any());
        verify(transactionLogMock, times(1)).log(any(), any(), any());
    }

    /**
     * 模拟抛出异常
     */
    @Test
    void testThrowException() {
        // 手动创建UserRepository的mock对象
        UserRepository mockRepo = mock(UserRepository.class);
        UserService service = new UserService(mockRepo);

        Long userId = 1L;
        // 设置当调用findById(userId)时抛出RuntimeException
        // thenThrow()用于模拟方法抛出异常
        when(mockRepo.findById(userId)).thenThrow(new RuntimeException("Database error"));

        // 验证调用service.getUserById(userId)确实抛出了RuntimeException
        // assertThrows用于验证是否抛出了预期的异常
        assertThrows(RuntimeException.class, () -> {
            service.getUserById(userId);
        });

        // 验证findById方法确实被调用了一次
        verify(mockRepo).findById(userId);
    }

    /**
     * 参数捕获
     */
    @Test
    void testArgumentCaptor() {
        // 创建UserRepository的mock对象
        UserRepository mockRepo = mock(UserRepository.class);
        UserService service = new UserService(mockRepo);

        // 创建参数捕获器,用于捕获User类型的参数
        // ArgumentCaptor可以捕获方法调用时传入的参数值
        ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class);

        // 执行测试:调用创建用户方法
        service.createUser("captured", "captured@example.com");

        // 捕获save方法调用时传入的User参数
        // verify配合captor可以捕获实际传入的参数
        verify(mockRepo).save(userCaptor.capture());

        // 获取捕获的参数值
        User capturedUser = userCaptor.getValue();

        // 验证捕获的参数值是否符合预期
        assertEquals("captured", capturedUser.getUsername());
        assertEquals("captured@example.com", capturedUser.getEmail());
    }

    /**
     * 部分mock
     * spy 更倾向于部分地监视一个真实的对象,只对特定的方法调用进行拦截或修改
     * 这里没办法用mock,应为helloService
     */
    @Test
    void testSpy() {
        // 这里不能用mock,因为helloservice没有被mock代理
        // when(helloService.testString("123")).thenReturn(new User());

        HelloServiceImpl spy = spy(helloService);

        User user = new User();
        user.setId(2L);
        doReturn(new User(1L, "Mocked Title", "sss")).when(spy).testString("12");

        User profile = spy.testString("12");
        assertEquals("Mocked Title", profile.getUsername());

        // 测试未被mock的方法调用是否仍然工作
        user.setId(3L);
        User real = spy.testString("456");
        assertEquals("Real Title for 456", real.getUsername());
    }

    /**
     * 连续调用返回不同值
     */
    @Test
    void testConsecutiveCalls() {
        // 创建Iterator的mock对象
        Iterator<String> mockIterator = mock(Iterator.class);

        // 设置连续调用的不同返回值
        // thenReturn可以链式调用,设置连续调用的不同返回值
        when(mockIterator.next())
                .thenReturn("first")   // 第一次调用返回"first"
                .thenReturn("second")  // 第二次调用返回"second"
                .thenReturn("more");   // 第三次及以后调用返回"more"

        // 验证连续调用的返回值
        assertEquals("first", mockIterator.next());
        assertEquals("second", mockIterator.next());
        assertEquals("more", mockIterator.next());
        assertEquals("more", mockIterator.next()); // 继续返回"more"
    }
}

Mock最佳实践

  1. 合理使用Mock:不要过度Mock,核心业务逻辑尽量使用真实对象
  2. 保持测试简洁:每个测试方法只关注一个功能点
  3. 命名规范:测试方法名应清晰表达测试意图
  4. 避免Mock具体实现类:尽量对接口进行Mock
  5. 及时清理:对于需要清理的Mock,使用@AfterEach进行重置
  6. 结合Spring Boot Test:对于Spring应用,可以结合@MockBean使用

常见问题与解决方案

  1. NPE问题:确保所有依赖都被Mock或注入
  2. 验证失败:检查调用次数和顺序是否符合预期
  3. 静态方法Mock:需要使用PowerMock等扩展工具
  4. final类/方法:Mockito 2.x+支持,可能需要额外配置
  5. 复杂对象构造:使用Builder模式或辅助方法简化测试数据准备

总结

Mock技术是现代单元测试不可或缺的工具,它通过虚拟依赖对象使测试更加专注、快速和可靠。Mockito作为Java领域最流行的Mock框架,提供了简洁而强大的API,能够满足绝大多数测试场景的需求。

测试方法名应清晰表达测试意图
4. 避免Mock具体实现类:尽量对接口进行Mock
5. 及时清理:对于需要清理的Mock,使用@AfterEach进行重置
6. 结合Spring Boot Test:对于Spring应用,可以结合@MockBean使用

常见问题与解决方案

  1. NPE问题:确保所有依赖都被Mock或注入
  2. 验证失败:检查调用次数和顺序是否符合预期
  3. 静态方法Mock:需要使用PowerMock等扩展工具
  4. final类/方法:Mockito 2.x+支持,可能需要额外配置
  5. 复杂对象构造:使用Builder模式或辅助方法简化测试数据准备

总结

Mock技术是现代单元测试不可或缺的工具,它通过虚拟依赖对象使测试更加专注、快速和可靠。Mockito作为Java领域最流行的Mock框架,提供了简洁而强大的API,能够满足绝大多数测试场景的需求。

掌握Mock技术不仅能提高测试代码质量,还能促使开发者思考代码的设计质量,推动实现更加松耦合、高内聚的系统架构。希望本文的详细介绍和实战案例能帮助你在项目中更好地应用Mock技术,构建更加健壮的测试体系。

Logo

技术共进,成长同行——讯飞AI开发者社区

更多推荐