如何用 Mockito 玩转单元测试

介绍

Mockito 是一个广泛使用的 Java 测试框架,它提供了简洁而强大的功能,用于模拟(mock)和验证对象的行为,尤其是在单元测试中。

当我们需要测试某个类的功能时,但又不希望依赖其外部组件或复杂的对象时,可以使用 Mockito 来创建模拟对象,这些模拟对象可以控制方法返回值、抛出异常或执行特定的逻辑。Mockito 使得测试变得更加独立、可靠和可维护,特别是在测试依赖较多或外部系统交互的代码时。


从一个例子开始

以下示例模拟了 List,因为大多数人都熟悉 List 的使用方法,例如 add、get、clear 方法。

java 复制代码
import static org.mockito.Mockito.*;

// 开始 mock List对象
List mockedList = mock(List.class);

// 使用 mock 对象
mockedList.add("one");
mockedList.clear();

// 验证
verify(mockedList).add("one");
verify(mockedList).clear();

创建模拟对象后,mock 将记住所有交互(交互一般是函数调用)。然后,你可以有选择地验证感兴趣的任何交互。


添加存根 stubbing

++存根是 stubbing 的中文翻译,由于存根一词并不利于理解,因此下文索性统一使用 stubbing++

stubbing 是模拟(mock)对象行为的过程,指的是为模拟对象的方法调用提供预定义的返回值或行为。简单来说,stubbing 就是告诉 Mockito,当模拟对象的方法被调用时,应该返回什么结果或者执行什么操作。

java 复制代码
LinkedList mockedList = mock(LinkedList.class);

// stubbing
when(mockedList.get(0)).thenReturn("first");
when(mockedList.get(1)).thenThrow(new RuntimeException());

// 打印 first
System.out.println(mockedList.get(0));

// 抛出 RuntimeException
System.out.println(mockedList.get(1));

// 打印 null
System.out.println(mockedList.get(999));

// verify stubbing 一般是多余的,这里为了演示
verify(mockedList).get(0);

**默认情况下,Mockito 会为所有方法的返回值提供自动模拟。**例如,返回 null、原始值(如 0 表示 int 或 Integer,false 表示 boolean 或 Boolean)或空集合(如空 List、空 Map 等)。这种默认行为使得即使没有明确进行 Stubbing,模拟对象仍然能安全地返回合理的默认值。

Stubbing 是指通过明确的指令为模拟对象的方法调用提供预定义的返回值或行为。一旦对某个方法进行了 Stubbing,不管该方法被调用多少次,都会返回 stubbing 值。

在多次对同一个方法进行 Stubbing 后,最后一次设置的 Stubbing 会覆盖之前的所有设置


参数匹配器 anyXXX()

any() 系列:匹配任意值(包括null),比如:

  • anyString()
  • anyInt()
  • anyList()
  • any(Class<T>)

eq():匹配特定值(需精确相等)。

java 复制代码
// 对于 anyInt() 都将返回 element
when(mockedList.get(anyInt())).thenReturn("element");

// 打印 element
System.out.println(mockedList.get(999));

// 可以用 anyInt() 做验证
verify(mockedList).get(anyInt());

如果使用参数匹配器,则所有参数必须由参数匹配器提供。

java 复制代码
// 验证调用了此方法,并且第三个参数为 third argument
verify(mock).someMethod(anyInt(), anyString(), eq("third argument"));

// 抛异常,因为第三个参数是具体值
verify(mock).someMethod(anyInt(), anyString(), "third argument");

不能在 verify 或 stubbing 方法之外使用 anyXXX()eq() 等方法。


验证调用次数 verify

java 复制代码
// 开始 mock
mockedList.add("once");

mockedList.add("twice");
mockedList.add("twice");

mockedList.add("three times");
mockedList.add("three times");
mockedList.add("three times");

// 以下两种方式完全等价,验证只调用过一次
// 强调一下,第一种方式仅代表调用过一次,不是至少一次
verify(mockedList).add("once");
verify(mockedList, times(1)).add("once");

// 校验调用的确切次数
verify(mockedList, times(2)).add("twice");
verify(mockedList, times(3)).add("three times");

// 校验没有调用过
verify(mockedList, never()).add("never happened");
verify(mockedList, times(0)).add("never happened");

// 校验至少、至多调用次数
verify(mockedList, atMostOnce()).add("once");
verify(mockedList, atLeastOnce()).add("three times");
verify(mockedList, atLeast(2)).add("three times");
verify(mockedList, atMost(5)).add("three times");

注意:times(1) 是默认值。 因此,显式使用 times(1) 可以是省略


排除冗余调用 verifyNoMoreInteractions

verifyNoMoreInteractions 用于验证模拟对象上是否没有多余的交互发生。

该方法通常在测试的最后阶段使用,确保模拟对象的行为完全符合预期。例如,在一个测试中,可能先调用某些方法并验证这些调用,之后使用 verifyNoMoreInteractions 确保没有其他未预料到的调用发生。

java 复制代码
// 开始 mock
mockedList.add("one");
mockedList.add("two");

// 调用过 add("one")
verify(mockedList).add("one");

// 失败:因为还调用过 add("two")
verifyNoMoreInteractions(mockedList);

对连续调用进行 stubbing

有时我们需要为相同的 stubbing 使用不同的 返回值/异常,典型的用例可能是模拟迭代器。

java 复制代码
when(mock.someMethod("some arg"))
.thenThrow(new RuntimeException())
.thenReturn("foo");

// 第一次调用 throws runtime exception
mock.someMethod("some arg");

// 第二次调用 打印foo
System.out.println(mock.someMethod("some arg"));

// 之后的调用都将打印foo,因为以最后一次为准
System.out.println(mock.someMethod("some arg"));

连续 stubbing 的可以简化代码为:

java 复制代码
// 直接模拟调用一次、两次、三次的返回结果
when(mock.someMethod("some arg")).thenReturn("one", "two", "three");

**警告:**如果不是流式调用,而是使用相同的匹配器或参数进行多个 stubbing ,则会以最后一个 stubbing 为准(即覆盖前面所有的 stubbing)

java 复制代码
when(mock.someMethod("some arg")).thenReturn("one");
when(mock.someMethod("some arg")).thenReturn("two");

// 接下来,所有的 mock.someMethod("some arg") 都将返回 two

模拟回调 Answer

这是另一个有争议的功能,最初并未包含在 Mockito 中。因为使用 thenReturn() 或 thenThrow() 函数应该足以进行测试任何干净简单的代码。但是,如果确实需要使用通用 Answer 接口进行 stubbing,下面是一个示例:

java 复制代码
when(mock.someMethod(anyString())).thenAnswer(
    new Answer() {
        public Object answer(InvocationOnMock invocation) {
            Object[] args = invocation.getArguments();
            Object mock = invocation.getMock();
            return "called with arguments: " + Arrays.toString(args);
        }
});

// 下面将打印 called with arguments: [foo]
System.out.println(mock.someMethod("foo"));

doXXX() 系列方法

方法 作用 使用场景 示例
doReturn() 指定方法返回值 用于无法在 when() 中调用的方法(如 finalprivate doReturn("value").when(mock).method()
doThrow() 模拟抛出异常 用于模拟抛出异常的场景,常见于 void 方法或带异常的调用 doThrow(new Exception()).when(mock).method()
doAnswer() 自定义行为 用于根据输入自定义方法的返回值或行为 doAnswer(invocation -> {}).when(mock).method()
doNothing() 模拟不做任何事情 用于 void 方法,确保它被调用但不执行实际逻辑 doNothing().when(mock).method()
doCallRealMethod() 调用真实方法 用于测试中需要部分调用真实方法的场景 doCallRealMethod().when(mock).method()

监视真实物体 spy

使用 spy 时,调用的是实际方法,所以需要小心。Spy 适用于遗留代码或特殊场景,但不应过度使用。Mockito 1.8 之前,spy 并不支持真正的部分模拟,认为部分模拟是一个不好的做法。但后来发现,在某些情况下(比如与第三方接口交互或临时重构遗留代码),部分模拟是有用的。

java 复制代码
List list = new LinkedList();
List spy = spy(list);

// stubbing size方法
when(spy.size()).thenReturn(100);

// 调用真实方法,添加两个元素
spy.add("one");
spy.add("two");

// 调用真实方法,打印one
System.out.println(spy.get(0));

// 调用 stubbing 方法吗打印 100
System.out.println(spy.size());

重置模拟对象 reset

java 复制代码
List mock = mock(List.class);
when(mock.size()).thenReturn(10);
mock.add(1);

// reset 之后以上的所有 stubbing 将会失效
reset(mock);

严格 Stubbing

java 复制代码
@Test
public void givenUnusedStub() {
    // 假设这个 stubbing 没有使用
    when(mockList.add("one")).thenReturn(true);
    // 这个使用了
    when(mockList.get(anyInt())).thenReturn("hello");
    assertEquals("List should contain hello", "hello", mockList.get(1));
}

当我们运行这个单元测试时,Mockito 将检测未使用的 stubbing 并抛出 UnnecessaryStubbingException:表明第一个 when 是多余的,因为没有****在单元测试中调用此方法。

Mockito 也提供了一些方法绕过严格 stubbing 的检查:

  • Mockito.lenient()
  • @MockitoSettings(strictness = Strictness.LENIENT)

模拟静态方法 mockStatic

为了确保静态模拟保持临时状态,建议在 try-with-resources 构造中定义范围。 在下面的示例中,除非被模拟,否则 Foo 类型的 static 方法将返回 foo:

java 复制代码
assertEquals("foo", Foo.method());

try (MockedStatic mocked = mockStatic(Foo.class)) {
    mocked.when(Foo::method).thenReturn("bar");
    assertEquals("bar", Foo.method());
    mocked.verify(Foo::method);
}
// 还是成功,不会被 try 的内部影响
assertEquals("foo", Foo.method());

模拟对象构造 mockConstruction

为了确保构造函数 mock 保持临时状态,建议在 try-with-resources 构造中定义范围。 在下面的示例中,Foo 类型的构造将生成一个 mock:

java 复制代码
assertEquals("foo", new Foo().method());

try (MockedConstruction mocked = mockConstruction(Foo.class)) {
    Foo foo = new Foo();
    when(foo.method()).thenReturn("bar");
    assertEquals("bar", foo.method());
    verify(foo).method();
}

assertEquals("foo", new Foo().method());

捕获参数 ArgumentCaptor

ArgumentCaptor 用于捕获方法调用时传递的参数,以便后续进行验证或断言。

ArgumentCaptor 必须与 verify 一起使用,单独使用 captor.capture() 不会产生任何效果。捕获的参数类型必须与实际的参数类型匹配,否则会抛出异常。

java 复制代码
List mockList = Mockito.mock(List.class);
ArgumentCaptor<String> arg = ArgumentCaptor.forClass(String.class); 

mockList.add("one");
Mockito.verify(mockList).add(arg.capture());

ssertEquals("one", arg.getValue());

参考

Mockito - mockito-core 5.18.0 javadoc

https://www.baeldung.com/mockito-series