那些年,我们写过的无效单元测试

图片
前言
那些年,为了学分,我们学会了面向过程编程
那些年,为了就业,我们学会了面向对象编程
那些年,为了生活,我们学会了面向工资编程
那些年,为了升职加薪,我们学会了面向领导编程
那些年,为了完成指标,我们学会了面向指标编程
……
那些年,我们学会了敷衍地编程
那些年,我们编程只是为了敷衍
现在,领导要响应集团提高代码质量的号召,需要提升单元测试的代码覆盖率。当然,我们不能让领导失望,那就加班加点地补充单元测试用例,努力提高单元测试的代码覆盖率。至于单元测试用例的有效性,我们大抵是不用关心的,因为我们只是面向指标编程。
我曾经阅读过一个Java服务项目,单元测试的代码覆盖率非常高,但是通篇没有一个依赖方法验证(Mockito.verify)、满纸仅存几个数据对象断言(Assert.assertNotNull)。我说,这些都是无效的单元测试用例,根本起不到测试代码BUG和回归验证代码的作用。后来,在一个月黑风高的夜里,一个新增的方法调用,引起了一场血雨腥风。
编写单元测试用例的目的,并不是为了追求单元测试代码覆盖率,而是为了利用单元测试验证回归代码——试图找出代码中潜藏着的BUG。所以,我们应该具备工匠精神、怀着一颗敬畏心,编写出有效的单元测试用例。在这篇文章里,作者通过日常的单元测试实践,系统地总结出一套避免编写无效单元测试用例的方法和原则。
一、单元测试简介
1.1. 单元测试概念
在维基百科中是这样描述的:
在计算机编程中,单元测试又称为模块测试,是针对程序模块来进行正确性检验的测试工作。程序单元是应用的最小可测试部件。在过程化编程中,一个单元就是单个程序、函数、过程等;对于面向对象编程,最小单元就是方法,包括基类、抽象类、或者派生类中的方法。
1.2. 单元测试案例
首先,通过一个简单的服务代码案例,让我们认识一下集成测试和单元测试。
1.2.1. 服务代码案例
这里,以用户服务(UserService)的分页查询用户(queryUser)为例说明。
1.2.2. 集成测试用例
很多人认为,凡是用到JUnit测试框架的测试用例都是单元测试用例,于是就写出了下面的集成测试用例。
集成测试用例主要有以下特点:
依赖外部环境和数据;
需要启动应用并初始化测试对象;
直接使用@Autowired注入测试对象;
有时候无法验证不确定的返回值,只能靠打印日志来人工核对。
1.2.3. 单元测试用例
采用JUnit+Mockito编写的单元测试用例如下:
单元测试用例主要有以下特点:
不依赖外部环境和数据;
不需要启动应用和初始化对象;
需要用@Mock来初始化依赖对象,用@InjectMocks来初始化测试对象;
需要自己模拟依赖方法,指定什么参数返回什么值或异常;
因为测试方法返回值确定,可以直接用Assert相关方法进行断言;
可以验证依赖方法的调用次数和参数值,还可以验证依赖对象的方法调用是否验证完毕。
1.3. 单元测试原则
为什么集成测试不算单元测试呢?我们可以从单元测试原则上来判断。在业界,常见的单元测试原则有AIR原则和FIRST原则。
1.3.1. AIR原则
AIR原则内容如下:
1、A-Automatic(自动的)
单元测试应该是全自动执行的,并且非交互式的。测试用例通常是被定期执行的,执行过程必须完全自动化才有意义。输出结果需要人工检查的测试不是一个好的单元测试。单元测试中不准使用System.out来进行人肉验证,必须使用assert来验证。
2、I-Independent(独立的)
单元测试应该保持的独立性。为了保证单元测试稳定可靠且便于维护,单元测试用例之间决不能互相调用,也不能对外部资源有所依赖。
3、R-Repeatable(可重复的)
单元测试是可以重复执行的,不能受到外界环境的影响。单元测试通常会被放入持续集成中,每次有代码提交时单元测试都会被执行。
1.3.2. FIRST原则
FIRST原则内容如下:
1、F-Fast(快速的)
单元测试应该是可以快速运行的,在各种测试方法中,单元测试的运行速度是最快的,大型项目的单元测试通常应该在几分钟内运行完毕。
2、I-Independent(独立的)
单元测试应该是可以独立运行的,单元测试用例互相之间无依赖,且对外部资源也无任何依赖。
3、R-Repeatable(可重复的)
单元测试应该可以稳定重复的运行,并且每次运行的结果都是稳定可靠的。
4、S-SelfValidating(自我验证的)
单元测试应该是用例自动进行验证的,不能依赖人工验证。
5、T-Timely(及时的)
单元测试必须及时进行编写,更新和维护,以保证用例可以随着业务代码的变化动态的保障质量。
1.3.3. ASCII原则
阿里的夕华先生也提出了一条ASCII原则
1、A-Automatic(自动的)
单元测试应该是全自动执行的,并且非交互式的。
2、S-SelfValidating(自我验证的)
单元测试中必须使用断言方式来进行正确性验证,而不能根据输出进行人肉验证。
3、C-Consistent(一致的)
单元测试的参数和结果是确定且一致的。
4、I-Independent(独立的)
单元测试之间不能互相调用,也不能依赖执行的先后次序。
5、I-Isolated(隔离的)
单元测试需要是隔离的,不要依赖外部资源。
1.3.4. 对比集测和单测
根据上节中的单元测试原则,我们可以对比集成测试和单元测试的满足情况如下:
图片
集成测试基本上不一定满足所有单元测试原则;通过上面表格的对比,可以得出以下结论:
集成测试基本上不一定满足所有单元测试原则;
单元测试基本上一定都满足所有单元测试原则。
所以,根据这些单元测试原则,可以看出集成测试具有很大的不确定性,不能也不可能完全代替单元测试。另外,集成测试始终是集成测试,即便用于代替单元测试也还是集成测试,比如:利用H2内存数据库测试DAO方法。
二、无效单元测试
要想识别无效单元测试,就必须站在对方的角度思考——如何在保障单元测试覆盖率的前提下,能够更少地编写单元测试代码。那么,就必须从单元测试编写流程入手,看哪一阶段哪一方法可以偷工减料。
2.1. 单元测试覆盖率
在维基百科中是这样描述的:
代码覆盖(Code Coverage)是软件测试中的一种度量,描述程序中源代码被测试的比例和程度,所得比例称为代码覆盖率。
常用的单元测试覆盖率指标有:
行覆盖(Line Coverage):
用于度量被测代码中每一行执行语句是否都被测试到了。
分支覆盖(Branch Coverage):
用于度量被测代码中每一个代码分支是否都被测试到了。
条件覆盖(Condition Coverage):
用于度量被测代码的条件中每一个子表达式(true和false)是否都被测试到了。
路径覆盖(Path Coverage):
用于度量被测代码中的每一个代码分支组合是否都被测试到了。
除此之外,还有方法覆盖(Method Coverage)、类覆盖(Class Coverage)等单元测试覆盖率指标。
下面,用一个简单方法来分析各个单元测试覆盖率指标:
图片
单元测试覆盖率,只能代表被测代码的类、方法、执行语句、代码分支、条件子表达式等是否被执行,但是并不能代表这些代码是否被正确地执行并返回了正确的结果。所以,只看单元测试覆盖率,而不看单元测试有效性,是没有任何意义的。
2.2. 单元测试编写流程
首先,介绍一下作者总结的单元测试编写流程:
图片
2.2.1. 定义对象阶段
定义对象阶段主要包括:定义被测对象、模拟依赖对象(类成员)、注入依赖对象(类成员)。
图片
2.2.2. 模拟方法阶段
模拟方法阶段主要包括:模拟依赖对象(参数、返回值和异常)、模拟依赖方法。
2.2.3. 调用方法阶段
调用方法阶段主要包括:模拟依赖对象(参数)、调用被测方法、验证参数对象(返回值和异常)。
2.2.4. 验证方法阶段
验证方法阶段主要包括:验证依赖方法、验证数据对象(参数)、验证依赖对象 。
2.3. 是否可以偷工减料
针对单元测试编写流程的阶段和方法,在不影响单元测试覆盖率的情况,我们是否可以进行一些偷工减料。
图片
2.4. 最终可以得出结论
通过上表格,可以得出结论,偷工减料主要集中在验证阶段:
调用方法阶段
验证数据对象(返回值和异常)
验证方法阶段
验证依赖方法
验证数据对象(参数)
验证依赖对象
通过一些合并和拆分,后续将从以下三部分展开:
验证数据对象(包括属性、参数和返回值);
验证抛出异常;
验证依赖方法(包括依赖方法和依赖对象)。
三、验证数据对象
在单元测试中,验证数据对象是为了验证是否传入了期望的参数值、返回了期望的返回值、设置了期望的属性值。
3.1. 数据对象来源方式
在单元测试中,需要验证的数据对象主要有以下几种来源。
3.1.1. 来源于被测方法的返回值
数据对象来源于调用被测方法的返回值,例如:
3.1.2. 来源于依赖方法的参数捕获
数据对象来源于验证依赖方法的参数捕获,例如:
3.1.3. 来源于被测对象的属性值
数据对象来源于获取被测对象的属性值,例如:
3.1.4. 来源于请求参数的属性值
数据对象来源于获取请求参数的属性值,例如:
当然,数据对象还有其它来源方式,这里就不再一一举例了。
3.2. 数据对象验证方式
在调用被测方法时,需要对返回值和异常进行验证;在验证方法调用时,也需要对捕获的参数值进行验证。
3.2.1. 验证数据对象空值
JUnit提供Assert.assertNull和Assert.assertNotNull方法来验证数据对象空值。
3.2.2. 验证数据对象布尔值
JUnit提供Assert.assertTrue和Assert.assertFalse方法来验证数据对象布尔值的真假。
3.2.3. 验证数据对象引用
JUnit提供Assert.assertSame和Assert.assertNotSame方法来验证数据对象引用是否一致。
3.2.4. 验证数据对象取值
JUnit提供Assert.assertEquals、Assert.assertNotEquals、Assert.assertArrayEquals方法组,可以用来验证数据对象值是否相等。
当然,数据对象还有其它验证方法,这里就不再一一举例了。
3.3. 验证数据对象问题
这里,以分页查询公司用户为例,来说明验证数据对象时所存在的问题。
代码案例:
3.3.1. 不验证数据对象
反面案例:
很多人为了偷懒,对数据对象不进行任何验证。
存在问题:
无法验证数据对象是否正确,比如被测代码进行了以下修改:
3.3.2. 验证数据对象非空
反面案例:
既然不验证数据对象有问题,那么我就简单地验证一下数据对象非空。
存在问题:
无法验证数据对象是否正确,比如被测代码进行了以下修改:
3.3.3. 验证数据对象部分属性
反面案例:
既然简单地验证数据对象非空不行,那么我就验证数据对象的部分属性。
存在问题:
无法验证数据对象是否正确,比如被测代码进行了以下修改:
3.3.4. 验证数据对象全部属性
反面案例:
验证数据对象部分属性也不行,那我验证数据对象所有属性总行了吧。
存在问题:
上面的代码看起来很完美,验证了PageDataVO中两个属性值totalSize和dataList。但是,如果有一天在PageDataVO中添加了startIndex和pageSize,就无法验证这两个新属性是否赋值正确。代码如下:
备注:本方法仅适用于属性字段不可变的数据对象
3.3.5. 完美地验证数据对象
对于数据对象属性字段新增,有没有完美的验证方案?有的!答案就是利用JSON序列化,然后比较JSON文本内容。如果数据对象新增了属性字段,必然会提示JSON字符串不一致。
完美案例:
备注:本方法仅适用于属性字段可变的数据对象。
3.4. 模拟数据对象准则
由于没有模拟数据对象章节,这里在验证数据对象章节中插入了模拟数据对象准则。
3.4.1. 除触发条件分支外,模拟对象所有属性值不能为空
在上一节中,我们展示了如何完美地验证数据对象。但是,这种方法真正完美吗?答案是否定。
比如:我们把userDAO.queryByCompany方法返回的uesrList的所有UserDO对象的属性值name和desc赋值为空,再把convertUser方法的name和desc赋值做一下交换,上面的单元测试用例是无法验证出来的。
所以,在单元测试中,除触发条件分支外,模拟对象所有属性值不能为空。
3.4.2. 新增数据类属性字段时,必须模拟数据对象的属性值
在上面的案例中,如果UserDO和UserVO新增了属性字段age(用户年龄),且新增了赋值语句如下:
如果还是用原有的数据对象执行单元测试,我们会发现单元测试用例执行通过。这是因为,由于属性字段age为空,赋值不赋值没有任何差别。所以,新增属性类属性字段是,必须模拟数据对象的属性值。
注意:如果用JSON字符串对比,且设置输出空字段,是可以触发单元测试用例执行失败的。
3.5. 验证数据对象准则
3.5.1. 必须验证所有数据对象
在单元测试中,必须验证所有数据对象:
来源于被测方法的返回值
来源于依赖方法的参数捕获
来源于被测对象的属性值
来源于请求参数的属性值。
具体案例可以参考《数据对象来源方式》章节。
3.5.2. 必须使用明确语义的断言
在使用断言验证数据对象时,必须使用确定语义的断言,不能使用不明确语义的断言。
正例:
反例:
谨防一些试图绕过本条准则的案例,试图用明确语义的断言去做不明确语义的判断。
3.5.3. 尽量采用整体验证方式
如果一个模型类,会根据业务需要新增字段。那么,针对这个模型类所对应的数据对象,尽量采用整体验证方式。
正例:
反例:
上面这种数据验证方式,如果模型类删除了属性字段,是可以验证出来的。但是,如果模型类添加了字段,是无法验证出来的。所以,如果采用了这种验证方式,在新增了模型类属性字段后,需要梳理并补全测试用例。否则,在使用单元测试用例回归代码时,它将会告诉你这里没有任何问题
四、验证抛出异常
异常作为Java语言的重要特性,是Java语言健壮性的重要体现。捕获并验证抛出异常,也是测试用例的一种。所以,在单元测试中,也需要对抛出异常进行验证。
4.1. 抛出异常来源方式
4.1.1. 来源于属性字段的判断
判断属性字段是否非法,否则抛出异常。
4.1.2. 来源于输入参数的判断
判断输入参数是否合法,否则抛出异常。
注意:这里采用的是Spring框架提供的Assert类,跟if-throw语句的效果一样。
4.1.3. 来源于返回值的判断
判断返回值是否合法,否则抛出异常。
4.1.4.来源于模拟方法的调用
调用模拟的依赖方法时,可能模拟的依赖方法会抛出异常。
这里,可以进行异常捕获处理,或打印输出日志,或继续抛出异常。
4.1.5. 来源于静态方法的调用
有时候,静态方法调用也有可能抛出异常。
除此之外,还有别的抛出异常来源方式,这里不再累述。
4.2. 抛出异常验证方式
在单元测试中,通常存在四种验证抛出异常方法。
4.2.1. 通过try-catch语句验证抛出异常
Java单元测试用例中,最简单直接的异常捕获方式就是使用try-catch语句。
4.2.2. 通过@Test注解验证抛出异常
JUnit的@Test注解提供了一个expected属性,可以指定一个期望的异常类型,用来捕获并验证异常。
注意:测试用例在执行到 userService.createUser方法后将跳出方法,导致后续验证语句无法执行。所以,这种方式无法验证异常编码、消息、原因等内容,也无法验证依赖方法及其参数。
4.2.3. 通过@Rule注解验证抛出异常
如果想要验证异常原因和消息,就需求采用@Rule注解定义ExpectedException对象,然后在测试方法的前面声明要捕获的异常类型、原因和消息。
注意:测试用例在执行到 userService.createUser方法后将跳出方法,导致后续验证语句无法执行。所以,这种方式无法验证依赖方法及其参数。由于ExpectedException的验证方法只支持验证异常类型、原因和消息,无法验证异常的自定义属性字段值。目前,JUnit官方建议使用Assert.assertThrows替换。
4.2.4. 通过Assert.assertThrows方法验证抛出异常
在最新版的JUnit中,提供了一个更为简洁的异常验证方式——Assert.assertThrows方法。
4.2.5. 四种抛出异常验证方式对比
根据不同的验证异常功能项,对四种抛出异常验证方式对比。结果如下:
图片
综上所述,采用Assert.assertThrows方法验证抛出异常是最佳的,也是JUnit官方推荐使用的。
4.3. 验证抛出异常问题
这里,以创建用户时抛出异常为例,来说明验证抛出异常时所存在的问题。
代码案例:
4.3.1. 不验证抛出异常类型
反面案例:
在验证抛出异常时,很多人使用@Test注解的expected属性,并且指定取值为Exception.class,主要原因是:
单元测试用例的代码简洁,只有一行@Test注解;
不管抛出什么异常,都能保证单元测试用例通过。
存在问题:
上面用例指定了通用异常类型,没有对抛出异常类型进行验证。所以,如果把ExampleException异常改为RuntimeException异常,该单元测试用例是无法验证出来的。
4.3.2. 不验证抛出异常属性
反面案例:
既然需要验证异常类型,简单地指定@Test注解的expected属性为ExampleException.class即可。
存在问题:
上面用例只验证了异常类型,没有对抛出异常属性字段(异常消息、异常原因、错误编码等)进行验证。所以,如果把错误编码DATABASE_ERROR(数据库错误)改为PARAMETER_ERROR(参数错误),该单元测试用例是无法验证出来的。
4.3.3. 只验证抛出异常部分属性
反面案例:
如果要验证异常属性,就必须用Assert.assertThrows方法捕获异常,并对异常的常用属性进行验证。但是,有些人为了偷懒,只验证抛出异常部分属性。
存在问题:
上面用例只验证了异常类型和错误编码,如果把错误消息"创建用户异常"改为"创建用户错误",该单元测试用例是无法验证出来的。
4.3.4. 不验证抛出异常原因
反面案例:
先捕获抛出异常,再验证异常编码和异常消息,看起来很完美了。
存在问题:
通过代码可以看出,在抛出ExampleException异常时,最后一个参数e是我们模拟的userService.createUser方法抛出的RuntimeException异常。但是,我们没有对抛出异常原因进行验证。如果修改代码,把最后一个参数e去掉,上面的单元测试用例是无法验证出来的。
4.3.5. 不验证相关方法调用
反面案例:
很多人认为,验证抛出异常就只验证抛出异常,验证依赖方法调用不是必须的。
存在问题:
如果不验证相关方法调用,如何能证明代码走过这个分支?比如:我们在创建用户之前,检查用户名称无效并抛出异常。
4.3.6. 完美地验证抛出异常
一个完美的异常验证,除对异常类型、异常属性、异常原因等进行验证外,还需对抛出异常前的依赖方法调用进行验证。
完美案例:
4.4. 验证抛出异常准则
4.4.1. 必须验证所有抛出异常
在单元测试中,必须验证所有抛出异常:
来源于属性字段的判断
来源于输入参数的判断
来源于返回值的判断
来源于模拟方法的调用
来源于静态方法的调用
具体内容可以参考《抛出异常来源方式》章节。
4.4.2. 必须验证异常类型、异常属性、异常原因
在验证抛出异常时,必须验证异常类型、异常属性、异常原因等。
正例:
反例:
4.4.3. 验证抛出异常后,必须验证相关方法调用
在验证抛出异常后,必须验证相关方法调用,来保证单元测试用例走的是期望分支。
正例:
五、验证方法调用
在单元测试中,验证方法调用是为了验证依赖方法的调用次数和顺序以及是否传入了期望的参数值。
5.1. 方法调用来源方式
5.1.1. 来源于注入对象的方法调用
最常见的方法调用就是对注入依赖对象的方法调用。
5.1.2. 来源于输入参数的方法调用
有时候,也可以通过输入参数传入依赖对象,然后调用依赖对象的方法。
5.1.3. 来源于返回值的方法调用
5.1.4. 来源于静态方法的调用
在Java中,静态方法是指被static修饰的成员方法,不需要通过对象实例就可以被调用。在日常代码中,静态方法调用一直占有一定的比例。
5.2. 方法调用验证方式
在单元测试中,验证依赖方法调用是确认模拟对象的依赖方法是否被按照预期调用的过程。
5.2.1. 验证依赖方法的调用参数
5.2.2. 验证依赖方法的调用次数
5.2.3. 验证依赖方法并捕获参数值
5.2.4. 验证其它类型的依赖方法调用
5.2.5. 验证依赖对象没有更多方法调用
5.3. 验证依赖方法问题
这里,以cacheUser(缓存用户)为例,来说明验证依赖方法时所存在的问题。
代码案例:
5.3.1. 不验证依赖方法调用
反面案例:
有些人觉得,既然已经模拟了依赖方法,并且被测方法已经按照预期返回了值,就没有必要对依赖方法进行验证。
存在问题:
模拟了依赖方法,并且被测方法已经按照预期返回了值,并不代表这个依赖方法被调用或者被正确地调用。
比如:在for循环之前,把userList置为空列表,这个单元测试用例是无法验证出来的。
5.3.2. 不验证依赖方法调用次数
反面案例:
有些很喜欢用Mockito.verify的验证至少一次和任意参数的组合,因为它可以适用于任何依赖方法调用的验证。
存在问题:
这种方法虽然适用于任何依赖方法调用的验证,但是基本上没有任何实质作用。
比如:我们不小心,把缓存语句写了两次,这个单元测试用例是无法验证出来的。
5.3.3. 不验证依赖方法调用参数
反面案例:
既然说验证至少一次有问题,那我就指定一下验证次数。
存在问题:
验证方法次数的问题虽然解决了,但是验证方法参数的问题任然存在。
比如:我们不小心,把循环缓存每一个用户写成循环缓存第一个用户,这个单元测试用例是无法验证出来的。
5.3.4. 不验证所有依赖方法调用
反面案例:
不能用任意参数验证方法,那只好用实际参数验证方法了。但是,验证所有依赖方法调用代码太多,所以验证一两个依赖方法调用意思意思就行了。
存在问题:
如果只验证了一两个方法调用,只能保障这一两个方法调用没有问题。
比如:我们不小心,在for循环之后,还进行了一个用户缓存。
5.3.5. 验证所有依赖方法调用
反面案例:
既然不验证所有方法调用有问题,那我就把所有方法调用验证了吧。
存在问题:
所有方法调用都被验证了,看起来应该没有问题了。但是,如果缓存用户方法中,存在别的方法调用。
比如:我们在进入缓存用户方法之前,新增了清除所有用户缓存,这个单元测试用是无法验证的。
5.3.6. 完美地验证依赖方法调用
验证所有的方法调用,只能保证现在的逻辑没有问题。如果涉及新增方法调用,这个单元测试用例是无法验证出来的。所有,我们需要验证所有依赖对象没有更多方法调用。
完美案例:
注意:利用ArgumentCaptor(参数捕获器),不但可以验证参数,还可以验证调用次数和顺序。
5.4. 验证方法调用准则
5.4.1. 必须验证所有的模拟方法调用
在单元测试中,涉及到的所有模拟方法都要被验证:
来源于注入对象的方法调用
来源于输入参数的方法调用
来源于返回值的方法调用
来源于静态方法的调用
具体案例可以参考《方法调用来源方式》章节。
5.4.2. 必须验证所有的模拟对象没有更多方法调用
在单元测试中,为了防止被测方法中存在或新增别的方法调用,必须验证所有的模拟对象没有更多方法调用。
正例:
备注:
作者喜欢在@After方法中对所有模拟对象进行验证,这样就不必在每个单元测试用例中验证模拟对象。
可惜Mockito.verifyNoMoreInteractions不支持无参数就验证所有模拟对象的功能,否则这段代码会变得更简洁。
5.4.3. 必须使用明确语义的参数值或匹配器
验证依赖方法时,必须使用明确语义的参数值或匹配器,不能使用任何不明确语义的匹配器,比如:any系列参数匹配器。
正例:
反例:
后记
最后,根据本文所表达的观点,即兴赋诗七言绝句一首:
《单元测试》
单元测试分真假,
工匠精神贯始终。
覆盖追求非目的,
回归验证显奇功。
意思是:
一定要知道如何去分辨单元测试的真假,
一定要把工匠精神贯彻单元测试的始终。
追求单测覆盖率并不是单元测试的目的,
回归验证代码才能彰显单元测试的功效。