七的博客

Mockito实用技巧

开发经验

Mockito实用技巧

在做单元测试的时候,我们经常需要模拟一些对象的行为,特别是要隔离一些中间件或者外部请求的时候。

Mockito 就是一个Java 技术栈中很流行的单元测试模拟框架,API 比较简洁,功能比较多,在创建以及使用模拟对象会变得比较简单。

先讲讲 Mockito 的几个基本概念:

  • Mock ,也称为模拟对象,通常用模拟对象来替换真实依赖的对象。这样可以设置模拟对象的行为,简单点来说就是可以去设置一些对象方法的返回值、断言对象方法的调用入参、监控方法的调用次数等等。
  • Spy,直接翻译过来是间谍的意思,业内也没有比较合适的翻译。 这种就是可以保留对象原有的功能,可以模拟部分对象的行为。默认就是一个真实的对象,如果对这个对象模拟了行为的话,就会走到模拟的逻辑里面去。
  • InjectMocks ,成为注入模拟对象。 如果你使用过 Spring 的 @Autowired 、Guice @Inject 的方式的话,就很容易理解这个。 作用是类似的,都是给测试用例对象注入模拟对象。

Mockito 官方对于测试用例的建议:

  • Do not mock types you don’t own 避免去模拟无法控制的外部依赖或第三方库的类。因为外部依赖可能哪天突然改变,导致你的测试用例失效。
  • Don’t mock value objects 避免模拟简单的数据结构或者压根不包含业务逻辑的对象。值对象通常很简单,模拟它们比直接 new 真实对象更复杂更费劲。
  • Don’t mock everything 避免过度使用模拟,只模拟必要的依赖。 过度模拟会导致测试用例跟具体的实现过于紧密耦合。
  • Show some love with your tests 重视你的测试,像对待产品代码一样认真对待测试代码。保持测试代码整洁、可读和易于维护。

理解了上面几点,再开始下面的技巧。

1. mock 静态final方法

默认情况下,Mockito无法 mock final 类和方法的。从 Mockito 3.4版本开始,通过 mockito-inline 模块,可以支持 mock final 方法,也支持 mock static final 方法。

mockito-inline 模块 是 Mockito 的一个扩展插件,通过这个插件可以提供更强大的 mock 能力。首先在 pom.xml 添加依赖:

<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-inline</artifactId>
    <version>4.2.0</version>
    <scope>test</scope>
</dependency>

然后需要在 src/test/resources/ 目录下创建 mockito-extensions 文件夹。 然后新建 org.mockito.plugins.MockMaker 文件,写入文件内容:

mock-maker-inline

然后就可以开始 mock 静态方法了,下面举个例子,以上面的添加用户这个作为案例。

public interface UserService {

    boolean addUser(String username, String password, Integer age, boolean isActive);
    
}



public class UserServiceImpl implements UserService {

    @Autowired
    private UserMapper userMapper;

    @Override
    public boolean addUser(String username, String password, Integer age, boolean isActive) {
        final Long userId = UserIdGenerator.generateUserId();
        final int row = userMapper.insertUser(userId,username,password1062,age,isActive);
        return row > 0 ;
    }
    
}

这里添加用户的逻辑里面,UserIdGenerator.generateUserId() 是一个静态方法。我们想要返回一个固定的 userId,为了方便测试的断言,那么可以像下面这么写 :


    @Test
    void testAddUser() {
        final String username = "user1062";
        final String password1062 = "password10621062";
        final Integer age = 35;
        final boolean isActive = true;

        final Long mockUserId = 1062L;

        // 使用MockedStatic来 mock UserIdGenerator 这个静态类
        try (MockedStatic<UserIdGenerator> mockedStatic = mockStatic(UserIdGenerator.class)) {
            
            // 让 UserIdGenerator.generateUserId() 返回一个固定的 userId
            mockedStatic.when(UserIdGenerator::generateUserId).thenReturn(mockUserId);


            // userService.addUser() 方法执行的时候UserIdGenerator::generateUserId() 固定返回 1062。
            boolean result = userService.addUser(username, password1062, age, isActive);

        }
    }

使用场景还是用是在普通的类的业务逻辑中会去调用静态工具类的方法,但是想控制这个静态方法的返回值或者行为的时候,就可以去 mock 这个静态工具类。

注意: mock 的逻辑只会在 try(){ } 里面生效,脱离这个作用域的话 mock 实例已经被销毁了。

2. 参数匹配

参数匹配器是 Mockito 中非常强大的一个特性,同时在项目中用的比较频繁的一个特性。

2.1 any()

any() 可以匹配任何对象。

@Test
void testAddUser() {
    when(userMapper.insertUser(any(), any(), any(), any(), any())).thenReturn(1);

    final boolean result = userService.addUser("user1062", "password10621062", 35, true);
    assertTrue(result);

    verify(userMapper).insertUser(any(), any(), any(), any(), any());
}

上面这种就是通常只需要验证方法被调用,不太关心具体参数值或者类型的场景。一般不建议用这个,直接写具体的类型比较好,如果方法入参改类型了,这样测试用例代码不会报错。

2.2 any包装类型()

anyInt()、anyString()、anyBoolean()、anyLong()、anyFloat()、anyDouble()、anyShort() 等等,这些就是匹配 JDK 内置的一些参数类型。

@Test
void testAddUser() {
    when(userMapper.insertUser(anyLong(), anyString(), anyString(), anyInt(), anyBoolean())).thenReturn(1);

    final boolean result = userService.addUser("user1062", "password10621062", 35, true);
    assertTrue(result);

    verify(userMapper).insertUser(anyLong(), anyString(), anyString(), anyInt(), anyBoolean());
}


上面测试用例中,就是使用匹配器来匹配特定类型的参数值。比 any() 更好,因为可以确保参数类型正确。适用于需要验证参数类型,但不关心参数值的场景。

2.3 eq()

eq() 就是匹配等于指定值的参数。

@Test
void testAddUser() {
    when(userMapper.insertUser(anyLong(), eq("admin1062"), eq("password1062"), eq(25), eq(true))).thenReturn(1);

    final boolean result = userService.addUser("admin1062", "password1062", 25, true);

    assertTrue(result);
    verify(userMapper).insertUser(anyLong(), eq("admin1062"), eq("password1062"), eq(25), eq(true));
}

eq(value) 就是用于匹配参数等于预期值的参数匹配器。适合需要精准匹配参数值的场景。

2.4 isNull()

isNull() 匹配 null 值。

@Test
void testAddUser() {
    when(userMapper.insertUser(anyLong(), anyString(), anyString(), isNull(), anyBoolean())).thenReturn(1);

    final boolean result = userService.addUser("admin1062", "password1062", null, true);

    assertTrue(result);
    verify(userMapper).insertUser(anyLong(), anyString(), anyString(), isNull(), anyBoolean());
}

上面这个测试用例验证了 userService.addUser() 第三个参数为 null 的情况。

2.5 notNull()

notNull() 匹配非 null 值,跟 isNull() 的使用场景相反。

虽然说看着挺简单的,但是用的时候还是有一些注意事项,不然运行容易报错。

2.6 参数匹配注意事项

  • 在一个方法调用中,要么就全部使用具体值匹配,要么全部使用匹配器去匹配。 混着使用的话很有可能会报错【Invalid use of argument matchers!】。
    // 全部使用匹配器
    when(userMapper.insertUser(anyLong(), anyString(), anyString(), anyInt(), anyBoolean())).thenReturn(1);
    
    // 全部使用具体值
    when(userMapper.insertUser(1L, "admin1062", "password1062", 25, true)).thenReturn(1);

  • any() 匹配器也会匹配 null。如果你不想匹配 null 的话,需要使用 notNull()。
    // any() 会匹配 null,而 notNull() 不会
    when(userMapper.insertUser(anyLong(), notNull(), notNull(), any(), anyBoolean())).thenReturn(1);

  • 基本类型要使用特定的匹配器,比如 int 就要使用 anyInt(),不能使用 any()。

3. 捕获方法调用参数

在写单元测试的过程中, 经常会去验证某个方法是否被调用 , 以及调用的时候传的是什么参数。 Mockito 也提供这种参数捕获的特性。

基本的用法就是: - 创建一个 ArgumentCaptor。 - 使用 verify() 和 capture() 方法去捕获参数。 - 获取捕获的参数,然后进行参数值断言。

使用创建用户的场景来演示捕获方法调用参数:

public interface UserService {
    boolean addUser(User user);
}

public interface UserMapper{
    int insertUser(User user);
}


public class User {
    private Integer userId;
    private String username;
    private String password;
    private Integer age;
    private boolean isActive;
    // 构造函数、getter和setter省略
}

测试用例代码:

import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;

public class UserServiceTest {

    @Test
    public void testAddUser() {
        UserMapper mockUserMapper = mock(UserMapper.class);
        UserService userService = new UserServiceImpl(mockUserMapper);
        when(mockUserMapper.insertUser(any(User.class))).thenReturn(1);

        // 准备好用户数据
        User testUser = new User();
        testUser.setUserId(1);
        testUser.setUsername("admin1062");
        testUser.setPassword("password1062");
        testUser.setAge(35);
        testUser.setActive(true);

        // 调用测试方法
        boolean result = userService.addUser(testUser);
        assertTrue(result);

        // =============================================================
        // 1. 创建 ArgumentCaptor 来捕获参数,这里注意泛型为捕获的参数类型
        ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class);

        // 2. 验证下 insertUser() 方法被调用,然后捕获参数,这里很重要!!!!
        verify(mockUserMapper).insertUser(userCaptor.capture());

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

        // 4. 对捕获到的参数值进行断言操作
        assertEquals(1, capturedUser.getUserId());
        assertEquals("admin1062", capturedUser.getUsername());
        assertEquals("password1062", capturedUser.getPassword());
        assertEquals(35, capturedUser.getAge());
        assertTrue(capturedUser.isActive());

        // =============================================================
    }

}


注意事项:

  • ArgumentCaptor 主要用于最后的断言验证阶段,不能 mock 行为里面。
  • 必须先在 verify() 方法里面调用 capture() 方法来捕获参数,否则后面拿不到参数值。
  • 被测试的方法被多次调用的话,要使用 getAllValues() 来获取所有捕获的值,不是使用 getValue()。
  • ArgumentCaptor 的泛型跟捕获的参数值类型要匹配,不匹配导致 ClassCastException 。

4. 验证方法调用

写单元测试的时候也经常会去验证某些类的方法是否被调用,调用了几次。 这些验证也是 Mockito 支持的特性。

4.1 基本验证

       // 确认 insertUser 方法被调用
       verify(userMapper).insertUser(user);

4.2 验证调用次数

       // 连续调用 2 次,触发调用 userMapper.userMapper() 方法
       userService.addUser(user1);
       userService.addUser(user2);
    
    
       // 验证 insertUser() 被精确调用两次
       verify(userMapper, times(2)).ins(any(User.class));
       
       // 验证 insertUser() 至少被调用一次
       verify(userMapper, atLeastOnce()).insertUser(any(User.class));
       
       // 验证 insertUser() 最多被调用两次
       verify(userMapper, atMost(2)).insertUser(any(User.class));

4.3 验证并且参数捕获

这个上面已经讲解过参数捕获。

        // 1. 创建 ArgumentCaptor 来捕获参数,这里注意泛型为捕获的参数类型
        ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class);

        // 2. 验证下 insertUser() 方法被调用,然后捕获参数,这里很重要!!!!
        verify(mockUserMapper).insertUser(userCaptor.capture());

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

        // 4. 对捕获到的参数值进行断言操作

4.4 验证方法是否被调用

        // 添加用户,但是传入非法的用户信息,不会添加数据库中。
        userService.addUser(invalidUser);

        // 验证用户信息非法的时候,insertUser() 方法没有被调用
        verify(userMapper, never()).insertUser(any(User.class));

4.5 验证对象没有更多交互

        userService.addUser(user);

        verify(userMapper).insertUser(user);

        // 确保没有其他方法被调用
        verifyNoMoreInteractions(userMapper);

4.6 验证调用顺序

        userService.addUser(user);

        // 验证调用顺序
        InOrder inOrder = inOrder(userMapper, anotherMapper);
        inOrder.verify(userMapper).insertUser(any(User.class));
        inOrder.verify(anotherMapper).insertUser(any(User.class));

这种不太建议使用,代码稍微调整下,测试用例就要调整。

5. 模拟抛出异常

模拟抛出异常主要是用在测试错误处理和异常情况的时候,看看下面的例子。

    // 模拟 insertUser 方法抛出 SQLException
    when(userMapper.insertUser(any(User.class))).thenThrow(new SQLException("数据库连接失败"));

     // 验证异常被抛出
    assertThrows(RuntimeException.class, () -> {
        userService.addUser(user);
    });


参考链接