how to learn unit test mock framework

每次使用各种形形色色的单元测试Mock框架都比较晕,因为写的不够多,等学会了,又流行了一个新的框架,思考为什么老是记不住以及为什么每次都不能胸有成竹的说自己掌握了,想想估计是因为每次都是现学现用,比较零散,不成体系,所以写下这个记录,汇总下到底应该学习,一方面可以帮助在新学一个单元测试mock框架的时候,按照这个顺序学,学完之后,按照这个步骤写CASE;另一方面在使用mockito/powermock时,直接根据场景复制代码。

必知必会(一)- 搞出“假”对象

既然是单元测试的Mock使用, 第一步要学的是怎么搞出一个假的对象,然后后续工作其实都是围绕这个假对象做文章。

根据应用场景不同可以划分为2种方式:

(1)Mock: 制造一个完全的假对象;

根据策略的不同,可以定义不同默认行为的假对象,例如:

所有方法不进行任何真实调用,都统一返回null;

Apple apple= Mockito.mock(Apple.class);

所有方法调用是真实调用

Apple apple= Mockito.mock(Apple.class,Mockito.CALLS_REAL_METHODS);

(2)Spy: 制造一个假对象,但是是基于已有一个真实的对象。

满足的需求是,大多方法想用真实实例来调用,只想定制实例内部的一些方法。

Apple apple = new Apple();
Apple spiedApple= Mockito.spy(apple);

必知必会(二)- 绑定上“假”对象

第一步搞出假对象后,不会就自动使用上了,否则别人不需要用mock的测试怎么测?所以第二步要做的是让自己的Mock对象使用上,即绑定上被测目标和mocked对象,思考一个类如何使用另外一个类:

1 被测目标自己不创建,而是需要使用者传递进去的方式:

(a)作为构造器参数直接传递进去;
(b)使用Set系方法传递进去;

2 被测目标负责创建

本质上创建都是new的过程(PS:除了静态类), 所以mock掉new,让new返回要mocked的对象,就搞定所有的事情,但是从被测目标看,不可能都是new,可能这个new离被测目标还是有一定的距离:例如使用工厂类,使用spring的@autowire的等等,所以从代码层次看有以下几种情况:

(1) Apple apple = new Apple();
(2) apple = AppleFactory.getIntance();
(3)
@Autowired
private Apple apple;

(a)针对直接new的方式:让new出一个对象都返回mock的实例

class AppleTree{

private Apple apple= new Apple();

}

@RunWith(PowerMockRunner.class)
@PrepareForTest({AppleTree.class}) //don't miss this statement and pay more attention it is caller for Apple, not Apple.
public class TestNewObject { 

@Test
public void test() throws Exception {
Apple apple= Mockito.mock(Apple.class);
PowerMockito.whenNew(Apple.class).withNoArguments().thenReturn(apple);
}

(b)针对使用其他类来创建:mock创建方法

一般都是静态工厂这种情况,如果不是“静态工厂”,是另外一个实例的普通方法创建的,则需要mock那个实例了。
这里仅考虑一般情况,即面对静态工厂方法,mockito暂时不支持静态类的mock,所以需要结合powermock:

@RunWith(PowerMockRunner.class)
@PrepareForTest({AppleFactory.class}) //don't miss this statement
public class TestStaticMethod {

@Test
public void test() throws Exception {
Apple apple= Mockito.mock(Apple.class);
PowerMockito.mockStatic(AppleFactory.class);
PowerMockito.when(AppleFactory.getInstance())
.thenReturn(apple);
}

(c)还有一种情况是使用框架自动创建的,例如使用Spring的@Autowired
此时可以使用JAVA反射来直接设置进去,但是既然是使用mock工具,也可以使用标准点的方式,例如:

Apple apple= Mockito.mock(Apple.class);
Whitebox.setInternalState(testAppleInstance, "apple", apple);

必知必会(三)- Mock对象上做文章-伪造行为

学完前面2步后,就可以开始考虑干活了,既然搞出假的mock对象,不可能不去做一些“假动作”: 匹配上一个方法,然后做出一个行为:

其中匹配包括2种:

粗略匹配:

Mockito.when(mockedApple.getOwner(Mockito.anyString()).thenReturn("tom");

note: Mockito.anyString()不能匹配null的情况,此时需要用更普适的Mockito.any().

精确匹配:

Mockito.when(mockedApple.getOwner(Mockito.eq("somegstring"))).thenReturn("tom");

行为包括以下三种:

(1) 定义方法非真实调用;

设置其返回值:

Mockito.when(mockedApple.getOwner()).thenReturn("tom");

设置其抛出异常:

Mockito.when(mockedApple.getOwner()).thenThrow(new RuntimeException());

(2)定义方法去进行真实调用:

Mockito.when(mockedApple.getNumbers()).thenCallRealMethod();

对于不同的mock框架,可能还提供另外一些模式,例如mockito:

提供另外一种使用模式Mockito.[action].When:Mockito.doNothing()/Mockito.doCallRealMethod/Mockito.doReturn/Mockito.doThrow

这种模式适合没有返回值的方法调用,因为Mockito.when需要接带返回值的方法调用,而这种模式可以如下使用:

Mockito.doNothing().when(mockedApple).someVoidCall(Mockito.any());

(3)自适应变化:

例如设置每次返回的不同可以使用:

 when(mockedApple.getOwner())
  .thenReturn("one")  //第一次行为
  .thenCallRealMethod() //第二次行为
  .thenThrow(new RuntimeException()); //第三次行为

其他形式的各种高级搞法,不考虑。

必知必会(四)- Mock对象上做文章-验证行为

不考虑本身case就可以写出验证点,有时候需要验证一些mocked对象上的行为来验证case是否成功,按照需要验证的要点来看:
(1)验证调用与否或调用次数

 Mockito.verify(mockedApple, Mockito.times(2)).someMethod(Mockito.anyString());
 Mockito.verify(mockedApple, Mockito.never()).someMethod(Mockito.anyString());

(2) 验证调用时间

 Mockito.verify(mockedApple, Mockito.timeout(10)).someMethod(Mockito.anyString());

(3)验证调用参数值

方式1:Matcher-直接验证参数

简单校验:

 Mockito.verify(mockedApple, times(2)).someMethod(Mockito.eq("expectedString")); //mockito要求此处不能直接写"expectedString"

自动义校验方法:

使用Mockito.argThat+ArgumentMatcher(Matchers.argThat(Matcher matcher)
):

Mockito.verify(mockedApple).someMethod(Mockito.argThat(new ArgumentMatcher<String>(){
			@Override
			public boolean matches(String argument) {
		    	return argument.equals("expectedString");
}}));

方式2:Captor-捕获出参数,然后校验

使用ArgumentCaptor捕获参数,然后进一步处理的

ArgumentCaptor<String> argument = ArgumentCaptor.forClass(String.class);
Mockito.verify(mockedApple).someMethod(argument.capture());
String value = argument.getValue();
Assert.assertEqual(value, expectedString);

区别:
Also, sometimes ArgumentCaptor may be a better fit than custom matcher. For example, if custom argument matcher is not likely to be reused or you just need it to assert on argument values to complete verification of behavior.

(4) 验证调用顺序

主要包括两种,一种是同一个mock对象的方法调用顺序,另外一种是跨mock对象的方法调用顺序验证,分别参考一下两种示例:

InOrder inOrder = Mockito.inOrder(mockedApple);
inOrder.verify(mockedApple).firstMethodCallName();
inOrder.verify(mockedApple).secondMethodCallName();
InOrder inOrder = Mockito.inOrder(mockedApple,mockedOrange);
inOrder.verify(mockedApple).methodCallName();
inOrder.verify(mockedOrange).methodCallName();

对于各种验证,有时候需要reset mock对象,以便处理共享等问题,可以使用Mockito.reset()。

总结:

对于一个新的单元测试框架大体要搞清楚几件事情:“伪造对象-绑定对象-定制对象动作-验证” ,核心关键是mock/spy it then when customized match one method do something and verify after executed写具体case的时候,也可以follow四个步骤来搞。另外上面演示的都是基本要点,其他都是各种形式的变种或高级用法,同时每种框架都有自己的特殊要求,必须遵从。

Reinventing the wheel-talk about new solution involved

所谓新方案,包括方法和技术两方面;相对于“旧”而言,“新”不代表就是自己去创造,而更多的是采用已有的更成熟、更“时髦”的工具或方法。涉及到编码话题,大到架构方案选型,小到具体某块代码都在考虑新方案。

在采用新方案时,我们必须正视一些原则,其实这些原则随处可“见”,而且基本潜移默化都会如此执行,但是这里还是以自己的切身感受重复总结一番:

(1)是否可以不引入。

每引入新的方案,带来的收益是否值得,基本也就一个简单问题:收益>支出?

有一些因素可能会让你避免引入新方案:

1.1 引入新方案就意味着引入新的风险,无新引入则无新风险,有则有新风险。

1.2 有损优势,例如有的项目以简洁见“长”(例如influxdb-java),尽量不引入额外的jar作为一个原则,仅仅为了1处复杂度不高的代码少写几行,以后估计别的地方也不会用,则引入额外的jar则得不偿失。

1.3 有应用新方案的时间,可以去做更有意义或更高优先级的事情。

(2)如果改,改成哪种?

2.1 采用的方案是否收费,版权如何规定的?

在选择时,收费和版权问题基本具有是“一票否决权”。

同时要意识到收费的方案虽“好”,但是也暗含着一定的“封闭性”。

2.2 采用的方案是否成熟?

包括是否有大规模应用案例,是否有活跃的社区支持。

2.3 采用某种方案的什么版本?

什么版本是稳定版,什么是“试验”版,例如netty有v3,v4,v5三个版本,5是试验版,但是一些喜欢“尝鲜”的工程师应用到产品中,结果悲剧发现,某天v5彻底宣布不维护了,而一些喜欢尝鲜的工程师后知后觉发现不维护后,戏称要拿刀砍死源作者!情何以堪!

2.4 所采用的版本有没有什么不能接受的问题?

基本所有的方案都会提及自己的问题, 例如git上的issues, doc更完善的方案会将issue和版本关联起来。使用某个版本时,一定要查看当前的issues,同时在具体使用中,一定要看看是否有提及“不稳定”因素等。

例如:在guava中,有一个实用的方法Splitter:


Splitter.on(",").withKeyValueSeparator('=').split("boy=tom,girl=tina,cat=kitty,dog=tommy");

用来先以,分割,然后以=分割,这个方法感觉挺酷的,正好可以解决去解析格式是key1=value1;key2=value2结构,但是用的过程中,就会发现偶尔会失败,最后定位到因为某个value是编码过的,有时候会含有分割符=,但是guava对于这种情况的处理,是直接抛异常。

这在它的javadoc中并没有说明这种情况,仅仅是在类中标了下@beta表明其有可能有bug(实际上翻阅历史,有2个bug导致其一直挂着@beta标记). (已提交1个pr去fix: https://github.com/google/guava/pull/2663,不过不知道什么时候能够merge)

大家可能说,这些都是废话,因为你要用那个方法,你不去看javadoc,不去彻头彻尾了解源码么?实际上,大家都懂的:

(1)有的方法起的太好了,以致你觉得根本不需要看文档;

(2)javadoc写不写一回事,清楚不清楚是另一会事,不是你想看就能看,想看明白就明白;

(3)就算翻源码,并没有多少时间,毕竟你要用的所有东西都有源码,根本翻不完。哈哈

2.5 持续观察

不管是什么大神级项目,都有bug,只是触发不触发到的问题,所以持续观察,不仅可以了解方案的发展和局限,也帮助预知一些问题。

使用mybatis,不言而喻其广泛使用度,最近发现一个问题,当要查询的procedure返回游标类型且为null时,报NPE错误。实际上不考虑procedure本身有没有问题,这种情况需要处理:“一个列表没有数据应该是空,但是有时有的人就要搞成null”;

Mybatis的处理,导致这种情况下不是返回null或者空,而是直接抛异常,这隐藏了一些问题,或者让人误解,因为找不到就是找不到,是正常情况。


fail &nbsp;for: org.mybatis.spring.MyBatisSystemException: nested exception is org.apache.ibatis.exceptions.PersistenceException:

### Error querying database.&nbsp; Cause: java.lang.NullPointerException

### The error may exist in sqlmaps/sqlmap.xml

### The error may involve project.procedure

### The error occurred while handling results

### SQL: {call xxx.YYY.call(&nbsp;&nbsp;&nbsp; ?,&nbsp;&nbsp;&nbsp; ?,&nbsp;&nbsp;&nbsp; ?,&nbsp;&nbsp;&nbsp; ?,&nbsp;&nbsp;&nbsp; ?,&nbsp;&nbsp;&nbsp; ?,&nbsp;&nbsp;&nbsp; ?,&nbsp;&nbsp;&nbsp; ?,&nbsp;&nbsp;&nbsp; ?&nbsp;&nbsp; )}

### Cause: java.lang.NullPointerException

产品一直work良好,只是有一些错误log,开始以为是产品的bug,后来debug到mybatis的一个bug,而这个bug的fix日期是2015/11/13.  https://github.com/mybatis/mybatis-3/commit/2d6aed5d3d0cb0dd1290cf520dfdc52d89e63b3f#diff-5372ad5ca04a1c3dcfd0a43546bf40ef, 让人情何以堪。而当时引入mybatis时,应该是在2014年末。所以不能认为你用了就一劳永逸了,可能是你还没有触发,或者你观察的不够。时不时跟踪注意下,就可能有新的发现。

总结:

编码就是不断使用轮子的过程,今天的新轮子可能就是明天的旧轮子。所以采用新方案要追“星”但是不能盲目追“新”。