一、百变怪 Mockito
Mockito可谓是Java世界的百变怪,使用它,可以轻易的复制出各种类型的对象,并与之进行交互。
1.1 对象“复制”
// 列表
List mockList = mock(List.class);
mockList.add(1);
mockList.clear();
// Socket对象
Socket mockSocket = mock(Socket);
mockSocket.connect(new InetSocketAddress(8080));
mockSocket.close();
1.2 技能复制
List mockList = mock(List.class);
mockList.add(1); // 简单交互
mockList.get(1); // 返回值为null
mockList.size(); // 返回值为0
虽然复制出来的对象上的所有方法都能被调用,但好像这个百变怪的技能有点弱呢...
其实,是使用方式不对,这个百变怪掌握的仅仅是基础技能,对于有返回值的调用,只会返回默认的返回值,在需要返回对象的场合,返回null
,需要返回int
的场合,返回0
。其他的默认返回值,见下表:
// todo 默认返回值表
要让它能按我们的需要展现技能(方法),需要事先“教会”它。
List mockList = mock(List.class);
when(mockList.get(anyInt()).thenReturn(1);
when(mockList.size()).thenReturn(1, 2, 3);
assertEquals("预期返回1", 1, mockList.get(1)); // pass
assertEquals("预期返回1", 1, mockList.get(2)); // pass
assertEquals("预期返回1", 1, mockList.get(3)); // pass
assertEquals("预期返回1", 1, mockList.size()); // pass
assertEquals("预期返回2", 2, mockList.size()); // pass
assertEquals("预期返回3", 3, mockList.size()); // pass
上面的代码,我们教会了这个百变怪:
- 只要调用
get
方法,不管参数是什么,都返回1
; - 对于
size
方法调用,第一次返回1
,第二次调用返回2
,第三次开始,则返回3
。
是的,这个百变怪就是这么的笨,只会有样学样。看起来一点用都没有。
1.3 验证
但是呢,虽然它笨,但是它却具备一些“笨方法”,也不算没有用。
verify(mockList, never()).clear(); // 从未调用过clear方法
verify(mockList, times(2)).get(1); // get(1)方法调用了2次
verify(mockList, times(3)).get(anyInt()); // get(任意数字)调用了3次
verfiy(mockList, times(4)).size(); // 这里会失败,因为上面我们只调用了size方法3次
可以看到,这个百变怪虽然笨,但不傻,对于它自己做过了什么,它是记得一清二楚的。至于它还有什么其他技能,可以到官网看下他的使用说明书详细了解。
1.4 小结
可以看到,虽然Mockito在正式的场合(生产环境)下派不上什么用场,但在训练场(测试环境)上,却能够成为一个相当不错的陪练。
所以,Mockito是一个适用于单元测试的mock库。在单元测试中,可以通过它来方便的生成模拟对象。便于进行测试。
二、Mockito与单元测试
2.1 例
假设我们有一段业务逻辑,需要对给定的请求做处理,在这种情况下,倘若要手工构造发起一个请求,那想必是很麻烦蛋疼。首先我们需要把代码编译部署到测试服务器上,然后构造并发起一个请求,等待服务器接收到请求后,交给我们的业务进行处理。如下:
// 业务代码
public boolean handleRequest(HttpServletRequest request) {
String module = request.getParameter("module");
if ("live".equals(module)) {
// handle module live request
return true;
} else if ("user".equals(module)) {
// handle module user request
return true;
}
return false;
}
为了测试这么一点点代码,就需要我们额外付出那么多的操作,对于追求效率的程序员来说,这种重复操作&等待简直就是慢性自杀。这里的代码还是相对简单的,要是请求的内容更加复杂,难道还要花上大把时间研究如何构造出这么一个Http请求吗?
其实,测试这段逻辑,我们想要做的事情其实很简单,给定一个特定的输入,验证其输出结果是否正确。也就是,验证的过程,应该尽可能的简单方便,把大部分的时间耗费在验证过程上绝对是有问题的。
如果我们使用单元测试,搭配Mockito,完全可以写出如下测试,在代码提交之前,先在本地的JVM上过一遍测试。
@Test
public void handleRequestTestLive() throws Exception {
HttpServletRequest request = mock(HttpServletRequest);
when(request.getParameter("module")).thenReturn("live");
boolean ret = handleRequest(request);
assertEquals(true, ret)
}
@Test
public void handleRequestTestUser() throws Exception {
HttpServletRequest request = mock(HttpServletRequest);
when(request.getParameter("module")).thenReturn("user");
boolean ret = handleRequest(request);
assertEquals(true, ret)
}
@Test
public void handleRequestTestNone() throws Exception {
HttpServletRequest request = mock(HttpServletRequest);
when(request.getParameter("module")).thenReturn(null);
boolean ret = handleRequest(request);
assertEquals(false, ret)
}
首先,我们模拟出一个假对象,并设定这个假对象的行为,这个假对象的行为会影响我们业务逻辑的结果,所以我们可以在不同的测试用例里,设定假对象返回不同的行为,这样我们就能验证各种输入下,我们的业务逻辑是不是能够按我们的设想正常工作。
2.2 Mockito 原理剖析
Ok,到现在为止,我们通过几个例子简单的展示了Mockito,以及它在单元测试中起到作用。从例子中可以看到,Mockito的使用是很直观的,使用起来行云流水,就跟说话一样自然。某种程度上,也可以看做代码即注释
的一种表现。当然这有点扯远了。
Mockito的这种神乎其技的使用方式,使得我在一开始见到它的时候,感到惊讶,惊讶之余又感到不解。
如mock(List.class)
,怎么就能够从List.class
这个接口搞出一个可以用的对象?when(mockList.size()).thenReturn(20)
这种,竟然就能干预到mock对象的执行,插桩返回了20。mockList.size()
本身不就是一个方法调用吗?verify(mockList, never()).add(10)
,这种验证方式又是通过什么黑科技实现的???
看着Mockito
的使用文档的我,当时真是一脸黑人问号。
后来,从我有限的知识储备里,我想到了mock
的实现方式可能是使用泛型 + 动态代理
实现,当想到这种组合的时候,我不禁感慨库作者的思维的精妙,所以我决定研究下Mockito的源码,看看作者是怎么做到的。
当然,后来我发现,泛型是用到了(废话),动态代理技术却没有用到。好了,闲话不多说,下面来讲讲Mockito
的实现。由于在座同学,平时使用Java应该不多,所以这里我就不深入讲解细节,会比较偏向原理性的东西。
2.3 Mock
让我们来分析一下,要mock一个对象,我们需要做什么。
- 首先需要知道要Mock的对象的类型,这样我们才能生成这个类型的对象
- 为了生成这个类型的对象,那么这个类型需要是能实例化的,但如果这个类型是抽象类或者一个接口?要怎么办?我们知道,抽象类和接口需要被实现,才能实例化,因此,最自然的方式就是,继承自这个类型,然后给这些方法一个空实现。
- 有了可以实例化的类型,接下来就好办了:实例化这个类型,并上转型成我们的目标类,返回。
总结起来就是:给到要mock的类型、生成一个继承这个类型的类、实例化生成的类、得到mock对象。
Mockito的源码里正是这么做的:
- 暴露出Mockito.mock接口给使用者
- 得到要mock的类型,进行一些设置,然后一路传递到
SubclassBytecodeGenerator
,由它来生成mock类型的子类 - 得到这个类型后,
SubclassByteBuddyMockMaker
将其实例化
第二步的实现借助了ByteBuddy
这个框架,这个框架可以直接生成Java的类,然后通过ClassLoader加载进来使用。这里就不深入了。
第三步实例化,实例化使用了objenesis
,一个能在不同平台上实例化一个类的库。
经过这几步,就得到了一个可以用来操作的模拟对象。
实现的思路大致是这样,代码里的处理还有很多细节性的部分,这里不进行源码探究,就不多讲了
2.4 打桩
when这一步要实现的功能是打桩。
那么,对于when(mockType.someMethod()).thenReturn(value)
这样的方法调用,该怎么实现?
一开始我以为方法调用的返回值有猫腻,返回值唯一标识一次方法调用,通过在内部记录这个值,来返回特定的值。但对于每个方法调用,返回一个特定的返回值并不可能,何况有的方法调用并没有返回值。
这个功能Mockito是这么实现的:
在mock那一步,我们知道了Mockito生成了一个派生类,派生类里的所有方法调用,也已经被hook掉,即所有的方法调用,并不会执行到原有的实现逻辑里,而是会返回一个默认值。
所有的方法调用最终都会交由MockHandlerImpl.handle
来执行。这个类很重要,可以说是Mockito整个功能的核心所在。
在进行方法调用的时候,Mockito会假定这个方法调用需要被打桩,生成一个和这个方法调用相对应的OngoingStubbing
对象,将这个对象暂时存起来。
当when
方法执行的时候,就会取出这个暂存的OngoingStubbing
对象返回,这样我们就能在这上面打桩(调用thenReturn等方法),返回我们需要的值了。打桩完毕会生成一个Answer
对象,存放到一个链表里。后面调用对应的方法的时候,就会从这个链表内找到对应的Answer
对象,从中获取对应的值返回。
2.5 验证
方法的执行都被我们拦截了,要验证方法的执行也就不是什么难事了。但还是过一下。
回忆下,验证的代码verify(mockList, times(2)).get(anyInt())
。为了达成这样的效果,实现里必须:
- 在verify方法的执行过程里,记录下要验证的对象,以及要验证的参数
- 在执行方法调用的时候,取出要验证的对象、验证的参数,执行验证。
当了解了Mockito的设计之后,这一切都顺理成章。这里就不详细说了,如果大家有兴趣,可以去看下Mockito的源码。
Mockito这个库的设计思路很特别,它的功能的实现并不是在一个执行过程里干完,而是分阶段分步骤的执行。但Mockito又很好的保证了这些在不同时空里执行的步骤能够准确的结合起来,共同完成这一个过程。更重要的是,在这种情况下,它所暴露出来的API依旧简洁优雅,对使用者来说几乎是无感的。
三、单元测试
再好的工具,如果没有使用起来,也只是一个摆设。那么介绍完了Mockito,接下来我们回过头来聊聊单元测试。
首先是几个概念:
3.1 Mock
Mock一词指效仿、模仿,在单元测试里,使用mock来构造一个“替身”。这个替身主要用于作为被测类的依赖关系的替代。
依赖关系 – 依赖关系是指在应用程序中一个类基于另一个类来执行其预定的功能.依赖关系通常都存在于所依赖的类的实例变量中.
被测类 – 在编写单元测试的时候, “单元”一词通常代表一个单独的类及为其编写的测试代码. 被测类指的就是其中被测试的类.
为什么需要mock呢?
真实对象具有不可确定的行为,产生不可预测的效果,(如:股票行情,天气预报
真实对象很难被创建的
真实对象的某些行为很难被触发
真实对象实际上还不存在的(和其他开发小组或者和新的硬件打交道)等等
在这些情形下,使用Mock能大大简化我们的测试难度。举个例子:
假定我们有如上的关系图:
类A依赖于类B和类C
类B又依赖于类D和类E
为了测试A,我们需要整个依赖树都构造出来,这未免太麻烦
使用Mock,就能将结构分解,像这样。从图中可以清晰的看出,我们的依赖树被大大的简化了。Mock对象就是在测试的过程中,用来作为真实对象的替代品。使用了Mock技术的测试,也就能称为Mock测试了。
3.2 Stub
Stub就是打桩。
Stubbing就是告诉模拟对象当与之交互时执行何种行为过程。通常它可以用来提供那些测试所需的公共属性(像getters和setters)和公共方法。
使用Stub,可以根据我们的需要返回一个特殊的值、抛出一个错误、触发一个事件,或者,自定义方法在不同参数下的不同行为。
而这并不会增大我们的工作量,相反,减少了我们的工作量。使用Stub甚至能让我们在实现被模拟的对象的方法之前去测试我们的代码。
Stub进一步增强了Mock对象的能力。Mock本质上是对依赖的模拟,它使得我们拥有了一个依赖。但在测试中,除了依赖,我们还需要对这个依赖的行为进行控制,这就是Stub要做的事情。
Stub让我们能对依赖的行为进行模拟,省略具体的实现逻辑,直接控制行为的结果,一般用来提供测试时所需的测试数据,验证交互是否符合预期。
3.3 使用Mock和Stub的好处
- 提前创建测试,比如进行TDD
- 团队可以并行工作
- 创建演示demo
- 为无法/难以获取的资源编写测试
- 隔离系统
- 作为模拟数据交付给用户(假数据)
3.4 测试流程
进行单元测试时,我们只需关心三样东西: 设置测试数据,设定预期结果,验证结果。并不是所有的测试都包含着三样,有的只涉及设置测试数据,有的只涉及设定预期结果和验证.
模拟替换外部依赖、执行测试代码、验证执行结果是否符合预期。简称3A原则:Arrange、Act、Assert
3.5 单元测试不是集成测试
刚接触单元测试的时候,一直很迷惑,我的业务逻辑那么多那么复杂,这要怎么做单元测试呢?比如说一个登陆功能,虽然它仅仅是一个登陆功能,但它背后要干的事情可不少:验证用户名,验证密码,判断网络,发起网络请求,等待请求结果,根据结果执行不同的逻辑。
想想都头大,这样的单元测试要怎么写?
答:这样的单元测试不用写。
我们给这个东西做测试的时候,不是测整个登陆流程。这种测试在测试领域里称为集成测试,而不是单元测试。集成测试并不是我们(程序员)花精力的地方,而的是测试同事的业务范围。
关于测试,有一个Test Pyramid理论,叫测试的金字塔模型。
Test Pyramid理论基本大意是,单元测试是基础,是我们应该花绝大多数时间去写的部分,而集成测试等应该是冰山上面能看见的那一小部分。
为什么是这样呢?因为集成测试设置起来很麻烦,运行起来很慢,发现的bug少,在保证代码质量、改善代码设计方面更起不到任何作用,因此它的重要程度并不是那么高,也无法将它纳入我们正常的工作流程中。
而单元测试则刚好相反,它运行速度超快,能发现的bug更多,在开发时能引导更好的代码设计,在重构时能保证重构的正确性,因此它能保证我们的代码在一个比较高的质量水平上。同时因为运行速度快,我们很容易把它纳入到我们正常的开发流程中。
至于为什么集成测试发现的bug少,而单元测试发现的bug多,这里也稍作解释,因为集成测试不能测试到其中每个环节的每个方面,某一个集成测试运行正确了,不代表另一个集成测试也能运行正确。而单元测试会比较完整的测试每个单元的各种不同的状况、临界条件等等。一般来说,如果每一个环节是对的,那么在很大的概率上,整个流程就是对的。虽然不能保证整个流程100%一定是对的。所以,集成测试需要有,但应该是少量,单元测试是我们应该花重点去做的事情。
3.6 为什么要进行单元测试
常见的理由有:
- 对软件质量的提升
- 方便重构
- 节约时间
- 提升代码设计
- ...
但以上的理由却很难得到证明。软件质量的提升,如何通过数据来表明?方便重构,这个必要性很大吗?尤其是在工期紧张,功能优先的情况下。需求都做不完,哪有时间写测试,更何谈节约时间。至于代码设计提升,更多的不是工程师的素养问题吗。
那么单元测试有没有别的作用?
当我们参与到新项目,接手维护旧模块,其实挺让人惊恐的。对项目结构的不熟悉、各模块各部分之间的关联也难以理清,有些还不一定能理清。经常改动一个地方,结果莫名其妙的引起了别的地方的问题,如果改动的是框架层上的东西,那更让人蛋疼了。业务用法千千万,一个一个手动测试,哪里来得及,就算来得及,重复几遍也让人蛋疼。
对于用户量大的应用,如QQ音乐、全民K歌,一天几千万的DAU,出一个bug,crash率上涨、外网投诉量蹭蹭蹭的涨,遇上这种时候肯定是内心十万个草泥马...要是遇上一个特殊的场景,非必现,用户复现路径复杂,定位调试也要耗费大量时间。
这种情况下,单元测试才是一枚更好的解药。单元测试仅是对一个代码单元进行测试,保证一个代码单元的正确可比保证整个APP的准确容易,遍历这个代码单元的所有参数输入和输出,也比验证所有的用户场景容易,重点是,跑一次单元测试,比运行一次手动测试快!而且还可以交给程序自动化。人的天性总是懒惰的。
另外一个,如果代码中有一些陈年代码,如果想要对其进行重构,如果没有单元测试,想要动手去重构想必也是需要一定勇气。而单元测试,可以成为我们的一道保障,让我们在改动代码的时候不需要顾虑太多,正确性由单元测试来验证和保障。这也是<重构>一书里不断强调的。
节省时间:
上面提到了Mock可以用来协同工作。这里举个例子:
我们做需求的时候,对于有一定经验,有一定代码思想的人来说,当他拿到一个新的需求,他会先想想代码的结构,应该有那些类,那些组件,什么责任应该划分到哪里去,然后才开始动手写代码,这个是很自然的一个思维过程。
但这样一来,我们要验证我们的代码正确性的时候,就只能等到每个部分都搞定在验证了?这显然是低效的,有的部分还涉及了前后台联动等外部条件的制约,每个部分都搞定了也不一定能测试。而且,每个未经测试的代码整合在一起,出错的时候往往还要花上相当的时间却定位问题出在哪部分上,然后修改,部署/安装,重复验证。
如果有单元测试,结合Mock,我们就能在编写每个小功能块的同时,对其进行验证。
使用单元测试,能够给我们:
- 更快的结果反馈
- 带来更少的bug(开发自测),也更容易发现bug(回归测试)
- 节约时间(不在受限于外部条件的制约无法验证)
- 更好的设计(为了写出便于测试的代码,会开始思考程序的架构是否合理,保持单一责任,减低耦合,倾向于组合,而不是继承)
3.7 如何开展单元测试
- 从现在开始,一点一点的写,有总好过没有
- 在测试过程中,逐渐建立自己的工具箱,相似的场合测试大同小异,抽公共部分作为辅助类,便于测试
- 如果当前项目里没有单元测试,引入起来有点困难,那么先在新的代码里引入,后面慢慢调整项目结构,将测试覆盖开去
四、参考资料
反模式的经典 - Mockito设计解析
JUnit + Mockito 单元测试(二)
Android单元测试(四):Mock以及Mockito的使用
5分钟了解Mockito
Mockito 简明教程
Mockito源码解析
[译] 使用强大的 Mockito 测试框架来测试你的代码
Mockito:一个强大的用于 Java 开发的模拟测试框架
Android单元测试: 首先,从是什么开始
Android单元测试(二):再来谈谈为什么