平时我们写代码,免不了要进行一些测试,如果没有使用单元测试,对于简单的程序,我们可以写一个main方法,调试查看指定的方法是否符合预期;对于一个服务系统,我们可以使用PostMan等工具来模拟一下真实请求,查看输入输出是否符合预期,这些方法粗暴简单,但是存诸多问题。
很难覆盖所有业务逻辑代码,而且无法统计覆盖率。
无法自动化重复测试,每次都需要人工调用,或者依赖外部工具,效率低下。
如果测试的功能依赖过多其他模块或者依赖外部系统,此时测试难度大。
测试代码和功能代码混合在一起,将带来代码混乱和安全上的问题(如一些测试使用的HTTP后门接口即为不安全)。
而单元测试正是为解决上述问题而生,对于代码中的大多数Bug,单元测试阶段是最容易被发现的。如果没有进行单元测试,那么后期发现这些Bug的周期会越来越长,修复的成本越来越高。写单元测试确实会占用编码的时间,甚至有些情况下写单元测试的时间会比写业务逻辑代码的时间还要长。但是:
单元测试保证测试的高覆盖率:单元测试是所有测试中最底层的一类测试,是第一个环节,也是最重要的一个环节。
单元测试可以降低软件开发的成本:来自微软的统计数据:bug在单元测试阶段被发现,平均耗时3.25小时,如果漏到系统测试阶段,要花费11.5小时。而且85%的缺陷都在代码设计阶段产生,而发现bug的阶段越靠后,耗费成本就越高,指数级别的增高。
单元测试可以自动化地重复进行测试:这在代码重构时很重要,因为代码重构可能会难免涉及到代码的改动,导致代码逻辑可能和最终需求不一致的问题,但是如果有自动化且可以快速的重复进行测试,并发现问题,将会大大提高重构代码的安全性。
规范代码提升代码质量:想要写出更容易测试的业务代码,需要在满足业务需求的基础上,合理的设计代码结构和规范,换句话说,单元测试促进了代码质量的提高。
Bug在单元测试阶段被发现,平均耗时3.25小时,如果漏到系统测试阶段,要花费11.5小时。 |
85%的缺陷都在代码设计阶段产生,而发现Bug的阶段越靠后,耗费成本就越高,指数级别的增高。 |
功能编码只是软件开发的一小部分工作,为了正确性,测试编码也是必不可少的。下表对单元测试、集成测试和功能测试概念和适用场景简单进行比较。
测试类型 |
概念 |
单元测试 | 又称模块测试,是针对软件设计的最小单位——程序模块进行正确性检验的测试工作。其目的在于检查每个程序单元能否正确实现详细设计说明中的模块功能、性能、接口和设计约束等要求,发现各模块内部可能存在的各种错误。单元测试需要从程序的内部结构出发设计测试用例,多个模块可以平行地独立进行单元测试。当前被测试的模块外模块都能被mock/stub。 |
集成测试 | 在单元测试的基础上,将所有的程序模块进行有序的、递增的测试。集成测试是检验程序单元或部件的接口关系,逐步集成为符合概要设计要求的程序部件或整个系统。 |
功能测试 | 假设整个系统是一个黑匣子,从用户界面(或API)开始测试端到端的交互。例如,测试系统自动打开浏览器,通过模拟网页上的按钮点击来选择商品,下单,输入信用卡等动作,并期望在屏幕上“看到”发货订单的跟踪号。 |
下图表示了单元测试、集成测试、功能测试之间的关系。
三类测试关注的方向不同,并且在项目的软件生命周期中需要有不同的调整,下表述了不同测试之间的区别。
单元测试 | 集成测试 | 功能试验 | |
试验范围 | 单个Java类 | 单个模块或多个类 | 整个系统 |
测试重点 | 正确性Java类 | 类通信、事务、日志、安全等等 | 端到端用户体验 |
结果取决于 | Java代码 | Java代码、文件系统、网络、数据库、其他系统 | Java代码、文件系统、网络、数据库、其他系统,GUI、API端点 |
稳定性 | 非常稳定 | 可能脱离环境变化 | 非常脆弱(一个微不足道的GUI更改可能会破坏它) |
失败的测试意味着 | 倒退 | 不是倒退就是环境变化 | 回归,环境改变,图形用户界面改变 |
设置所需代价 | 最小 | 中等(可能需要外部系统) | 高(需要一个正在运行的副本系统) |
修复所需的工作量 | 最小 | 中等(多个类可能有bug) | 中/高(错误可能在应用程序的任何层中) |
所需工具 | 测试框架 | 测试框架、容器、数据库和外部服务 | 专门的,有时是专有的外部工具,一个暂存系统 |
Mock/Stub | 需要时使用 | 很少使用 | 很少使用 |
运行单个测试的时间 | 毫秒 | 秒 | 秒或分钟 |
运行该类型的所有测试的时间 | 最多5分钟 | 可能是几个小时 | 可能是几个小时 |
测试时机 | 每次提交后自动 |
在不同的预定时间间隔自动 |
发布前自动/手动 |
涉及人员 | 研发 | 研发、测试 | 研发、架构师、测试、分析师、客户 |
如右图测试金字塔所示,根据经验,70%的测试应该是纯单元测试,20%是较慢的集成测试,10%是功能测试,本文后续内容限定对单块测试的讨论展开。 |
当前已经有很多成熟的测试框架,Spock是其中之一。Spock 框架是一个基于Groovy语法的测试框架,而Groovy是基于Java虚拟机的一种敏捷的动态语言,是成熟的面向对象编程语言,既可以用于面向对象编程,又可以用作脚本语言,Groovy给Spock带来了非常大的灵活性,下面对Groovy和Spock加以介绍。
Groovy具备动态语言(如Python或Ruby)的特性,它没有Java那么多的限制,但同时,它运行在JVM中,又可以利用所有Java库。Groovy语法是Java语法的超集,几乎所有的Java代码(除了一些小的例外)同时是有效的Groovy代码。Groovy开发工具包或GDK(www.Groovy-lang.org/GDK.html)是JDK的增强版,以下为Groovy语言的一些特点:
Groovy是一种动态语言(Java是静态的)
是强类型语言(与Java相同)
是面向对象的(与Java相同)
附带GDK(Java有JDK)
在JVM上运行(与Java相同)
支持简洁的代码(与Groovy相比,Java被认为是冗长的)
提供自己的库(例如,web和ORM框架)
可以按原样使用任何现有的Java库(Java也可以调用Groovy代码)
有闭包(Java8有lambda表达式)
支持duck类型(Java有严格的继承)
Groovy源代码被编译成Java字节码。
使用Groovy代码中的Java类时,new关键字与Java完全相同。
Groovy主要与Java语法兼容。
在Groovy中,类默认是公共的,字段默认是私有的。
Groovy自动生成getter和setter。
在Groovy中,分号和return关键字是可选的。
Groovy支持可选类型:可以声明变量的类型(如在Java中)或使用def关键字将其留给运行时。
Groovy将所有对象都视为true,除非对象是空字符串、空集合、0、null或false。
Groovy允许您通过在构造函数中使用字段/值的映射来创建对象。
Groovy字符串采用自动模板,类似于JSTL。
Groovy附带了大量的实用程序,可以读取XML和JSON文件。
Groovy支持闭包,闭包可以用来减少assert语句中的代码行。
ObjectGraphBuilder可用于快速创建业务域的树状结构。
可以使用Expando在运行时动态创建Groovy对象。
Spock是基于BDD(Behavior Driven Development行为驱动开发)思想实现的、优秀的测试框架,它可以将枯燥、重复、人工的软件测试工作自动化,功能非常强大。Spock结合Groovy动态语言的特点,提供了各种标签,并采用简单、通用、结构化的描述语言,让编写测试代码更加简洁、高效。
Spock是Java:JUnit测试框架(http://junit.org/)的超集,Spock还为通常需要额外库的特性提供了内置功能。Spock的核心是一个能够处理软件应用程序整个生命周期的测试框架,右图表示了几个主要的测试框架之间的关系。 |
下表对Spock和Junit这两个场景的测试框架着重进行比较,对各自的特点、优缺点进行阐述。
Spock |
Junit |
|
特点 |
||
优点 |
|
Spock可以做JUnit所做所有事情,还可以做Junit不能够做的事情,所以比较起来无突出优点。 |
缺点 |
/ |
|
总结 |
1. Spock采用了一种整体的方法,提供了JUnit功能的超集,同时重用了它与工具和开发环境的成熟集成,保持测试运行程序的向后兼容性。 2. Spock是用Groovy编写的,它没有Java那么冗长。Spock测试比各自的JUnit测试更简洁。更少的代码更易于阅读、调试和长期维护。 |
为了更好了解Spock的使用,我们首先通过一个具体的业务场景走进Spock.
待测试系统 |
场景 |
假设我们要对一个“消防系统”进行测试:处理单元连接到多个火灾传感器,并连续轮询它们以获取异常读数。发现火灾时,警报声响起。如果火势开始蔓延并且触发了另一个检测器,则会自动呼叫消防队。 (1)如果所有传感器均报告无异常,则表明系统正常,无需采取任何措施。 (2)如果触发了一个传感器,则会发出警报声。 (3)如果触发了多个传感器,则会呼叫消防队。 |
面对这个“消防系统”的测试需求,我们使用Spock来描述测试场景,对测试问题进行抽象描述。
待测试代码 |
测试代码 |
以上,Spock通过提供规范性的描述,定义多种标签(given
、when
、then
、where
等),去描述代码“应该做什么”,“输入条件是什么”,“输出是否符合预期”,从语义层面规范了代码的编写。
在实际生产中,我们不可避免需要面对更加复杂的系统。下面我们引入一个场景:假设我们正在测试的应用程序是一个核反应堆的监控系统,它的功能类似于上面所说的消防系统,但有更多的输入传感器,系统如下图所示,系统的组成如下:
多个火灾传感器(输入)
三个辐射传感器处于临界点(输入)
当前压力(输入)
报警(输出)
疏散命令(输出)
通知操作人员反应堆应该关闭(输出)
待测试系统 |
场景 |
该系统已经按照核反应堆所需的所有安全要求实施,它定期读取传感器数据,并根据读数发出警报或建议采取纠正措施。以下是一些要求: (1)如果压力超过150 bars,警报就会响起。 (2)如果触发两个或多个火灾警报,警报响起,并通知操作员需要停机(作为预防措施)。 (3)如果检测到辐射泄漏(来自任何传感器的辐射超过100rads),警报响起,宣布反应堆应在下一分钟内撤离,并向操作人员发送需要关闭的通知。 |
通过核反应堆的技术专家咨询,确定至少要检查12个测试场景,如下表所示。
样本输入 | 预期产出 | ||||
电流压力 | 火灾传感器 | 辐射传感器 | 声音报警器 | 需要关机 | x分钟内撤离 |
150 | 0 | 0, 0, 0 | 不 | 不 | 不 |
150 | 1 | 0, 0, 0 | 是的 | 不 | 不 |
150 | 3 | 0, 0, 0 | 是的 | 是的 | 不 |
150 | 0 | 110.4,0.3, 0.0 | 是的 | 是的 | 1分钟 |
150 | 0 | 45.3,10.3, 47.7 | 不 | 不 | 不 |
155 | 0 | 0, 0, 0 | 是的 | 不 | 不 |
170 | 0 | 0, 0, 0 | 是的 | 是的 | 3分钟 |
180 | 0 | 110.4,0.3, 0.0 | 是的 | 是的 | 1分钟 |
500 | 0 | 110.4,300, 0.0 | 是的 | 是的 | 1分钟 |
30 | 0 | 110.4,1000, 0.0 | 是的 | 是的 | 1分钟 |
155 | 4 | 0, 0, 0 | 是的 | 是的 | 不 |
170 | 1 | 45.3、10.f、47.7 | 是的 | 是的 | 3分钟 |
表中列出的场景是参数化测试的典型示例:测试逻辑总是相同的(接受这三个输入,期望这三个输出),测试代码只需要为这个单一的测试逻辑处理不同的变量集。本例中,我们有12个场景和6个变量。面对这个场景,最直接方法是编写12个单独的测试。那么问题来了:测试代码重复,而且因为未来的维护困难。如果在系统中添加了一个新变量(例如,一个新的传感器),则必须同时更改所有12个测试。因此需要更好的方法,最好是将测试代码(应该编写一次)与测试数据集和预期输出(应该为所有场景编写)分离。这种测试需要一个明确支持参数化测试的框架。Spock内置了对参数化测试的支持,它有一个友好的DSL语法,专门用于处理多个输入和输出。同样,我们使用Spock来描述测试场景,对测试问题进行抽象。
在处理复杂系统的测试的时候,除了需要面对系统的多输入问题,我们还需要面临被测试系统依赖复杂的问题,我们对一个系统进行测试的时候,所依赖的系统可能还在设计和实现中,或者我们没有操作所依赖系统的权限。
待测试系统 |
场景 |
左图以核反应堆监控系统的一部分系统为例: (1)假设我们正在对一个温度监控器进行测试,被测系统温度监测器不直接与温度传感器通信,它从另一个Java系统温度读取器(由不同的软件公司实现)获取读数。 (2)对温度监视器的要求表明,如果温度读数(上升或下降)的差值大于20度,则应发出警报。 |
在现实世界中,系统很少能够轻松构建预期的输入输出,因此我们需要进行将待测试的模块从复杂的系统中隔离出来,对外部依赖的输入输出进行伪造。下面分别介绍Stub和Mock两种方式。
Stub方式对输入进行伪造:
待测试代码 |
测试代码 |
Stub对输入进行伪造 |
和输入进行伪对应,对输出进行伪造也是一项重要的测试技术。
待测试系统 |
场景 |
如右图,反应堆的温度控制是系统核心任务,是完全自动化。 (1)如果温差超过50度,就会自动关闭反应堆。 (2)如果温度差异超过20度,警报仍然会响起,但在这种情况下,反应堆不会关闭,从而允许其他系统采取纠正措施。 (3)关闭反应堆和拉响警报是通过一个外部Java库(我们无法控制它)完成的,该库是作为一个简单的API提供的,被测试系统现在也注入了这个外部API。 |
Mock方式对输入进行伪造:
待测试代码 |
测试代码 |
Mock对输出进行伪造 |
上文主要是对一些测试概念和思想进行介绍,下面开始进行一些实践,通过一些小case加深对Spock框架的理解,体会其便利和高效。
Spock是Java栈的一个测试框架,在Java项目配置Spock只需要在pom文件中引入以下文件,开箱即用,无需复杂操作。
org.springframework.boot
spring-boot-starter-test
test
org.spockframework
spock-core
1.2-groovy-2.4
test
org.codehaus.groovy
groovy-all
2.4.15
org.spockframework
spock-spring
1.2-groovy-2.4
test
下面就一个整型的加法器进行测试,展示Spock测试基本语法,测试类继承 Specification,并分为given,when,then三个部分,在given部分我们确定条件,在when部分进行调用,在then部分进行判断。
待测试代码 |
测试代码 |
Spock block |
描述 |
given: |
输入条件(前置参数) |
setup: |
用于定义变量、准备测试数据、构建mock对象等; |
when: |
在这个块中调用要测试的方法; |
then: |
一般跟在when后使用,尽可以包含断言语句、异常检查语句等等,用于检查要测试的方法执行后结果是否符合预期; |
and: |
衔接上个标签,补充的作用。 |
expect: |
一般跟在setup块后使用,包含一些assert语句,检查在setup块中准备好的测试环境 |
where: |
预期结果 |
cleanup: |
清除 |
单元测试的含义在于测试一个单元,测试仅限于某个方法内部的代码。如果这写代码中依赖了外部的服务,或者其他人还未写好的接口,我们不能保证外部服务的可用性,也同样不能等另一个人写好接口后再测试,因此需要通过模拟被调用的外部对象的行为和数据,从而保证单元测试的顺利进行。
在3.3.3节介绍了Stub和Mock的使用场景,下表对二者进行总结和对比。
分类 |
详情 |
Stub |
提供测试的条件 |
Mock |
模拟测试的结果 |
注:事实以上二者很容易混淆
4.1.5 静态方法测试
Spock扩展第三方PowerMock对静态方法进行测试。Spock的单元测试代码继承自Specification
基类,而Specification
又是基于JUnit的注解@RunWith()
实现的,PowerMock的PowerMockRunner也是继承自JUnit,所以使用PowerMock @PowerMockRunnerDelegate()注解,可以指定Spock的父类Sputnik去代理运行PowerMock,这样就可以在Spock里使用PowerMock去模拟静态方法、final方法、私有方法等。其实Spock自带的GroovyMock可以对Groovy文件的静态方法Mock,但对Java代码支持不完整,只能Mock当前Java类的静态方法。
待测试代码 |
测试代码 |
Spock除了能够做单元测试,对集成测试也有很好的支持。如我们在测试的时候需要同数据库或者外界系统(接口、mq等)进行交互,Spock也能够胜任这些测试工作,下表展示基于Spock对数据库操作的测试。
Spring context | Groovy SQL |
Spock测试本身可以 self-documenting. 测试的名字可以使用句子命名(支持中文),因此我们在测试函数命名的时候即可以描述清楚测试的需求。
测试方法检查多个场景,其中测试逻辑总是相同的,并且每次只有输入和输出变量更改。测试代码是固定的,而测试输入和输出数据以参数的形式出现。所有参数共享测试代码。不是为每个场景复制这个公共代码,而是将它集中在一个测试方法上。下图所示为一个加法器的参数化测试案例。
使用Where块
def "测试加法工具"(){
given: "加法"
Adder adder = new Adder()
expect: "计算两数之和判断是否相等"
adder.add(first, second) == sum
where: "以下 "
first | second || sum
1 | 1 || 2
3 | 2 || 5
}
使用数据管道计算输入/输出参数
def "测试加法工具"(){
given: "加法"
Adder adder = new Adder()
expect: "计算两数之和判断是否相等"
adder.add(first, second) == sum
where: "以下 "
first << [1, 2, 3]
second << [4, 5, 6]
sum << [5, 7, 9]
}
使用数据生成器:可以编写java程序,批量产生数据:如生成随机数,读取Excel文件等构造测试参数。
使用第三方数据生成器:如https://github.com/Bijnagte/spock-genesis
我们在实际编写单元测试的时候,为了保证测试的有效性,是需要通过一些测试规范来对测试过程进行约束。在生产过程中我们不能够为了写单测而写单测,如果忘掉“测试有效性”的初衷,写再多的单元测试,盲目刷高覆盖率的同时必将丧失单元测试的意义。下表展示了一些单元测试的规范,当然也是使用Spock进行单元测试需要遵循的规范。
序号 |
规范 |
详情 |
1 |
不要为了覆盖率而做单元测试 | 单元测试不仅仅是为了达到覆盖率统计,更重要的是验证业务代码的正确性、健壮性、逻辑的严谨性以及设计的合理性。 |
2 |
不要总想着“补”测试 | 测试代码并不比普通代码地位低,选择事后补测试,将牺牲掉用测试来驱动代码设计的机会。在编写代码时同步编写单元测试,才能更好的发挥出单元测试的能力。 |
3 |
注重测试有效性 | 执行void方法的时候因为没有返回值,偷懒的做法使用 1==1, 等无效单测 (1)一般来说无返回值的方法,内部逻辑会修改入参的属性值,比如参数是个对象,那代码里可能会修改它的属性值,虽然没有返回,但还是可以通过校验入参的属性来测试 void 方法。 (2)还有一种更有效的测试方式,就是验证方法内部逻辑和流程是否符合预期,比如: a. 应该走到哪个分支逻辑? b. 是否执行了这一行代码? c. for 循环中的代码执行了几次? d. 变量在方法内部的变化情况? |
4 | 注重代码可测试性 | 一条真理:难测试的代码就是烂代码 |
5 | 尽量不要跨层测试 | 如果当前被测方法依赖了其他层或module的逻辑,最好mock掉,尽量不要跨层测试,这属于功能测试或集成测试的范畴。 |
6 |
注重测试代码结构 | Java单测和用Spock写的单测代码独立编写,Spock单测文件建议名使用*Spec命名。测试代码文件目录结构尽量同被测试代码保持一致。 |
7 |
遵守 BCDE 原则 | (1)B:Border,边界值测试,包括循环、 特殊取,边界值测试包括循环、 特殊取特殊时间点、数据顺序等。 (2)C:Correct,正确的输入,并得到预期结果。,正确的输入并得到预期结果。 (3) D:Design,与设计文档相结合,来编写单元测试。,与设计文档相结合来编写单元测试。 (4) E:Error,强制错误信息输入(如:非法数据、异常流程业务允许等),并得 ,强制错误信息输入(如:非法数据、异常流程业务允许等),并得到预期结果。 |
8 |
确保100%运行成功 | 不应该忽略单元测试失败的用例,尤其历史版本的单元测试报错时,极有可能是当前变更破坏了原始逻辑,当然不排除原测试代码不够健壮。总的来说一旦报错,一定要求根问底进行定位并解决! |
9 |
不推荐在线上环境执行单元测试 | 在线上环境package的时候应该通过配置命令跳过单元测试。 |
10 |
谨记单元测试存在局限性 | 单元测试永远无法完全证明代码的正确性! |
4.4.1 覆盖率
Jacoco是统计单元测试覆盖率的一种工具,当然Spock也自带了覆盖率统计的功能,这里使用第三方Jacoco的原因主要是国内公司使用的比较多一些。使用Jacoco只需要在pom文件里引用Jacoco的插件:jacoco-maven-plugin,然后执行mvn package 命令即可,执行成功后会在target目录下生成单元测试覆盖率的报告,点开报告找到对应的被测试类查看覆盖情况。配置文件见:https://github.com/damaohongtu/dag-demo/blob/main/dag-backend/pom.xml,效果如下。
项目 | 详情 |
配置 | 步骤1:在pom中引入jacoco插件 步骤2:执行mvn test |
单测报告 | 以上,新增的单侧覆盖率为10%. |
覆盖率详情 | 以上,可以查看单个文件的覆盖情况。 |
4.4.2 研发流程规范
在团队共同开发时,我们除了通过默认约定控制单元测试的落地外,更有效的方式是将单元测试融入到测试流程中,限制单元测试是不可或缺的环节,在研发流程进行卡点。Jekins是基于一个基于Java开发的一种持续集成工具,我们可以通过Jenkins流水线来编排我们的发布流程,如下图,我们将单元测试作为研发流程中的一个stage.
下面的视频是一个Jenkins+Jacoco展示Spock单元测试覆盖率的教程。项目https://github.com/damaohongtu/dag-demo/tree/main/dag-backend 中提供了单测编写的示例,持续更新中,若对你有帮助烦请star.
Jenkins+Jacoco展示Spock单元测试覆盖率
单元测试无疑能够提高代码质量,间接能够提高开发效率。Spock为单元测试带来了诸多便利,且接入成本极低,在Java栈项目中已经被广泛使用。在生产环境中,团队除了通过默认约定推行单元测试落地,还可以通过水线卡点的方式强制约束,保证新增代码均被单元测试覆盖。在做单元时候,研发人员应该保持“为什么做单测的初心”,坚守单元测试的规范,如果仅仅追求单测覆盖率,那么单元测试本身将毫无意义。
[1] Groovy语言官网:http://www.groovy-lang.org/
[2] Spock官网:https://spockframework.org/
[3] Spock案例:https://github.com/spockframework/spock-example
[4] Jacoco插件:https://mvnrepository.com/artifact/org.jacoco/jacoco-maven-plugin
[5] Jenkins官网:https://www.jenkins.io/
[6] Kapelonis, K. . (2016). Java Testing with Spock. Manning Publications Co.
[7] 奥西洛夫金迎. (2014). 单元测试的艺术 : The art of unit testing: with examples in C#. 人民邮电出版社.