Java 单测伴侣 - mockito

2019-06-17 一
By xiayf

其实工作以来,我很少写测试/单测代码,一方面是大部分互联网公司团队对测试的要求不高,另一方面是想写好测试代码还挺难的,挺花时间,其中最麻烦的是待测代码可能会访问外部资源(比如数据库、HTTP API),如果不能方便地进模拟访问这些外部资源,那么测试起来会非常麻烦。

但,对于复杂逻辑,如果不经过严格测试,发布到生产环境,又有些不放心,没底气,或者在代码重构时,如果没有覆盖全面的测试,很难评估代码变动带来的影响。

直到遇到 mockito,我才觉得是时候认真写写测试代码了。


mockito 提供两种对象模拟方式:mockspy

简单来说,mock 模拟的对象是一个完全假的对象,只是具备指定类型的接口,以 java.util.List 为例:

import static org.mockito.Mockito.mock;

List mockedList = mock(List.class);

虽然 List 是一个 interface,也可以模拟出一个对象实例,这个 mockedList 对象具备 List 接口定义的所有方法,但所有方法都不具备实际的行为操作,对于有返回值的方法,则默认返回方法返回类型的默认值,没有返回值的方法,则纯粹是一个空方法。比如:

// mockedList 并不会真的把 1 存下来
mockedList.add(1);
// 所以,size() 返回默认值,输出 0
System.out.println(mockedList.size());
// 输出 null
System.out.println(mockedList.get(0));
// 输出 null
System.out.println(mockedList.get(1));

对于模拟出来的对象,可以任意指定其方法的返回值,比如:

import static org.mockito.Mockito.when;

// 调用 size() 方法时,返回 10
when(mockedList.size()).willReturn(10);
when(mockedList.get(0)).willReturn("Hello World!");
when(mockedList.get(1)).thenReturn("您好!");

// 输出 10
System.out.println(mockedList.size());
// 输出 Hello World!
System.out.println(mockedList.get(0));
// 输出 您好!
System.out.println(mockedList.get(1));

当然我们写测试代码时,并不会使用 System.out.println,然后看输出,而是使用断言

import static org.junit.Assert.assertEquals;

assertEquals(10, mockedList.size());
assertEquals("Hello World!", mockedList.get(0));
assertEquals("您好!", mockedList.get(1));

断言方法非常多,不仅仅只是 assertEquals。

对于同一个方法,可以模拟多次调用返回不同的值:

// 会覆盖之前 mock 的行为:when(mockedList.size()).willReturn(10);
// 或者这么写:when(mockedList.size()).willReturn(0, -1, 10);
when(mockedList.size()).thenReturn(0).thenReturn(-1).thenReturn(10);
assertEquals(0, mockedList.size());
assertEquals(-1, mockedList.size());
assertEquals(10, mockedList.size());
// 第 3 次之后的 mockedList.size() 调用都返回 10
assertEquals(10, mockedList.size());

Iterator iterator = mock(Iterator.class);
// 或者这么写:when(iterator.next()).thenReturn(0, 1, 10, 1000);
when(iterator.next()).thenReturn(0).thenReturn(1).thenReturn(10).thenReturn(1000);
assertEquals(0, iterator.next());
assertEquals(1, iterator.next());
assertEquals(10, iterator.next());
assertEquals(1000, iterator.next());
// 第 4 次之后的 iterator.next() 调用都返回 1000
assertEquals(1000, iterator.next());

还可以模拟异常抛出:

List mockedList = mock(List.class);

when(mockedList.get(-1000)).thenThrow(new RuntimeException("参数异常!"));
try {
    mockedList.get(-1000);
} catch (Exception e) {
    assertTrue(e instanceof RuntimeException);
    assertEquals("参数异常!", e.getMessage());
}

也可以基于复杂的逻辑来构造返回值:

import org.mockito.invocation.InvocationOnMock;
import org.mockito.stubbing.Answer;

List<Integer> mockedList = mock(List.class);
when(mockedList.get(anyInt())).thenAnswer(new EchoAnswer());

assertTrue(1 == mockedList.get(1));
assertTrue(10 == mockedList.get(10));

public class EchoAnswer implements Answer<Integer> {

    public Integer answer(InvocationOnMock var) {
        return var.getArgument(0);
    }
}

除了 when(...).thenReturn(...) 风格的测试模拟方式,还有 BDD(Behavior Driven Development 行为驱动开发)风格的:

import static org.mockito.BDDMockito.given;

// given
given(mockedList.get(0)).willReturn(100);
// when
int v = (int) mockedList.get(0);
// then
assertEquals(100, v);

如果方法没有返回值,或者其它奇葩的需求,则没法使用 when.thenReturn / willReturn 这样的模拟方法,可以使用 doReturn(...).when(...)...: 

import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.doReturn;

ArrayList mockedList = mock(ArrayList.class);
// clear 方法无返回值
doThrow(new RuntimeException("清除失败")).when(mockedList).clear();

try {
    mockedList.clear();
} catch (Exception e) {
    assertTrue(e instanceof RuntimeException);
    assertEquals("清除失败", e.getMessage());
}

// 没有意义,因为没法使用 断言 来验证,实际运行时会抛异常
doReturn(10).when(mockedList).clear();

从示例代码可以看出,doReturn(...).when(...).... 不会做类型校验,mockedList.clear() 返回值类型为 void,但我们模拟让其返回 10;所以,正常情况应该尽可能使用 when(...).thenReturn(...)given(...).willReturn(...)


前述代码示例中,模拟方法的参数都做了硬编码,实际情况通常都不是这么测试,而是模拟方法的参数符合一定的要求即可,比如:在某个范围之内、符合类型的任何值:

import static org.mockito.Mockito.anyInt;

/*
以任何 int 类型的参数调用 mockedList.get 方法,都返回 100

如果写成 when(mockedList.get(0)).thenReturn(100),则只有以 0 为参数调用 mockedList.get 方法,才会返回100,其他参数值,返回的都是默认值 0
*/
when(mockedList.get(anyInt())).thenReturn(100);

assertEquals(100, mockedList.get(0));
assertEquals(100, mockedList.get(1000));

可用的参数匹配器,见 org.mockito.ArgumentMatchers 类的静态方法列表,也可以自己实现 ArgumentMatcher 接口:

package org.mockito;

public interface ArgumentMatcher<T> {
    boolean matches(T var1);
}
import org.mockito.ArgumentMatcher;
import static org.mockito.Mockito.intThat;

when(mockedList.get(intThat(new LimitedInt()))).thenReturn(10);

assertEquals(null, mockedList.get(-1));
assertEquals(10, mockedList.get(1));
assertEquals(10, mockedList.get(99));
assertEquals(null, mockedList.get(100));

public class LimitedInt implements ArgumentMatcher<Integer> {

    public boolean matches(Integer var) {
        return var > 0 && var < 100;
    }
}

如果被模拟的方法包含多个参数,那么这些参数要么全部使用匹配器,要么全部不使用。


模拟某些类(A)的方法,通常会将 mock 出来的对象注入到依赖该类实例的其他类(B)中,来替代真实的依赖,这种方式的目的是为了测试类 B 的行为是否符合预期。

另一个测试需求是,测试某个类 A' 在某个上下文环境中的行为是否符合预期,比如: A' 的某个方法是否被调用过、调用过几次、调用参数是否符合预期、几个方法之间的调用次序是否符合预期、方法调用耗时是否符合预期等等。

import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verifyZeroInteractions;

List mocked = mock(List.class);

Caller caller = new Caller();
caller.setList(mocked);

// 调用 0 次
caller.run(0);
// 验证是否从来没调用过 mocked.size()
verify(mocked, never()).size();
// 验证 没有和 mocked 产生过任何交互
// 因为 Caller.run 中调用了 list.isEmpty(),实际产生了交互,所以这行测试会失败
verifyZeroInteractions(mocked);

// 调用 10 次
caller.run(10);
// 验证是否调用 mocked.size() 10 次
verify(mocked, times(10)).size();

// 再调用一次
caller.run(1);
// 所以是 11 次了
verify(mocked, times(11)).size();

@Data
public class Caller {
    List list;

    public void run(int count) {
        for (int idx=0; idx < count; idx++) {
            list.size();
        }
        //
        list.isEmpty();
    }
}
List mocked = mock(List.class);

mocked.add(1);
mocked.add(2);

verify(mocked).add(1);

// 是否有其他交互没有验证过?因为 mocked 还调用过 mocked.add(2),所以这句测试会失败
verifyNoMoreInteractions(mocked);
import org.mockito.InOrder;

// 也可以验证调用次序
List mocked1 = mock(List.class);
List mocked2 = mock(List.class);

mocked1.size();
mocked1.isEmpty();
mocked2.isEmpty();

// 会记录 mocked1、mocked2 中方法的调用/交互次序,要求:与 mocked1 的交互先于 mocked2
InOrder inOrder = inOrder(mocked1, mocked2);
// mocked1、mocked2 的交互顺序必须和 inOrder.verify 之间的顺序一致
inOrder.verify(mocked1).size();
inOrder.verify(mocked1).isEmpty();
inOrder.verify(mocked2).isEmpty();

也可以验证某个方法被调用时所使用的参数是否符合预期:

import org.mockito.ArgumentCaptor;

List mockedlist = mock(List.class);

Caller caller = new Caller();
caller.setList(mockedlist);
caller.run();

// 捕获 mockedList.add 的调用参数
ArgumentCaptor<Integer> argumentCaptor = ArgumentCaptor.forClass(Integer.class);
verify(mockedlist).add(argumentCaptor.capture());
assertTrue(100 == argumentCaptor.getValue());

@Data
public class Caller {
    List list;

    public void run() {
        list.add(100);
    }
}

前面的内容都是以 mock 为例,我们再来说说 spy,与 mock 的区别:

mock 出来的对象是一个完全假的对象,但 spy 通常是基于一个具体的类或类实例,对其篡改某些方法,对于被篡改方法之外的方法,其行为都和调用真实对象的方法一样,不过并没有调用真实对象的方法,也不会对真实对象产生影响:

// 基于一个实际的类实例
List<Integer> realList = new ArrayList<>(10);
List<Integer> spy = spy(realList);

spy.add(1);

// 被窃听的对象并没有发生变化
assertEquals(0, realList.size());
// 间谍对象确实将 1 存了下来
assertEquals(1, spy.size());
// 这句会抛出 java.lang.IndexOutOfBoundsException,因为 realList 还是为空
assertTrue(1 == realList.get(0));
assertTrue(1 == spy.get(0));

也可以基于一个具体的类来构造 spy,但这样无法使用带参数的构造方法,也无法指定类型参数:

List<Integer> = spy(ArrayList.class);
assertEquals(0, spy.size());
spy.add(100);
assertEquals(1, spy.size());
assertTrue(100 == spy.get(0));

// 篡改方法
when(spy.size()).thenReturn(-1);
assertEquals(-1, spy.size());

实际上,mock 也可以基于具体的类来构造,这时可以指定某些方法实际调用具体类的方法。


除了使用 mock、spy 方法来构造模拟对象,还可以通过注解来构造,但这样的话得指定 JUnit 的 Runner 为 org.mockito.junit.MockitoJUnitRunner

import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.MockitoJUnitRunner;

import java.util.ArrayList;
import java.util.List;

import static org.mockito.Mockito.when;
import static org.junit.Assert.assertTrue;

@RunWith(MockitoJUnitRunner.class)
public class testTester {

    @Mock
    private List<Integer> mocked;

    @Spy
    private ArrayList<Integer> spyed;

    @Test
    public void test() {
        when(mocked.isEmpty()).thenReturn(false);
        when(spyed.isEmpty()).thenReturn(false);

        assertTrue(!mocked.isEmpty());
        assertTrue(!spyed.isEmpty());

        mocked.add(0);
        spyed.add(0);

        assertTrue(0 == mocked.size());
        assertTrue(1 == spyed.size());
    }
}