反模式的经典 - Mockito 设计解析

阅读数:7614 2015 年 10 月 19 日 11:02

测试驱动的开发 (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)
{1}
// 设置 mock 对象的行为 - 当调用其 get 方法获取第0个元素时,返回"one"
Mockito.when(mockedList.get(0)).thenReturn("one")
{1}
// 使用 mock 对象 - 会返回前面设置好的值"one",即便列表实际上是空的
String str = mockedList.get(0)
Assert.assertTrue("one".equals(str))
Assert.assertTrue(mockedList.size() == 0)
{1}
// 验证 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 的代码进行比较,我们会发现,我们的代码有几个问题:

  1. 不够直观
  2. 对重构不友好

第二点尤其重要。想象一下,如果我们要做重构,把 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) {
...
{1}
}
// 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,唯品会等公司从事后端开发工作多年。理念是用技术的手段解决“非技术”的问题。对于能够提升开发人员的效率和改进开发流程的技术抱有极大的兴趣。


感谢丁晓昀对本文的审校。

给 InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ @丁晓昀),微信(微信号: InfoQChina )关注我们,并与我们的编辑和其他读者朋友交流(欢迎加入 InfoQ 读者交流群反模式的经典 - Mockito设计解析)。

评论

发布