深入Java单元测试mock技术Mockito的原理

6,284 阅读9分钟

「这是我参与11月更文挑战的第9天,活动详情查看:2021最后一次更文挑战」。

在软件开发的单元测试环节,外部调用和第三方代码没必要测试,因为可能无法测试,也可能会影响测试效率。为了不测试恼人的外部调用和第三方代码,我们经常需要模拟这些方法的返回值,这就是测试常用的mock技术。JAVA测试框架Mockito是这样的一个测试框架,本文将深入浅出Mockito的工作原理。

Mockito

Mockito使用不难,操作方便。但是问起具体的工作机制来,却不甚清楚,需要好好整理一番。

基本使用

使用上按照invoke-when-then-invoke这样的步骤去使用就可以了,即插桩前调用-插桩-插桩后调用

如下案例所示,这里用的是3.9.0版本的mockito-core包:

<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-core</artifactId>
    <version>3.9.0</version>
    <scope>test</scope>
</dependency>
A a = Mockito.mock(A.class);
System.out.println("a.test() = " + a.test());
Mockito.when(a.test()).thenReturn(new MyMap());
System.out.println("a.test() = " + a.test());
  • 返回符合预期
a.test() = null
a.test() = {}
  • 插桩指的是when-then的过程,指定方法参数,模拟结果返回。

Mock到底做了什么?

使用看起来很简单,那么,Mock到底做了什么?怎么就修改了类的既定逻辑

  • 其实并没有修改原类的既定逻辑,而是调用mock工具函数会生成一个Mock对象
  • 这个对象是继承了原类的一个子类 如下示意图:

mock3.png

  • 子类是用字节码框架在运行时生成的
    • 使用Byte Buddy字节码框架处理技术生成的字节码
    • 字节码可以直接加载,选定一个合适的类加载器加载生成的字节码,并实例化为一个对象

为什么要生成字节码呢,因为如果是生成源码还要先编译再加载,反而多了一个步骤。

从图上我们看到,mock对象处理的方法都交给mockHandler实例的handle方法去处理,下面将详细分析这个对象和方法做了什么。

关系图

除了生成字节码,mock同时还做了一些很重要的事情,如下图生成了一些实例对象

mock4.png 这些实例构成了mock和stub过程中的处理器和容器

  • MockHandler
  • invocationContainer
  • stubbed

mockHandler等对象

上面的图示过程可以在代码里找到印证,最主要的执行逻辑是在createMock方法里面

    public <T> T createMock(MockCreationSettings<T> settings, MockHandler handler) {
        //生成并加载mock类
        Class<? extends T> mockedProxyType = createMockType(settings);
        //实例化
        T mockInstance = instantiator.newInstance(mockedProxyType);
        MockAccess mockAccess = (MockAccess) mockInstance;
        //给对象成员变量mockitoInterceptor赋值<-handler
        mockAccess.setMockitoInterceptor(new MockMethodInterceptor(handler, settings));
        return ensureMockIsAssignableToMockedType(settings, mockInstance);
    }
  • 在类MockUtil创建的handler: MockHandler mockHandler = createMockHandler(settings)

  • 在这个创建handler方法的里面生成invocationContainerthis.invocationContainer = new InvocationContainerImpl(mockSettings)

  • invocationContainer里面创建stubbed LinkedList<StubbedInvocationMatcher>用来存放打桩的函数和参数以及返回的结果

  • 给对象成员变量mockitoInterceptor赋值为handler

    这里,已经先通过Bytebuddy生成的字节码声明了mockitoInterceptor成员,这样再通过mockAccess.setMockitoInterceptor(new MockMethodInterceptor(handler, settings))就给它赋值了。

 Bytebuddy.builder()...//设置父类、名字
            .method(matcher)
            .intercept(dispatcher)
            ...//设置其他方法属性,比如synchronized设置
            //声明成员变量: mockitoInterceptor
            .defineField("mockitoInterceptor", MockMethodInterceptor.class, PRIVATE)

这样之后,mock对象里面的成员mockHander,通过getMockitoInterceptor函数能获取到,这个非常重要。

关系图增强

另外,MockingProgress是stub过程的工具类,在mock的过程中也生成了MockingProgress类的实例。可以看到:

public <T> T mock(Class<T> typeToMock, MockSettings settings) {
    ...
    T mock = createMock(creationSettings);
    mockingProgress().mockingStarted(mock, creationSettings);
    return mock;
}
  • mockingProgress()是生成一个ThreadLocal<MockingProgress>

于是,现有的关系图增强了:

mock6.png 下面会具体分析MockingProgress的作用。

执行流程

假设线程thread1开始执行mock,执行流程如下图所示:

mock11.png

  • A a=Mockito.mock(A.class)初始化...
  • Mockito.when(a.func(...)).then...,这里可以分解为如下三个流程

调用

这是when里面的那一次调用

  • a.func(“abc”) 执行了handle方法
  • handle方法里生成局部ongoingStubbing对象
  • handler传递自己的成员invocationContainer给ongoingStubbing
  • handler将生成的ongoingStubbing对象pushmockingProgress
  • 此时返回默认的empty结果null 如下代码所示:
    public Object handle(Invocation invocation) throws Throwable {
        OngoingStubbingImpl<T> ongoingStubbing = new OngoingStubbingImpl<T>(invocationContainer);
        mockingProgress().reportOngoingStubbing(ongoingStubbing);
        StubbedInvocationMatcher stubbing = invocationContainer.findAnswerFor(invocation);
        if (stubbing != null) {
            stubbing.captureArgumentsFrom(invocation);
            return stubbing.answer(invocation);
        } else {
            //使用的Mockito最初的withSettings()提供的默认返回值,null,
            //when里面的那一次调用就是返回的默认值
            return mockSettings.getDefaultAnswer().answer(invocation);
        }
    }

when

when逻辑的执行:从当前线程的mockingProgress拉取pull对象ongoingStubbing

  • mockingProgress
    • 如果stubbingInProgress!=null,则前面的stub还没完成,将抛出异常
    • 如果stubbingInProgress=null,则设置stubbingInProgress,返回ongoingStubbing给when,设置自己的ongoingStubbing为null,when函数返回ongoingStubbing
 public <T> OngoingStubbing<T> when(T methodCall) {
        mockingProgress().stubbingStarted();//如果stubbingInProgress!=null,则前面的stub还没完成
        return (OngoingStubbing<T>) mockingProgress.pullOngoingStubbing();
    }

then

  • when函数返回的ongoingStubbing调用了then方法。
  • 然后then从ongoingStubbing的成员invocationContainer拿到stubbed
  • 加锁并修改这个链表,也就是加入一个打桩的函数和参数以及返回的结果。 通过invocationContainer可以知道,invoke送来的方法参数和取走后设置的返回值类型要对应,不然then执行会报错

再调用

最后使用stub的结果

  • a.func("abc"):从invocationContainerstubbed匹配调用参数,能匹配则返回调用的结果

执行流程的总结

通过上面的分析可以看出,使用ThreadLocal的mockingProgress使得mock的过程在一个线程里面变得可行。

MockingProgress引导状态演化

对于 MockingProgress来说

  • invoke 送来ongoingStubbing
    • 可以送来多次,最后一次的为准
  • when 取走ongoingStubbing
    • 不能取走多次 在执行各个操作的时候会判断ongoingStubbing是否为null

mock8.png

  • when 需要ongoingStubbing有值,之后thenongoingStubbing调用的

  • UnfinishedStubbingException

    • stub过程 when-then是原子的,而且stub没有完成时调用a的方法或继续发起新的stub都会抛出Method threw 'org.mockito.exceptions.misusing.UnfinishedStubbingException' exception.

MockingProgress再探究

小实验:多线程mock共享

一个线程mock的数据,另外一个线程是可以使用的。

@Test
void multiMockA() throws Exception{
    A a = Mockito.mock(A.class);
    Mockito.when(a.func("one")).thenReturn(1);
    Thread thread = new Thread(() -> {
            Mockito.when(a.func("two")).thenReturn(2);
        System.out.println("a.func("one") = " + a.func("one"));
    });
    thread.start();
    thread.join();
    System.out.println("a.func("two") = " + a.func("two"));
}

返回

a.func("one") = 1
a.func("two") = 2

这个小实验的目的是希望碰到了可以理解,但也不提倡这种用法。

小实验:多线程mock异常

@Test
void multiMock() throws Exception {
    Aclass a = Mockito.mock(Aclass.class);
    new Thread(() -> {
        Mockito.when(a.func2("two")).thenReturn("2");
    }).start();
    Mockito.when(a.func("one")).thenReturn(1);

    Thread.sleep(100);
}

执行发现抛出异常String cannot be returned by func()

这是什么原因呢?

debug发现两个线程的函数执行顺序如下:

mock1.png

  • thread1和thread2共享a的invocationContainer对象
  • invocationContainer对象的invocationForStubbing成员
    • 方法调用的时候通过invocationContainer.setMethodForStubbing(invocationMatcher) 设置的invocationForStubbing
    • invocationForStubbing成员先被设置为a.func("one"),后被重置为a.func2("two") 因为Invocation被替换了,所以当thread1再调用then的时候会抛出类型不匹配的异常。

小实验:一个线程里mock多个类

如下代码:

@Test
void mocMultiClass() throws Exception{
    A a = Mockito.mock(A.class);
    B b= Mockito.mock(B.class);
    Mockito.when(a.func("one")).thenReturn(1);
    Mockito.when(b.func(1)).thenReturn("one");
    System.out.println("a.func("one") = " + a.func("one"));
    System.out.println("b.func(1) = " + b.func(1));
}
a.func("one") = 1
b.func(1) = one

这个正常操作都没有问题。 可以看出,MockingProgress帮着A stub 完,又帮着B mock,如下示意图:

mock9.png

不常用但可以了解的功能

最常用的就是上面详细描述的这种模式了,但也有一些不常用但可以了解的功能,主要是下面两种。

SPY模式

Spy是另外一种插桩的方式,和mock相比,字节码生成的时候interceptor处理的逻辑不同。

  • spy生成的对象,stub之前的方法调用(invoke),是调用原来的类(super)的方法处理逻辑。
  • mock生成的对象,stub之前的方法调用是返回空值,比如null,集合类则是空集合。
    • Mockito支持了几种集合类的空值返回
    • 对于HashMap这样的返回类型,Mockito是返回一个空的集合
    • 但是如果返回的是继承HashMap的类,比如NutMap,Mockito还是返回null

VERIFY模式

  • 校验MOCK的方法是否调用过
  • 校验MOCK的方法调用次数等信息

经验总结

总结了一些生产过程里遇到的几个案例分享,包括在SpringBoot里面使用Mock、mock带泛型的对象时遇到的问题以及插桩的参数的一些问题。

SpringBoot和Mock

在Spingboot的测试里面,测试的场景更为复杂,我们可以使用:

  • @MockBean 用于注入mock的单例对象,@MockBean 是spring-boot-test包里用于帮助spring bean mock的注解。 可以这么使用
@MockBean
KafkaTemplate kafkaTemplate;

在做单元测试时,如果想要 mock bean 的逻辑,只需要声明一个变量并在上面加上 @MockBean 的注释即可, 之后就和上面详述的意义。使用 when-then 来设定 mock 的行为。

在运行时,SpringBoot 会扫描到你注解的 @MockBean ,并自动装配到被测试的 Component 里。

  • 同理,@SpyBean 用于注入spy的单例对象,是spring-boot-test里用于帮助spring bean spy的注解。 但是注意的一点:

当有多个测试类时,@MockBean注解会因不同的上下文从而导致springboot多次启动。

mock和泛型的关系

泛型只是一种Java编译时用到的语法糖🍬,比如下面这个例子:

@Test
void mockGeneric(){
    HashMap<String,Integer> list = Mockito.mock(HashMap.class);
    Mockito.when(list.get("one")).thenReturn(1);
    System.out.println("list.get("one") = " + list.get("one"));
}
list.get("one") = 1

也就是说,mock和泛型是没有关系的

但是当使用@MockBean的时候,我遇到了一个泛型相关的问题。苦恼了一阵子。

最开始的时候没注意,使用了方式1来做mock:

@MockBean
KafkaTemplate kafkaTemplate;

结果发现mock根本就没有生效。

后面改为 方式2来能正确的mock:

@MockBean
KafkaTemplate<String, Object> kafkaTemplate;

因为在Springboot的 Component里自动注入的是KafkaTemplate<String, Object> kafkaTemplate类型的变量,导致mock的KafkaTemplate没有被自动装配到被测试的 Component 里面, 这是因为Spring自动注入Bean也已经支持到泛型了。

Spring4的新特性就是把泛型的具体类型也作为类的一种分类方法(Qualifier)。这样我们的KafkaTemplate<String, Object> 和KafkaTemplate<String, Integer> 虽然是同一个类和KafkaTemplate,但是因为泛型的具体类型不同,也会被区分开。

也就是说,如果Component里使用的地方用的是泛型,则@MockBean声明的时候必须也要加上对应的泛型。

插桩的参数模式问题

  • 参数包括两种模式:固定的参数匹配器
  • 匹配器:可以使用Mockito.any的一系列函数来匹配参数,包括ArgumentMatchers类下面提供的所有静态方法,eq,startsWith等等。
  • 方法的多个参数使用模式要一致,否则会抛出InvalidUseOfMatchersException(参数匹配)异常,也就是如果有一个参数是采用匹配模式,另外一个不能使用固定模式(非ArgumentMatchers提供的)。