前言&背景
说起单元测试,每个开发人员都很熟悉,但很多人却不重视。发现很多IT公司里对于单测都没有规范,最多也只规定了一个覆盖率。很多开发人员认为单测属于可有可无,意义不大。或者有时间就写,没时间就算了的情况,甚至认为:“反正有测试同学帮忙把控代码质量,为什么还要开发浪费时间写单测呢?难道不是重复工作么?”。这个问题其实很有代表性,很多开发因为有这个想法,就算写了单测,可能也只是敷衍了事或者随意发挥,写出来的单测五花八门,没有规范可言,也就没有任何实际价值,纯粹是为了完成任务而已。
单元测试目的
正是因为目前业内对单测没有统一的标准和要求,对于单测的意义和价值也有这样那样的疑惑,所以希望探讨一下对单元测试的一些想法,求同存异。
首先要解释一下前言中开发同学的问题:
- 第一,职责的不同。开发人员的职责是什么?是完成一个功能的开发并保证其质量,而测试人员的职责则验收开发的成果,为产品的质量把关,保证项目交付的质量。很明显,保证代码质量是开发这一环节的工作职责之一,你自己写的代码的质量自己都不把控,又怎么能寄希望于别人能帮你把控呢?换句话说,在测试阶段发现了你代码里的很多缺陷,其实就说明了你的开发职责没有履行到位,本职工作都没有完成。
- 第二,成本的不同。对于同一个缺陷,在越早期发现,修复的成本就越小,这个道理想必大家都知道。如果很多的质量问题,都需要到测试阶段才能发现,才来返工修复,这对整体项目的时间和资源来说绝对是不小的浪费。
- 第三,角度的不同。对于开发人员来说,写单测更多的是针对单个方法的逻辑的测试,而对于测试人员来说,更多的是针对功能的黑盒测试,很多方法内部的逻辑其实并没有办法测到,这些逻辑是必须用单测才能覆盖到的。
然后谈谈个人对单测的看法。单测的作用到底是什么?意义究竟如何体现?
- 第一,单测可以很好保证代码质量,从而增加开发人员的信心,这点就不再赘述了。
- 第二,单测可以一定程度提高代码合理性。当我们发现给一个方法写单测非常困难,比如单测需要覆盖的分支非常多,那可能说明方法可以拆分;又比如单测需要mock的调用非常多,那可能说明方法违背了单一责任原则,处理了太多的逻辑,也可以拆分等等。
- 第三,单测能够有效防止回溯问题(regression issue)的出现。所谓回溯问题,指的就在之前版本没有在新版本才出现的问题。这种问题的严重程度是最高的,影响也是最恶劣的。原因很简单:用户可以接受一个本来在老版本就不存在的功能不可用,但是一定无法接受一个本来在老版本用地好好的功能突然失效了。在新功能开发完后,运行老功能单测,如果发现未变更逻辑的老功能单测报错,则很有可能是出现了回溯问题。
- 第四,单测能够帮助测试人员确定回归范围。这一点其实是第三点扩展。在新功能提测的时候,开发人员需要提供测试范围,毕竟随着功能的不停增加,全量回归已经变得越来越不可能了。有些开发同学,为了安全起见,随意增加回归范围,这无疑增加了测试人员不必要的工作,是一种严重浪费测试资源的行为。在新功能开发完后,运行老功能单测,如果发现单测报错,则说明这部分老功能的逻辑可能发生了变化,单测需要进行相应的调整,且相关功能应该属于回归的范围
单元测试规范
一. 可衡量:单测的编写应该是可以用具体的指标衡量的
单测通过率要求100%,行覆盖率要求50%。
解释:通过率100%没啥好多说的,如果单测跑不通过,那不是单测有问题就是代码逻辑有问题。覆盖率的话可以根据具体的工程进行微调,建议不应小于40%,越底层的代码覆盖率应该越高,越新的代码覆盖率也应该越高。
老代码有逻辑变更时,单测也应该做相应的变更。
解释:这点的目的也是为了保证单测通过率100%。同时,这部分功能应该也属于改次功能的测试回归范围内。
新业务提测前,必须保证老单测的通过率也保持100%。
解释:这点的目的是为了防止回溯问题的出现。
二. 独立性:单测应该是独立且相互隔离的
一个单测只测试一个方法。
解释:保证了单测的独立性。当单测出错的时候也能够明确知道是哪个方法出了问题。但这并不是说一个方法只对应一个单测,因为为了覆盖方法内的不同分支,我们可以为一个方法创建多个单测。
单测不应该依赖于别的单测。
解释:保证了单测的独立性。每个单测应该都能独立运行。不应该有A单测跑完才能跑B单测的情况。
单测如果涉及到数据变更,必须进行回滚。
解释:保证了单测的隔离性。如果单测运行后在数据库中产生了数据,那这些脏数据可能干扰测试同学的测试工作,且也可能影响别的单测的运行结果。
单测应该测试目标方法本身的逻辑,对于被测试的方法内部调用的非私有方法应进行mock,推荐使用Mockito进行mock。
解释:目标方法存在内部调用情况,进行mock可以屏蔽其他方法对目标方法的影响。这样保证了单测的独立性,一个单测只保证它测试的目标方法的逻辑正确性,而不应该受其内部调用方法的逻辑的影响,这部分应该是这些内部调用的方法对应的单测的责任。但是真实情况中,这一点是最难被严格执行,因为这样做就意味着需要对所有的方法都设计单测,比如a调用b调用c的情况,需要至少设计三个单测,而不能只对a设计单测来覆盖整个调用链。不过,这不正是单测的含义吗?对最小的逻辑单元——方法进行测试,如果对于一个调用链进行测试,更像是集成测试的范畴了。而且如果不这么做,我们就会违反上面的第4条“一个单测只测试一个方法”。只有一种情况例外,方法内部调用的是私有方法,这样的话是可以通过调用方的单测一并测试的,见下面的第13条“私有方法通过调用类的单测进行测试”。我们可以试想一种情况,当一个项目由很多人协同开发时,我怎么才能放心使用另一个人开发的方法?至少得提供单测吧,如果这个方法的测试是在其调用方的单测中的,那就没有直接对应的单测了,这样也就无法保证该方法是否被妥当测试过了。
三. 规范性:单测的编写需要符合一定规范
对实现类进行测试而非接口。
解释:面向接口编程,面向实现测试。
单测应该是无状态的。
解释:即单测应该可以重复执行,且无论跑几次都应该保证通过率。比如有些方法会对当前时间进行判断,对于这类方法的单测也需根据当前时间的不同而进行不同的测试。
覆盖范围应包括所有提供了逻辑的类:service层、manager层、自定义mapper等,甚至还有部分提供业务逻辑的controller层代码。
解释:只要是提供了逻辑的就应该测试,不过个人并不建议在controller层提供业务逻辑,具体原因参考《设计之道-controller层的设计》。
覆盖范围不应包括自动生成的类:如MyBatis Generator生成的Mapper类、Example类,不应包括各种POJO(DO,BO,DTO,VO...),也不应包括无业务逻辑的controller类。
解释:自动生成的类有啥好测的?POJO的getter/setter有啥好测的?没有提供业务逻辑的controller类有啥好测的?这些被排除的类应该在覆盖率统计中被剔除。
私有方法通过调用类的单测进行测试。
解释:因为私有方法在测试类内没法直接调用,除非使用反射或其他Mock框架(PowerMock, TestableMock等)。
单测要覆盖到正常分支和异常分支,使用专门的异常测试属性junit(expected)/testng(expectedExceptions)。禁止使用try-catch。
解释:很多同学的单测覆盖率不达标,就是因为只覆盖了正常的分支而遗漏的异常的分支。异常的测试和正常的一样重要,也就是该报错的时候就应该报错。有些同学为了达到单测的覆盖率和通过率的指标,在单测中使用try-catch,这也是不允许的,应该使用专门的异常测试注解。
如果被测试的方法的逻辑体现在方法返回或成员变量中,则使用Assert断言验证该返回或成员变量。
解释:如果一个方法的内部组装了一个返回值,或变更了一个成员变量,那么应该使用Assert来验证该返回值或成员变量是否符合预期。
比如下面的三个方法,前两个的逻辑都是体现在返回值上,后一个的逻辑体现在成员变量中。
/**
* 逻辑体现在返回值
*
* @return
*/
public String displayName() {
String name = "HangzhouZoo";
return "Zhejiang " + name;
}
/**
* 逻辑体现在返回值
*
* @return
*/
public String luxuryShow() {
String show = dog.run();
return "luxury!! " + show;
}
/**
* 逻辑体现在成员变量
*/
public void close() {
this.open = false;
}
那么我们就可以使用Assert断言来测试这些逻辑:
//逻辑在方法返回体现
@Test
public void displayName() {
Assert.assertEquals("Zhejiang HangzhouZoo", hangzhouZoo.displayName());
}
//逻辑在方法返回体现
@Test
public void luxuryShow() {
when(dog.run()).thenReturn("dog show");
Assert.assertEquals("luxury!! dog show", hangzhouZoo.luxuryShow());
}
//逻辑在成员变量中体现
@Test
public void close() {
Assert.assertTrue(hangzhouZoo.isOpen());
hangzhouZoo.close();
Assert.assertFalse(hangzhouZoo.isOpen());
}
如果被测试的方法的逻辑体现在内部的方法调用行为本身,则使用Mockito的verify验证内部方法调用的情况。
解释:有些方法的内部根据不同的条件会调用不同的方法,则应该验证该方法的调用是否符合预期。Mockito的verify可以验证被mock的方法是否调用了,甚至可以验证方法调用的次数。
比如下面这个方法有三分条件分支,分支一抛出异常,分支二调用内部方法,分支三组装返回值。
/**
* 逻辑体现在异常、方法调用行为和返回值
*
*/
@Override
public String show(Animal animal) throws ZooException {
if (animal instanceof Tiger) {
throw new ZooException("tiger is not allowed");
} else if (animal instanceof Dog) {
return animal.run();
} else {
return "only dogs here";
}
}
其中分支二的逻辑就体现在方法调用的行为上,我们可以通过verify来验证方法是否如预期一样调用,也可使用times验证方法调用的次数。
//被测试的方法的逻辑体现在内部方法的调用行为本身
@Test
public void show() throws Exception {
when(dog.run()).thenReturn("dog run");
hangzhouZoo.show(dog);
//验证方法被调用过了
verify(dog).run();
//也可以通过times参数来验证方法具体被调用的次数
verify(dog, times(1)).run();
//验证另一个分支,逻辑体现在返回值
Assert.assertEquals("only dogs here", hangzhouZoo.show(new Cat()));
}
当然,还记得第13条“异常分支也需要测试么”,我们还需要写一个单测来覆盖异常分支:
//测试异常分支
@Test(expected = ZooException.class)
public void showForEx() throws Exception {
hangzhouZoo.show(new Tiger());
}
如果被测试的方法的逻辑体现在内部方法调用的参数中,即方法的逻辑用于构建内部调用方法的参数,则使用Mockito的verify验证内部方法调用的参数。
解释:有些方法的内部会组装一个对象,然后将这个对象作为参数传入另一个内部方法。使用Mockito的verify可以验证被mock的方法被调用的参数。如果是简单类型,可以直接验证,如果是复杂类,则需要扩展ArgumentMatcher
类来做验证。
下面这个方法的逻辑体现在内部调用方法的参数构造上:
/**
* 逻辑体现在参数构造-基本类
*
* @param times
*/
public void bark(int times) {
int actualTimes = times * 10;
dog.bark(actualTimes);
}
由于参数类型是基本类,所以我们可以直接用verify来验证:
//逻辑在参数体现-简单类型
@Test
public void bark() {
doNothing().when(dog).bark(anyInt());
hangzhouZoo.bark(3);
verify(dog).bark(30);
//与上面等价
verify(dog).bark(eq(30));
}
不过如果像下面这样的参数是复杂类的,就需要扩展一下:
/**
* 逻辑体现在参数构造-复杂类
*
* @param
* @return
*/
public String feedVegetable() {
Food tomato = Food.builder().name("tomato").build();
return dog.eat(tomato);
}
自定义参数匹配器:
/**
* 自定义参数匹配规则
*/
public class ObjectMatcher<T> extends ArgumentMatcher<T> {
private Object expected;
private Function<T, Object> getProperty;
public ObjectMatcher(Object expected, Function<T, Object> getProperty) {
this.expected = expected;
this.getProperty = getProperty;
}
@SuppressWarnings("unchecked")
@Override
public boolean matches(Object actual) {
return getProperty.apply((T) actual).equals(expected);
}
}
测试的时候使用argThat校验方法参数:
//逻辑在参数体现-复杂类
@Test
public void feedVegetable() {
when(dog.eat(any())).thenReturn("dog eat");
hangzhouZoo.feedVegetable();
//验证参数
verify(dog).eat(argThat(new ObjectMatcher<>("tomato", Food::getName)));
}
单测应在相应的目标方法开发完后立即编写,如能在开发前就开始编写则更好(TDD)。
解释:这点可能会违背很多开发同学的认知,怎么可能先写单测再写代码呢?实际上,如果稍微了解下测试驱动开发(Test-Driven Development),就会发现这并非异想天开,反倒是顺理成章的事。我认为有两种场景下单测的习惯是很容易能够推动的,第一种是团队里没有测试人员,代码质量完全由开发人员把控;而第二种就是软件开发流程使用的是TDD的方式,这样天然的就保证了单测必须存在。
原文: 设计之道-单元测试规范
作者:SawyerZhou