对中国开发者最具吸引力的科技企业有哪些?快来为你 pick 的企业投票! 了解详情
写点什么

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

2015 年 10 月 19 日

测试驱动的开发 (Test Driven Design, TDD) 要求我们先写单元测试,再写实现代码。在写单元测试的过程中,一个很普遍的问题是,要测试的类会有很多依赖,这些依赖的类 / 对象 / 资源又会有别的依赖,从而形成一个大的依赖树,要在单元测试的环境中完整地构建这样的依赖,是一件很困难的事情。

所幸,我们有一个应对这个问题的办法:Mock。简单地说就是对测试的类所依赖的其他类和对象,进行 mock - 构建它们的一个假的对象,定义这些假对象上的行为,然后提供给被测试对象使用。被测试对象像使用真的对象一样使用它们。用这种方式,我们可以把测试的目标限定于被测试对象本身,就如同在被测试对象周围做了一个划断,形成了一个尽量小的被测试目标。关于 Mock 在单元测试中的作用,Martin Fowler 有过专门的叙述 http://martinfowler.com/articles/mocksArentStubs.html

Mockito 的设计

Mock 的框架有很多,最为知名的一个是 Mockito,这是一个开源项目,使用广泛。官网: http://site.mockito.org/ 。示例:

复制代码
import org<span>.mockito</span><span>.Mockito</span>
// 创建 mock 对象
List mockedList = Mockito<span>.mock</span>(List<span>.class</span>)
// 设置 mock 对象的行为 - 当调用其 get 方法获取第 <span>0</span> 个元素时,返回 <span>"one"</span>
Mockito<span>.when</span>(mockedList<span>.get</span>(<span>0</span>))<span>.thenReturn</span>(<span>"one"</span>)
// 使用 mock 对象 - 会返回前面设置好的值 <span>"one"</span>,即便列表实际上是空的
String str = mockedList<span>.get</span>(<span>0</span>)
Assert<span>.assertTrue</span>(<span>"one"</span><span>.equals</span>(str))
Assert<span>.assertTrue</span>(mockedList<span>.size</span>() == <span>0</span>)
// 验证 mock 对象的 get 方法被调用过,而且调用时传的参数是 <span>0</span>
Mockito<span>.verify</span>(mockedList)<span>.get</span>(<span>0</span>)

代码中的注释描述了代码的逻辑:先创建 mock 对象,然后设置 mock 对象上的方法 get,指定当 get 方法被调用,并且参数为 0 的时候,返回”one”;然后,调用被测试方法(被测试方法会调用 mock 对象的 get 方法);最后进行验证。逻辑很好理解,但是初次看到这个代码的人,会觉得有点儿奇怪,总感觉这个代码跟一般的代码不太一样。让我们仔细想想看,下面这个代码:

复制代码
// 设置 mock 对象的行为 - 当调用其 get 方法获取第 <span>0</span> 个元素时,返回 <span>"one"</span>
Mockito<span>.when</span>(mockedList<span>.get</span>(<span>0</span>))<span>.thenReturn</span>(<span>"one"</span>)

如果按照一般代码的思路去理解,是要做这么一件事:调用 mockedList.get 方法,传入 0 作为参数,然后得到其返回值(一个 object),然后再把这个返回值传给 when 方法,然后针对 when 方法的返回值,调用 thenReturn。好像有点不通?mockedList.get(0) 的结果,语义上是 mockedList 的一个元素,这个元素传给 when 是表示什么意思?所以,我们不能按照寻常的思路去理解这段代码。实际上这段代码要做的是描述这么一件事情:当 mockedList 的 get 方法被调用,并且参数的值是 0 的时候,返回”one”。很不寻常,对吗?如果用平常的面向对象的思想来设计 API 来做同样的事情,估计结果是这样的:

Mockito<span>.returnValueWhen</span>(<span>"one"</span>, mockedList, <span>"get"</span>, <span>0</span>)第一个参数描述要返回的结果,第二个参数指定 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 方法获取第 <span>0</span> 个元素时,返回 <span>"one"</span>
Mockito<span>.when</span>(mockedList<span>.get</span>(<span>0</span>))<span>.thenReturn</span>(<span>"one"</span>)

当 when 方法被调用的时候,它实际上是没有办法获取到 mockedList 上调用的方法的名字 (get),也没有办法获取到调用时候的参数 (0),它只能获得 mockedList.get 方法调用后的返回值,而根本无法知道这个返回值是通过什么过程得到的。这就是普通的 java 代码。为了验证我们的想法,我们实际上可以把它重构成下面的样子,不改变它的功能:

复制代码
// 设置 mock 对象的行为 - 当调用其 get 方法获取第 <span>0</span> 个元素时,返回 <span>"one"</span>
String str = mockedList<span>.get</span>(<span>0</span>)
Mockito<span>.when</span>(str)<span>.thenReturn</span>(<span>"one"</span>)

这对 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 {
<span>if</span> (invocationContainerImpl.hasAnswersForStubbing()) {
<span>...</span>
}
<span>...</span>
InvocationMatcher invocationMatcher = matchersBinder.bindMatchers(
mockingProgress.getArgumentMatcherStorage(),
invocation
);
mockingProgress.validateState();
// <span>if</span> verificationMode is not null then someone is doing verify()
<span>if</span> (verificationMode != null) {
<span>...</span>
}
// prepare invocation <span>for</span> stubbing invocationContainerImpl.setInvocationForPotentialStubbing(invocationMatcher);
OngoingStubbingImpl<<span>T</span>> ongoingStubbing =
new OngoingStubbingImpl<<span>T</span>>(invocationContainerImpl);
mockingProgress.reportOngoingStubbing(ongoingStubbing);
<span>...</span>
}

注意第 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 堪称一个经典案例。

参考资料

作者简介

吴以均,浙江大学硕士,专注于 Java 服务端的开发,在 IBM,唯品会等公司从事后端开发工作多年。理念是用技术的手段解决“非技术”的问题。对于能够提升开发人员的效率和改进开发流程的技术抱有极大的兴趣。


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

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

2015 年 10 月 19 日 11:028668

评论

发布
暂无评论
发现更多内容

如何使用 Apache CXF 快速实现一个 WebService

Rayjun

Java WebService CXF

2万字长文带你细细盘点五种负载均衡策略。

why技术

Java 负载均衡 源码分析 dubbo java面试

体验一次简洁的代码

你当像鸟飞往你的山

我的编程之路 -6(新时代)

顿晓

android 编程之路 时代

那些会阻碍程序员成长的细节[2]

MavenTalker

程序员 程序人生

MAC OS 下 HomeBrew 使用

耳东

macos brew homebrew

clang-format 使用与集成介绍

Geek_101627

深入计算机底层,从几本靠谱的书开始

HackMSF

计算机工作原理

ARTS week 3

刘昱

Apache DolphinScheduler新特性与Roadmap路线

海豚调度

数据中台 大数据任务调度 工作流调度 海豚调度 数据湖调度

[ARTS打卡] week 01

Mau

ARTS 打卡计划

后疫情时代,区块链的发展迎来曙光!

CECBC区块链专委会

CECBC 区块链技术

ARTS打卡第一周

GKNick

ARTS-01

NIMO

ARTS 打卡计划 ARTS活动

5G时代下应用的安全防御研究

Nick

5G 5G网络安全 5G安全

Element-UI实战系列:Table+Pagination组件实现已选和全选功能

brave heart

Vue 前端 Element

你会写测试用例吗

鱼贩

ARTS 打卡 WEEK2

编程之心

ARTS 打卡计划

ARTS打卡计划_第一周

叫不醒装睡的人

ARTS 打卡计划

如何设置线程池参数?美团给出了一个让面试官虎躯一震的回答。

why技术

Java 源码分析 面试题 线程池

【ARTS打卡】Week01

Rex

学习

ARTS week2

紫枫

ARTS 打卡计划

Mysql索引不会怎么办?6000字长文教会你

Super~琪琪

MySQL 数据库 sql 索引

ARTS-1

你当像鸟飞往你的山

ARTS 打卡计划

重学 Java 设计模式:实战单例模式

小傅哥

设计模式 编程思维 重构 优化代码

ARTS week 2

刘昱

愚蠢写作术(1):怎么让你的标题被读者忽视

史方远

个人成长 写作

区块链技术大显身手,仅用20分钟就打完一场官司!

CECBC区块链专委会

CECBC 区块链技术 数字版权 存证

MySQL 可重复读,差点就我背上了一个 P0 事故!

楼下小黑哥

Java MySQL

ARTS Week1

姜海天

Java日志门面系统

泛泛之辈

Java 日志 slf4j

滴滴 Logi 日志管理与分析平台

滴滴 Logi 日志管理与分析平台

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