想要快速实践的朋友可以跳转到第三或四部分
一、背景与初衷
单元测试是软件开发过程中的一个重要环节,它能为我们带来不少的收益:
- 早期发现缺陷:通过在开发早期阶段编写和运行单元测试,可以更早地发现和修复错误,这通常比在后期发现和修复要成本低得多。
- 提高代码质量:单元测试有助于确保代码在修改或扩展时仍然按预期工作,从而提高整体代码质量。
- 文档化功能:单元测试可以作为代码的一种文档,展示函数或方法应该如何使用以及它们的预期行为。
- 简化代码维护:良好的单元测试可以减少对代码的恐惧,使得开发者更愿意进行必要的重构和维护工作。
- 促进设计:编写单元测试往往需要代码具有良好的设计,这促使开发者采用更清晰、更模块化的架构。
- 自动化回归测试:单元测试可以自动化运行,确保在后续开发中不会引入新的错误。
- 提高开发效率:虽然编写测试可能会增加初期的工作量,但长远来看,它可以减少调试时间,提高开发效率。
- 增强团队信心:当有一套完整的单元测试时,团队对代码的稳定性和可靠性更有信心,这有助于提高团队士气。
- 易于集成新成员:对于新加入项目的开发者,单元测试提供了一个快速了解现有代码行为和预期的方式。
- 减少生产环境中的错误:通过在开发过程中捕获错误,可以减少软件发布到生产环境后出现的问题。
虽然单元测试带来的收益有目共睹,但是现实中许多同学往往对单测编写望而却步,这其中一部分原因是历史代码设计不合理、逻辑复杂,难以编写单测;还有相当多一部分原因是基于现有的JUnit、Mockito等框架编写单测的效率太低,需要编写很多“样板代码”,耗费相当多的时间。
所谓“工欲善其事,必先利其器”,本文将向大家介绍一款能够极大提升我们单测编写效率的测试框架——Spock。使用Spock可以极大提升单测的编写效率、可读性和可维护性。
本文将结合Spock框架的实践经验向大家介绍Spock最实用的知识点,帮助大家快速掌握Spock,并应用到自己的项目中去。
二、Spock简介&Spock优势
首先,让我们来了解下什么是Spock,下面是Spock官网对Spock的介绍:
“Spock is a testing and specification framework for Java and Groovy applications. What makes it stand out from the crowd is its beautiful and highly expressive specification language. Thanks to its JUnit runner, Spock is compatible with most IDEs, build tools, and continuous integration servers. Spock is inspired from JUnit, jMock, RSpec, Groovy, Scala, Vulcans, and other fascinating life forms.”
总结下来,Spock的核心要点如下:
- 适用于Java应用:Spock是一个针对Java和Groovy应用的测试框架。
- 语法优雅强大:使得Spock从诸多测试框架中脱颖而出的特点是其优雅和极富表达性的语法,这是因为它基于Groovy语言,并且封装了许多强大的语法糖。
- 兼容性:Spock使用JUnit runner运行测试,这使得它和大多数IDE、构建工具和持续集成服务兼容。
- 博采众长:Spock结合了JUnit, jMock, RSpec, Groovy, Scala, Vulcans等框架和语言的优点。
接下来,让我们具体来看看Spock相比其他测试框架到底有哪些优势,为什么使用Spock能提升我们编写测试代码的效率。
优势
详细描述
一站式的测试解决方案
Spock的第一个优势是它是一个一站式的测试解决方案,仅需引入Spock这一个依赖就可以完成测试、Mock、断言、交互次数验证、BDD等测试任务。
你可以理解为:Spock = JUnit + Mockito + AssertJ + Cucumber + …
测试方法的结构清晰
Spock测试基于BDD风格,测试方法被分为一个个block,不同block负责不同的职责,结构清晰。
这些block中,最常用的也许就是“given-when-then”的组合了:
- given:准备测试环境。
- when:调用被测方法。
- then:验证测试结果。
测试可读性高
测试方法名和步骤均可用自然语言描述,使得测试方法和测试运行结果更容易被理解,好的测试甚至可以作为代码的文档。
语法简洁强大
一方面,Spock测试基于Groovy语言,可以使用Groovy简洁强大的语法。
另一方面,Spock也额外封装了一些语法糖,让Mock、参数化测试等任务更简单。
提供更多的错误上下文
Spock的断言基于Groovy里面的Power Assert特性,能够在断言失败时提供更多的错误上下文信息,方便我们定位问题根因。
三、Groovy语法快速入门
上面介绍过了,Spock测试基于Groovy语言,要想写出简洁强大的Spock测试,有必要学习一些Groovy语法。本节将编写Spock测试的过程中常用的一些Groovy语法总结到了下面的表格:
Groovy语法点
详细描述
1
和Java语言的互操作性
Groovy支持和Java的互操作性,主要体现在:
- Groovy中可以操作Java文件定义的类
- Groovy中可以使用Java语法
所以说,在编写Spock测试时我们也是可以使用Java语法的,只不过写出来的测试可能没有那么简洁。为了写出简洁的Spock测试,最好了解一些Groovy语法。
2
Groovy可以省略很多
Groovy可以省略很多,包括:权限修饰符、getter/setter、分号(;)、return关键字、方法调用时的括号、main方法声明...
下图所示是同一个类在Java和Groovy中的实现。
3
字符串插值
字符串中可以使用 ${} 符号访问变量
4
数值类型默认是BigDecimal
在Groovy中,我们书写的数值字面量默认会被Groovy视作BigDecimal类型,例如下面两个assert语句在Groovy中都是会成功的。这一点使得我们在Groovy使用BigDecimal更加简洁,无需显式通过new BigDecimal(),或BigDecimal.valueOf()等创建BigDecimal。
5
闭包
闭包类似Java中的Lambda,是一个“方法指针”,用一对 “{}”声明。Groovy闭包的代码示例如下:
6
集合操作
List:使用方括号语法声明([])
Map:也是使用方括号声明([]),键值对的形式定义map元素
7
创建对象(map-based constructor)
如下图所示,左侧是Java中通过setter创建对象的方式,右侧是Groovy中通过map-based constructor语法创建对象的方式,该语法支持通过类似map的键值对语法来设置对象属性值,更加简洁。
8
读写对象属性
在Java中我们可以通过getter/setter来读写对象属性,在Groovy中可以更简洁的实现该任务,直接用 "对象.字段名" 的语法即可,如下图所示:
9
equals和==
在Groovy中,==等价于equals。
例如下图是同一个条件语句在Java和Groovy中的写法,判断state的值是不是State.COMPLETED:
因此,在对对象进行断言时,我们可以不用equals,直接用==,使断言看起来更清晰。
10
任何值都可以被转为boolean类型
在Groovy中,任何值都可以被转成boolean类型!
11
Power Assert
Groovy内置的断言功能强大,错误上下文提示详细,被叫做“Power Assert”。Spock的断言基于Groovy的Power Assert。
四、Spock实践学习
4.1 如何运行第一个Spock测试
下面通过一个简单的例子,来讲解如何在你的工程中运行第一个Spock测试。
步骤
详细说明
1
引入Spock依赖
在pom.xml文件中引入如下依赖
<!-- spock单元测试框架 -->
<!--引入 groovy 依赖-->
<dependency>
<groupId>org.codehaus.groovy</groupId>
<artifactId>groovy-all</artifactId>
<version>2.4.15</version>
<scope>test</scope>
</dependency>
<!--引入spock 与 spring 集成包-->
<dependency>
<groupId>org.spockframework</groupId>
<artifactId>spock-spring</artifactId>
<version>1.2-groovy-2.4</version>
<scope>test</scope>
</dependency>
2
新建测试目录和测试类
测试目录:推荐放在 src/test/groovy 目录下,和JUnit测试类区分。
测试类:推荐 XxxSpec 格式命名。
3
运行测试
运行结果如下:
4.2 Spock测试类的结构
本小节将介绍Spock测试类的结构,学完后,你将知道如何编写一个Spock测试类。
class XxxSpec extends Specification {
// fields
// fixture methods
// feature methods
// helper methods
}
上面是一个Spock测试类的基本结构,要点如下:
- 测试类需要继承自Spock内置的 spock.lang.Specification 类,推荐以 “XxxSpec” 的格式命名。
- 测试类的主体包含 fields、fixture methods、feature methods、helper methods 4部分,其中只有feature methods(测试方法)是必须的,其他都是可选的。下面将会一一介绍这4部分。
4.2.1 Fixture Methods
Spock中的Fixture Methods等价于JUnit中的@Before、@After、@BeforeClass、@AfterClass注解的功能。
Spock中支持的Fixture Method如下:
def setupSpec() {} // runs once - before the first feature method
def setup() {} // runs before every feature method
def cleanup() {} // runs after every feature method
def cleanupSpec() {} // runs once - after the last feature method
- setupSpec:相当于JUnit中的@BeforeClass。用于准备多个测试方法可以共享的上下文。例如数据库链接。
- setup:相当于JUnit中的@Before。用于准备那些不同测试方法之间需要相互隔离的上下文。例如mock对象不能被不同测试方法共享,否则会导致不同测试方法的mock逻辑相互影响,正确的做法是针对每个测试方法单独创建一个mock对象。
- cleanup:相当于JUnit中的@After。用于清理那些不同测试方法之间需要相互隔离的上下文。
- cleanupSpec:相当于JUnit中的@AfterClass。用于清理共享上下文,例如释放数据库链接。
4.2.2 Fields
即测试类的字段,用于定义一些测试类中用到的字段,例如被测对象、Mock对象、公共的常量、共享的对象等等。
Spock测试类中的字段可以分为3类:实例字段、共享字段 和 静态字段。
字段类型
说明
1
实例字段
非共享、非静态的字段,即没有加@Shared注解或是static修饰符的字段。例如下面的orderDAO、orderService都是实例字段。
Spock实例字段的一个特殊点就是,在执行测试类的每个测试方法前都会对实例字段进行一次初始化。
以上面的代码中的orderDAO为例,执行每个测试方法前都会执行一下初始化语句 orderDAO = Mock(),也就是不同测试方法使用的是不同的mock对象,互不干扰。
所以说,上面代码中实例字段的初始化方式等价于使用setup对实例字段进行初始化:
2
共享字段
使用@Shared注解标记的字段,该字段被所有测试方法共享。
Spock共享字段的一个特殊点就是,仅在执行测试类的第一个测试方法前会对静态字段执行一次初始化。
所以说,上面代码中共享字段的初始化方式等价于使用setupSpec对共享字段进行初始化:
3
静态字段
用于定义一些常量,例如:
4.2.3 Feature Methods
Feature Methods即测试方法,使用Groovy的def关键词来定义测试方法:
def "pushing an element on the stack"() {
// blocks go here
}
根据Spock官方的定义,一个测试方法包括如下4个阶段(phase):
- Setup:Set up the feature’s fixture 准备测试数据
- Stimulus:Provide a stimulus to the system under specification 调用被测方法
- Response:Describe the response expected from the system 验证返回结果
- Cleanup:Clean up the feature’s fixture 清理测试数据
这4个阶段分别由不同的block实现,例如given block用于准备测试数据、when block用于调用被测方法等等。
下面介绍Spock中最常用的5个block:
block名称
功能
用法
代码示例
1
given
准备测试数据和测试环境
- 必须出现在测试方法的最前面。
- 只能有一个。
- 该block没有特殊的语义,given关键字可以省略。
given: "空的栈"
def stack = new Stack()
and: "要往栈里面push的元素"
def elem = "push me"
2
when/then
when用于调用被测方法
then用于验证结果(断言、是否发生异常、交互次数...)
when/then总是一起出现。
一个测试方法中,when/then组合可以出现任意多次。
then里面的断言语句,可以省略assert关键字。
when:
stack.push(elem)
then:
!stack.empty
stack.size() == 1
stack.peek() == elem
then除了基本的断言外,还能进行异常断言、验证交互次数:
when:
stack.pop()
then:
def e = thrown(EmptyStackException)
e.cause == null
then:
1 * subscriber1.receive("event")
1 * subscriber2.receive("event")
3
where
提供参数化测试的参数
- 必须出现在测试方法的最后。
- 不能重复出现,即只能出现一次。
- 提供参数化测试数据的形式有data table、data pipe、表达式等等,后文会详细介绍。
- 使用@Unroll注解可以展开打印每个参数case的执行结果。
4
and
and用于将其他block划分为若干个子块,增强测试代码的条理性和可读性
- 和其他block搭配,将其他block划分为若干个子块
想要了解全部block,可以参考Spock的官方文档:https://spockframework.org/spock/docs/2.3/all_in_one.html#_blocks
4.2.4 Helper Methods
顾名思义,Helper Methods就是工具方法,例如我们可以将一些公共的逻辑、构造对象的逻辑封装到这类方法里。
4.3 Spock的Mock功能用法
4.3.1 创建指定类型的mock对象
def subscriber = Mock(Subscriber)
// 或
Subscriber subscriber = Mock()
4.3.2 mock方法返回值
Spock中,可以使用 “>>” 符号mock返回值,该符号右边的值即为mock的返回值。
given:
subscriber.receive(_) >> "ok"
这里涉及两个知识点:
mock返回值:使用 “>>” 符号,该符号右边的值即为mock的返回值。
参数匹配:receive方法使用 “_” 符号进行参数匹配,该符号能匹配任意参数,包括null。关于参数匹配的更多语法,后文会介绍。
4.3.3 验证mock对象交互次数
then:
1 * subscriber.receive(_)
这里涉及两个知识点:
验证交互次数:使用 “*” 符号,该符号左边的值为预期调用次数,右边的值为目标方法。
参数匹配:同上。关于参数匹配的更多语法,后文会介绍。
下面代码展示了更详细的交互次数验证用法:
1 * subscriber.receive("hello") // 1次调用
0 * subscriber.receive("hello") // 0次调用
(1..3) * subscriber.receive("hello") // 1至3次调用
(1.._) * subscriber.receive("hello") // 至少一次调用
(_..3) * subscriber.receive("hello") // 至多3次调用
_ * subscriber.receive("hello") // 任意次调用
4.3.4 既mock返回值,又验证交互次数
这一点需要注意,在Spock中,如果对一个mock对象既要mock返回值,又要验证交互次数的话,需要将这两个操作写在同一行,否则验证不会生效。
如下图所示,正确的写法应该是右边的写法。
4.3.5 mock抛异常
mock方法抛异常时需要用到Groovy的闭包语法:
when:
subscriber.receive(_) >> { throw new RuntimeException()} // mock抛RuntimeException
4.3.6 mock多次调用返回不同的值
// 1.每次调用都返回"ok"
subscriber.receive() >> "ok"
// 2.第一次调用返回ok, 第二次及以后的调用返回"error"。下面两种写法等价。
subscriber.receive() >> "ok" >> "error"
subscriber.receive(_) >> ["ok","error"]
// 3.上面两种用法混合
subscriber.receive(_) >> ["ok", "error"] >> "aaa" >> "bbb"
4.4 Spock的参数匹配功能用法
上面我们看到了,Spock在Mock和验证交互次数的时候都需要进行参数匹配,下面来看一下Spock参数匹配的语法
1 * subscriber.receive("hello") // 匹配等于"hello"的参数
1 * subscriber.receive(!"hello") // 匹配不等于"hello"的参数
1 * subscriber.receive() // 匹配没有参数的调用
1 * subscriber.receive(_) // 匹配任意单个参数, 包括null
1 * subscriber.receive(*_) // 匹配任意参数列表, 包括空参数列表
1 * subscriber.receive(!null) // 匹配任意非null值
1 * subscriber.receive(_ as String) // 匹配任意非null的String类型值
1 * subscriber.receive(endsWith("lo")) // 匹配"lo"结尾的字符串
1 * subscriber.receive({ it.size() > 3 && it.contains('a') }) // 匹配长度大于3且包含字符a的字符串
1 * process.invoke("ls", "-a", _, !null, { ["abcdefghiklmnopqrstuwx1"].contains(it) }) // 匹配第一个参数是ls, 第二个参数是-a, 第三个参数...
4.5 Spock的断言功能用法
Spock的断言语句写在then block里,并且可以省略assert关键字,下面是一个Spock断言的例子:
def "offered PC matches preferred configuration"() {
when:
def pc = shop.buyPc()
then:
pc.vendor == "Sunny"
pc.clockRate >= 2333
pc.ram >= 4096
pc.os == "Linux"
}
有时候,为了代码更加清晰可读,我们可以使用with关键字把对同一个对象的断言group到一起:
def "offered PC matches preferred configuration"() {
when:
def pc = shop.buyPc()
then:
with(pc) {
vendor == "Sunny"
clockRate >= 2333
ram >= 406
os == "Linux"
}
}
with关键字遇到第一个断言失败时就终止(短路),如果想要校验所有条件,可以使用verifyAll关键字:
def "offered PC matches preferred configuration"() {
when:
def pc = shop.buyPc()
then:
verifyAll(pc) {
vendor == "Sunny"
clockRate >= 2333
ram >= 406
os == "Linux"
}
}
4.6 Spock的参数化测试功能用法
可以通过下图的例子直观的感受一下Spock参数化测试:
关于Spock的参数化测试,有以下要点:
- 参数提供:参数在where block提供,提供形式有data table、data pipe、赋值表达式等等。上图提供参数的形式叫做data table,每一行对应一组参数。参数提供形式提供形式语法要点data table- 列之间用“|”分隔,输入和预期结果之间用“||”分隔。
@Unrolldef "test addition with #a and #b should be #c"() { expect: a + b == c where: a | b || c 1 | 2 || 3 4 | 5 || 9 -1 | 1 || 0}
- 至少两列,只有1列的话用“_”占位一列。where:a | _1 | _2 | _3 | _
- 支持使用表达式(但不推荐!)。data pipedata pipe语法主要借助“<<”运算符。例如data table中第一个代码示例用data pipe语法可以写为:@Unrolldef "test addition with #a and #b should be #c"() { expect: a + b == c where: a << [1, 4, -1] b << [2, 5, 1] c << [3, 9, 0]}
或@Unrolldef "test addition with #a and #b should be #c"() { expect: a + b == c where: [a, b, c] << [ [1, 4, -1], [2, 5, 1], [3, 9, 0] ]}
赋值表达式如果某一个参数是固定值,则可以写成复制表达式。点击展开内容 - 展开测试运行结果:在测试方法上加上@Unroll注解后,运行测试方法时会展开每个参数case的运行结果。
- 方法名访问参数:方法名中可以通过 "#" 符号访问where block中的参数。
版权归原作者 Cycrus 所有, 如有侵权,请联系我们删除。