FinOps有望降低企业50%+的云成本! 了解详情
写点什么

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

  • 2015-10-19
  • 本文字数:4726 字

    阅读完需:约 16 分钟

测试驱动的开发 (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:029740

评论 1 条评论

发布
用户头像
mark
2021-08-06 11:19
回复
没有更多了
发现更多内容

Python 的切片为什么不会索引越界?

Python猫

Python

网络安全教程:13 信息收集

喀拉峻

黑客 网络安全 安全 信息安全

vue3.2组件库-element plus 自动按需引入

Mr.Cactus

typescript Vue3 Element Plus Vite2

怎么活的超脱:把自己的生活看成一场戏

mtfelix

28天写作

《PyTorch 深度学习实战》复习14

IT蜗壳-Tango

28天写作 12月日更

语音信号处理 4:语音信号在时域和频域的表示

轻口味

28天写作 12月日更

不要被数据蒙蔽你的眼睛

好奇分析

数据分析 统计学 辛普森悖论

☕【Java深层系列】「技术盲区」让我们一起完全吃透针对于时间和日期相关的API指南

洛神灬殇

Java 工具 日期处理 12月日更

如何正确的重写hashcode()

李子捌

Java 28天写作 12月日更

Dubbo框架学习笔记七

风翱

dubbo 12月日更

元宇宙100讲-0x007

hackstoic

元宇宙

十二张图带你了解 Redis 的数据结构和对象系统

程序员历小冰

redis 数据结构 28天写作 12月日更

JavaScript面试系列:JavaScript设计模式之桥接模式和懒加载

Jerry Wang

JavaScript 设计模式 桥接模式 28天写作 12月日更

聊聊你每天是如何修bug的

卢卡多多

bug修复 28天写作 12月日更

花一点时间优化一次年迈的后台系统的检索体验

为自己带盐

28天写作 12月日更 ​jQuery

解决:Command ‘mongo‘ not found, but can be installed with

liuzhen007

28天写作 12月日更

Istio的认证授权机制分析

xcbeyond

istio 认证授权 28天写作 12月日更

GrowingIO Reactor速成指南

GrowingIO技术专栏

响应式编程 reactor

跟着源码学IM(九):基于Netty实现一套分布式IM系统

JackJiang

Netty websocket 即时通讯 IM

复盘健康

将军-技术演讲力教练

【架构实战营】模块七

Henry | 衣谷

架构实战营

🏆【CI/CD技术专题】「Docker实战系列」(1)本地进行生成镜像以及标签Tag推送到DockerHub

洛神灬殇

Docker 容器镜像 12月日更 Dockerhub

王者荣耀异地多活架构设计

Beyond Ryan

PassJava 开源(一) :初始化项目和添加微服务

悟空聊架构

SpringCloud 28天写作 passjava 悟空聊架构 12月日更

50 K8S之Contour控制器

穿过生命散发芬芳

k8s 28天写作 12月日更

动手做个 AI 机器人,帮我回消息!

程序员鱼皮

JavaScript AI 前端 nlp Node

DDD领域驱动设计实战(三)-深入理解实体

JavaEdge

12月日更

如何实现Redis限流

喵叔

28天写作 12月日更

如何提高用户留存?

石云升

AARRR 产品思维 28天写作 产品增长 12月日更

vivo:不做开发者的过客,变成IoT的归人

脑极体

【架构实战营】模块八

Henry | 衣谷

架构实战营

  • 需要帮助,请添加网站小助手,进入 InfoQ 技术交流群
反模式的经典 - Mockito设计解析_研发效能_吴以均_InfoQ精选文章