测试驱动的开发(Test Driven Design, TDD)要求我们先写单元测试,再写实现代码。在写单元测试的过程中,一个很普遍的问题是,要测试的类会有很多依赖,这些依赖的类/对象/资源又会有别的依赖,从而形成一个大的依赖树,要在单元测试的环境中完整地构建这样的依赖,是一件很困难的事情。
所幸,我们有一个应对这个问题的办法:Mock。简单地说就是对测试的类所依赖的其他类和对象,进行mock - 构建它们的一个假的对象,定义这些假对象上的行为,然后提供给被测试对象使用。被测试对象像使用真的对象一样使用它们。用这种方式,我们可以把测试的目标限定于被测试对象本身,就如同在被测试对象周围做了一个划断,形成了一个尽量小的被测试目标。关于Mock在单元测试中的作用,Martin Fowler有过专门的叙述 http://martinfowler.com/articles/mocksArentStubs.html。
Mockito的设计
Mock的框架有很多,最为知名的一个是Mockito,这是一个开源项目,使用广泛。官网:http://site.mockito.org/。示例:
import org.mockito.Mockito;
// 创建mock对象
List mockedList = Mockito.mock(List.class);
// 设置mock对象的行为 - 当调用其get方法获取第0个元素时,返回"one"
Mockito.when(mockedList.get(0)).thenReturn("one");
// 使用mock对象 - 会返回前面设置好的值"one",即便列表实际上是空的
String str = mockedList.get(0);
Assert.assertTrue("one".equals(str));
Assert.assertTrue(mockedList.size() == 0);
// 验证mock对象的get方法被调用过,而且调用时传的参数是0
Mockito.verify(mockedList).get(0);
代码中的注释描述了代码的逻辑:先创建mock对象,然后设置mock对象上的方法get,指定当get方法被调用,并且参数为0的时候,返回”one”;然后,调用被测试方法(被测试方法会调用mock对象的get方法);最后进行验证。逻辑很好理解,但是初次看到这个代码的人,会觉得有点儿奇怪,总感觉这个代码跟一般的代码不太一样。让我们仔细想想看,下面这个代码:
// 设置mock对象的行为 - 当调用其get方法获取第0个元素时,返回"one"
Mockito.when(mockedList.get(0)).thenReturn("one");
如果按照一般代码的思路去理解,是要做这么一件事:调用mockedList.get方法,传入0作为参数,然后得到其返回值(一个object),然后再把这个返回值传给when方法,然后针对when方法的返回值,调用thenReturn。好像有点不通?mockedList.get(0)的结果,语义上是mockedList的一个元素,这个元素传给when是表示什么意思?所以,我们不能按照寻常的思路去理解这段代码。实际上这段代码要做的是描述这么一件事情:当mockedList的get方法被调用,并且参数的值是0的时候,返回”one”。很不寻常,对吗?如果用平常的面向对象的思想来设计API来做同样的事情,估计结果是这样的:
Mockito.returnValueWhen("one", mockedList, "get", 0);
第一个参数描述要返回的结果,第二个参数指定mock对象,第三个参数指定mock方法,后面的参数指定mock方法的参数值。这样的代码,更符合我们看一般代码时候的思路。
但是,把上面的代码跟Mockito的代码进行比较,我们会发现,我们的代码有几个问题:
- 不够直观
- 对重构不友好
第二点尤其重要。想象一下,如果我们要做重构,把get方法改名叫fetch方法,那我们要把”get”字符串替换成”fetch”,而字符串替换没有编译器的支持,需要手工去做,或者查找替换,很容易出错。而Mockito使用的是方法调用,对方法的改名,可以用编译器支持的重构来进行,更加方便可靠。
实际上,Mockito的设计还有很多其他的好处,Mockito的作者写了一篇文章描述它背后的设计思想。
实现分析
明确了Mockito的方案更好之后,我们来看看Mockito的方案是如何实现的。首先我们要知道,Mock对象这件事情,本质上是一个Proxy模式的应用。Proxy模式说的是,在一个真实对象前面,提供一个proxy对象,所有对真实对象的调用,都先经过proxy对象,然后由proxy对象根据情况,决定相应的处理,它可以直接做一个自己的处理,也可以再调用真实对象对应的方法。Proxy对象对调用者来说,可以是透明的,也可以是不透明的。
Java本身提供了构建Proxy对象的API:Java Dynamic Proxy API。Mockito就是用Java提供的Dynamic Proxy API来实现的。
下面我们来看看,到底如何实现文章开头的示例中的API。如果我们仔细分析,就会发现,示例代码最难理解的部分是建立Mock对象(proxy对象),并配置好mock方法(指定其在什么情况下返回什么值)。只要设置好了这些信息,后续的验证是比较容易理解的,因为所有的方法调用都经过了proxy对象,proxy对象可以记录所有调用的信息,供验证的时候去检查。下面我们重点关注stub配置的部分,也就是我们前面提到过的这一句代码:
// 设置mock对象的行为 - 当调用其get方法获取第0个元素时,返回"one"
Mockito.when(mockedList.get(0)).thenReturn("one");
当when方法被调用的时候,它实际上是没有办法获取到mockedList上调用的方法的名字(get),也没有办法获取到调用时候的参数(0),它只能获得mockedList.get方法调用后的返回值,而根本无法知道这个返回值是通过什么过程得到的。这就是普通的java代码。为了验证我们的想法,我们实际上可以把它重构成下面的样子,不改变它的功能:
// 设置mock对象的行为 - 当调用其get方法获取第0个元素时,返回"one"
String str = mockedList.get(0);
Mockito.when(str).thenReturn("one");
这对Java开发者来说是常识,那么这个常识对Mockito是否还有效呢。我们把上面的代码放到Mockito测试中实际跑一遍,结果跟前面的写法是一样的,证明了常识依然有效。
有了上面的分析,我们基本上可以猜出来Mockito是使用什么方式来传递信息了 —— 不是用方法的返回值,而是用某种全局的变量。当get方法被调用的时候(调用的实际上是proxy对象的get方法),代码实际上保存了被调用的方法名(get),以及调用时候传递的参数(0),然后等到thenReturn方法被调用的时候,再把”one”保存起来,这样,就有了构建一个stub方法所需的所有信息,就可以构建一个stub方法了。
上面的设想是否正确呢?Mockito是开源项目,我们可以从代码当中验证我们的想法。下面是MockHandlerImpl.handle()方法的代码。代码来自Mockito在Github上的代码。
public Object handle(Invocation invocation) throws Throwable {
if (invocationContainerImpl.hasAnswersForStubbing()) {
...
}
...
InvocationMatcher invocationMatcher = matchersBinder.bindMatchers(
mockingProgress.getArgumentMatcherStorage(),
invocation
);
mockingProgress.validateState();
// if verificationMode is not null then someone is doing verify()
if (verificationMode != null) {
...
}
// prepare invocation for stubbing invocationContainerImpl.setInvocationForPotentialStubbing(invocationMatcher);
OngoingStubbingImpl<T> ongoingStubbing =
new OngoingStubbingImpl<T>(invocationContainerImpl);
mockingProgress.reportOngoingStubbing(ongoingStubbing);
...
}
注意第1行,第6-9行,可以看到方法调用的信息(invocation)对象被用来构造invocationMatcher对象,然后在第19-21行,invocationMatcher对象最终传递给了ongoingStubbing对象。完成了stub信息的保存。这里我们忽略了thenReturn部分的处理。有兴趣的同学可以自己看代码研究。
看到这里,我们可以得出结论,mockedList对象的get方法的实际处理函数是一个proxy对象的方法(最终调用MockHandlerImpl.handle方法),这个handle方法除了return返回值之外,还做了大量的处理,保存了stub方法的调用信息,以便之后可以构建stub。
总结
通过以上的分析我们可以看到,Mockito在设计时实际上有意地使用了方法的“副作用”,在返回值之外,还保存了方法调用的信息,进而在最后利用这些信息,构建出一个mock。而这些信息的保存,是对Mockito的用户完全透明的。这是一个经典的“反模式”的使用案例。“模式”告诉我们,在设计方法的时候,应该避免副作用,一个方法在被调用时候,除了return返回值之外,不应该产生其他的状态改变,尤其不应该有“意料之外”的改变。但Mockito完全违反了这个原则,Mockito的静态方法Mockito.anyString(), mockInstance.method(), Mockito.when(), thenReturn(),这些方法,在背后都有很大的“副作用” —— 保存了调用者的信息,然后利用这些信息去完成任务。这就是为什么Mockito的代码一开始会让人觉得奇怪的原因,因为我们平时不这样写代码。
然而,作为一个Mocking框架,这个“反模式”的应用实际上是一个好的设计。就像我们前面看到的,它带来了非常简单的API,以及编译安全,可重构等优良特性。违反直觉的方法调用,在明白其原理和一段时间的熟悉之后,也显得非常的自然了。设计的原则,终究是为设计目标服务的,原则在总结出来之后,不应该成为僵硬的教条,根据需求灵活地应用这些原则,才能达成好的设计。在这方面,Mockito堪称一个经典案例。
参考资料
- Mockito - A Simple, Intuitive Mocking Framework
https://dzone.com/refcardz/mockito - Mockito: drink it without moderation
http://damienlepage.com/mockito-drink-it-without-moderation/ - Mockito作者自己的话
http://monkeyisland.pl/2008/01/14/mockito/ - Explanation how proxy based Mock Frameworks work
http://blog.rseiler.at/2014/06/explanation-how-proxy-based-mock.html - How does mockito when() invocation work?
http://stackoverflow.com/questions/14440270/how-does-mockito-when-invocation-work/32321037#32321037 - Explore the Dynamic Proxy API
http://www.javaworld.com/article/2076233/java-se/explore-the-dynamic-proxy-api.html - Mockito for Spring https://www.geekbooks.me/book/view/mockito-for-spring
- Mockto 2.0 doc
http://site.mockito.org/mockito/docs/current/org/mockito/Mockito.html#2
作者简介
吴以均,浙江大学硕士,专注于Java服务端的开发,在IBM,唯品会等公司从事后端开发工作多年。理念是用技术的手段解决“非技术”的问题。对于能够提升开发人员的效率和改进开发流程的技术抱有极大的兴趣。