可能是全网最好的 Spock 单测入门文章!
Spock 是非常简洁规范的单元测试框架,网上很多资料都不齐全,例子也很难懂。我自己经过一段时间的学习,梳理了这篇文章,不仅讲解层次递进,而且还有非常简洁明了的例子,小白都能懂!
快速入门 Spock
使用 Spock 非常简单,只需要引入对应的 Spock 依赖包就可以写 Spock 单测代码了。下面我将演示一个使用 Spock 进行单测的最小项目,帮助大家最快上手 Spock。本文档所有例子可在 Github 项目中找到,地址:chenyurong/quick-start-of-spock: 深入浅出 Spock 单测
首先,我们使用 Spring Initializr 初始化一个项目,不需要引入任何依赖应用,这里我命名为 quick-start-of-spock。项目初始化完成之后,在 pom.xml 文件中添加 Spock 依赖,如下代码所示。
- <dependency>
- <groupId>org.spockframework</groupId>
- <artifactId>spock-core</artifactId>
- <version>1.2-groovy-2.4</version>
- </dependency>
接着,我们编写一个计算器类,用来演示 Spock 单测的使用,代码如下所示。
- package tech.shuyi.qsos
-
- public class Calculator {
- public int add(int num1, int num2) {
- return num1 + num2;
- }
- public int sub(int num1, int num2) {
- return num1 - num2;
- }
- public int mul(int num1, int num2) {
- return num1 * num2;
- }
- public int div(int num1, int num2) {
- return num1 / num2;
- }
- }
接着,我们为 Calculator 生成一个测试类,放在 test 目录下即可,名称命名为 CalculatorTest.groovy,代码如下所示。
- package tech.shuyi.qsos
-
- import spock.lang.Specification
-
- class CalculatorTest extends Specification {
-
- Calculator calculator = new Calculator()
-
- def "test add method, 1 add 1 should equals 2."() {
- given: "init input data"
- def num1 = 1
- def num2 = 1
- when: "call add method"
- def result = calculator.add(num1, num2)
- then: "result should equals 2"
- result == 2
- }
-
- def "test sub"() {
- expect:
- calculator.sub(5, 4) == 1
- }
-
- def "test mul"() {
- expect:
- calculator.mul(5, 4) == 20
- }
-
- def "test div"() {
- when:
- calculator.div(1, 0)
- then:
- def ex = thrown(ArithmeticException)
- ex.message == "/ by zero"
- }
- }
这个测试类中,针对 Calculator 类的 4 个加减乘除方法都配置了对应的单测用例。到这里,Spock 的代码就编写完成了。我们直接点击 CalculatorTest 类左边的运行按钮即可运行整个单测用例,如下图所示。
正常情况下,所有单测用例都应该通过测试,都显示绿色的图标,如下图所示。
我们还可以用来计算一下单测覆盖率,运行入口如下图所示。
点击运行之后,会弹出单测覆盖率结果,我这里对所有方法都覆盖了,因此覆盖率是 100%,如下图所示。
到这里,一个最小单元的 Spock 示例项目就结束了。
Spock 语法块
对于 Spock 来说,其最大的特点是使用 give-when-then 等结构来规范了单测的写法,这也是一种非常好的单测规范。因此,了解 Spock 的语法块,知道每个关键词代表的意思就显得非常重要了。
基础语法
对于 Spock 来说,最常用的几个语法块关键词有:
given
when
then
and
expect
given
given 代码块通常用来进行数据准备,以及准备 mock 数据。例如上面计算器加法单测的例子:
- def "test add method, 1 add 1 should equals 2."() {
- given: "init input data"
- def num1 = 1
- def num2 = 1
- when: "call add method"
- def result = calculator.add(num1, num2)
- then: "result should equals 2"
- result == 2
- }
我们在 given 代码块中初始化了 num1 和 num2 两个数据,用于后续计算加法的入参。一般情况下,given 标签都是位于单测的最前面,但 given 代码块并不是必须的。因为如果初始化的数据并不复杂,那么它可以直接被省略。
例如我们这个例子中,初始化的数据只是两个变量,并且数据很简单,那么我们就可以不需要定义变量,而是直接写在入参处,如下代码所示。
- def "test add method, 1 add 1 should equals 2."() {
- when: "call add method"
- def result = calculator.add(1, 1)
- then: "result should equals 2"
- result == 2
- }
when
when 代码块主要用于被测试类的调用,例如我们计算器的例子中,我们在 when 代码块中就调用了 Calculator 类的 add 方法,如下代码所示。
- def "test add method, 1 add 1 should equals 2."() {
- given: "init input data"
- def num1 = 1
- def num2 = 1
- when: "call add method"
- // 调用 Calculator 类的 add 方法
- def result = calculator.add(num1, num2)
- then: "result should equals 2"
- result == 2
- }
then
then 代码块主要用于进行结果的判断,例如我们计算器的例子中,我们就在 then 代码块中判断了 result 的结果,如下代码所示。
- def "test add method, 1 add 1 should equals 2."() {
- given: "init input data"
- def num1 = 1
- def num2 = 1
- when: "call add method"
- def result = calculator.add(num1, num2)
- then: "result should equals 2"
- // 判断 result 结果
- result == 2
- }
and
and 代码块主要用于跟在 given、when、then 代码块后,用于将大块的代码分割开来,易于阅读。例如我们计算器的例子,我们假设初始化的数据很多,那么都堆在 given 代码中不易于理解,那么我们可以将其拆分成多个代码块。同理,我们在 when 和 then 代码块中的代码也可以进行同样的拆分,如下代码所示。
- def "test add method, 1+1=2, 2+3=5"() {
- given: "init num1 and num2"
- def num1 = 1
- def num2 = 1
- and: "init num3 and num4"
- def num3 = 2
- def num4 = 3
- when: "call add method(num1, num2)"
- def result1 = calculator.add(num1, num2)
- and: "call add method(num3, num4)"
- def result2 = calculator.add(num3, num4)
- then: "1 add 1 should equals 2"
- result1 == 2
- and: "2 add 3 should equals 5"
- result2 == 5
- }
expect
expect 代码块是 when-then 代码块的精简版本,有时候我们的测试逻辑很简单,并不需要把触发被测试类和校验结果的逻辑分开,这时候就可以用 expect 替代 when-then 代码块。例如计算器的例子中,我们就可以用如下的 expect 代码块来替换 when-then 代码块。
- def "test add method, 1 add 1 should equals."() {
- given: "init input data"
- def num1 = 1
- def num2 = 1
- expect: "1 add 1 should equals 2"
- calculator.add(num1, num2) == 2
- }
到这里,关于 Spock 语法块的基础语法介绍就结束了。
最佳实践
看完了 Spock 语法块的介绍之后,是不是觉得有点懵,不知道应该怎样搭配使用?没关系,其实你用多了之后就会发现,其实常用的搭配就那几种。这里我总结几种代码块的最佳实践,记住这几种就可以了。
given-when-then
given-when-then 组合是使用最多的一种,也是普适性最强的一种。你可以不记得其他的语法块,但这一种你必须记住。对于 given-when-then 组合来说,它的用法如下:
given:用来定义初始数据、以及 Mock 信息。
when:用来触发被测试类的方法。
then:用来进行结果的校验。
根据测试逻辑的复杂程度,我们可以自由地在这三个代码块的后面加上 and 代码块,从而使得代码更加地简洁易读。given-when-then 组合的示例如下代码所示。
- def "test add method, 1 add 1 should equals 2."() {
- given: "init input data"
- def num1 = 1
- def num2 = 1
- when: "call add method"
- def result = calculator.add(num1, num2)
- then: "result should equals 2"
- result == 2
- }
given-expect
given-expect 是 given-when-then 的简化版本,主要用于简化代码,提升我们写代码的效率。本质上来说,其就是把 when-then 组合在一起,换成了 expect 代码块。对于 given-expect 组合来说,它的用法如下:
given:用来定义初始数据、以及 Mock 信息。
expect:用来触发被测试类的方法,并进行结果校验。
如果触发被测试类以及结果校验的逻辑很简单,那么你可以尝试用 given-expect 组合来简化代码。given-expect 组合的示例如下代码所示。
- def "test add method, 1 add 1 should equals."() {
- given: "init input data"
- def num1 = 1
- def num2 = 1
- expect: "1 add 1 should equals 2"
- calculator.add(num1, num2) == 2
- }
更进一步,如果单测逻辑中初始化数据的逻辑也很简单,那么你可以直接省略 given 代码块,直接写一个 expect 代码块即可!
- def "test add method, 1 add 1 should equals 2."() {
- expect: "1 add 1 should equals 2"
- calculator.add(1, 1) == 2
- }
高级语法
where
where 代码块是 Spock 用于简化代码的又一利器,它能以数据表格的形式一次性写多个测试用例。还是拿上面的计算器加法函数的例子,我们可能会测试正数是否运算正确,也需要测试负数是否运算正确。如果没有用 where 代码块,那么我们需要重复写两个测试函数,如下代码所示:
- def "test add method, 1 add 1 should equals 2."() {
- expect: "1 add 1 should equals 2"
- calculator.add(1, 1) == 2
- }
- def "test add method, -1 add -1 should equals -2."() {
- expect: "1 add 1 should equals 2"
- calculator.add(-1, -1) == -2
- }
如果使用了 where 代码块,那么可以将其合并成一个测试函数,如下代码所示:
- def "test add method with multi inputs and outputs"() {
- expect: "1 add 1 should equals 2"
- calculator.add(num1, num2) == result
- where: "some possible situation"
- num1 | num2 || result
- 1 | 1 || 2
- -1 | -1 || -2
- }
上面代码运行的结果如下图所示:
可以看到两个测试用例都整合在一行了,这样不当某行数据出错的时候,我们不知道到底是哪个出错。其实我们可以使用 @Unroll 注解给每个行测试数据起个名字,这样方便后续知道哪个用例出错,如下代码所示:
- @Unroll
- def "test add method #name"() {
- expect: "1 add 1 should equals 2"
- calculator.add(num1, num2) == result
- where: "some possible situation"
- name | num1 | num2 || result
- "positive number" | 1 | 1 || 2
- "negative number" | -1 | -1 || -2
- }
这样每个测试用例都会独自成为一行,如下图所示:
一般来说 where 代码块可以放在 expect 后,也可以跟在 then 后,其执行效果都一样。
stub
在单测中会有很多外部依赖,我们需要把外部依赖排除掉,其中有一个很常见的场景是:需要让外部接口返回特定的值。而单测中的 stub 就是用来解决这个问题的,通过 stub 可以让外部接口返回特定的值。
说起 stub 这个单词,一开始很不理解。但后面查了查它的英文单词,再联想一下其使用场景,就很容易理解了。stub 英文是树桩的意思,啥是树桩,就是像下面的玩意。单测的 stub 就是在外部依赖接口那里立一个树桩,当你跑到那个位置遇到了桩子(单测执行),就自动弹回来(返回特定值)。
在 Spock 中使用 stub 非常简单,只需要两步即可:
确定需要 stub 的对象
指定 stub 对象被调用方法的行为 举个例子,现在我们有一个更加复杂的计算器,里面有一个加法函数。该加法函数调用了开关服务的 isOpen 接口用于判断开关是否打开。当开关打开时,我们需要将最终的结果再乘以 2。当开关服务关闭时,直接返回原来的值。这个复杂计算器类的代码如下所示:
- public class ComplexCalculator {
- SwitchService switchService;
-
- public int add(int num1, int num2) {
- return switchService.isOpen()
- ? (num1 + num2) * 2
- : num1 + num2;
- }
- public void setSwitchService(SwitchService switchService) {
- this.switchService = switchService;
- }
- }
我们并不知道 SwitchService 的具体逻辑是什么,但我们只知道开关打开时结果乘以 2,开关关闭时返回原来的结果。那么我们如何测试我们的加法函数是否编写正确呢?这时候就需要用到 stub 功能去让 SwitchService 接口返回特定的值,以此来测试我们的 add 函数是否正确了。此时的测试类代码如下所示:
- import spock.lang.Specification
- import spock.lang.Unroll
-
- class ComplexCalculatorTest extends Specification {
- @Unroll
- def "complex calculator with Stub #name"() {
- given: "a complex calculator"
- ComplexCalculator complexCalculator = new ComplexCalculator()
- and: "stub switch service"
- // stub a switch service return with isOpen
- SwitchService switchService = Stub(SwitchService)
- switchService.isOpen() >> isOpen
- // set switch service to calculator
- complexCalculator.setSwitchService(switchService)
- expect: "should return true"
- complexCalculator.add(num1, num2) == result
- where: "possible values"
- name | isOpen | num1 | num2 || result
- "when switch open" | true | 2 | 3 || 10
- "when switch close" | false | 2 | 3 || 5
- }
- }
如上代码所示,我们在 and 代码块中 stub 了一个 SwitchService 对象,并将其复制给了 ComplexCalculator 对象,对象返回的值取决于 isOpen 属性的值。最后,在 where 代码块里,我们分别测试了开关打开和关闭时的场景。
mock
mock 又是单测中一个非常重要的功能,甚至很多人会把 mock 与 stub 搞混,以为 stub 就是 mock,实际上它们很相似,但又有所区别。应该说:mock 包括了 stub 的所有功能,但是 mock 有 stub 没有的功能,那就是校验 mock 对象的行为。
我们先来说第一个点:mock 包括了 stub 的所有功能,即 mock 也可以插桩返回特定数据。在这个功能上,mock 其用法与 stub 一模一样,你只需要把 Stub 关键词换成 Mock 关键词即可,例如下面的代码与上文 stub 例子中代码的功能是一样的。
- @Unroll
- def "complex calculator with Mock #name "() {
- given: "a complex calculator"
- ComplexCalculator complexCalculator = new ComplexCalculator()
- and: "stub switch service"
- // replace Stub with Mock
- SwitchService switchService = Mock(SwitchService)
- switchService.isOpen() >> isOpen
- complexCalculator.setSwitchService(switchService)
- expect: "should return true"
- complexCalculator.add(num1, num2) == result
- where: "possible values"
- name | isOpen | num1 | num2 || result
- "when switch open" | true | 2 | 3 || 10
- "when switch close" | false | 2 | 3 || 5
- }
接着,我们讲第二个点,即:Mock 可以校验对象的行为,而 stub 不行。举个例子,在上面的例子中,我们知道 add () 方法需要去调用 1 次 switchService.isOpen () 方法。但实际上有没有调用,我们其实不知道。
虽然我们可以去看代码,但是如果调用层级和链路很复杂呢?我们还是要一行行、一层层去调用链路吗?这时候 Mock 的校验对象行为功能就发挥出价值了!
- @Unroll
- def "complex calculator with Mock examine action #name "() {
- given: "a complex calculator"
- ComplexCalculator complexCalculator = new ComplexCalculator()
- and: "stub switch service"
- SwitchService switchService = Mock(SwitchService)
- complexCalculator.setSwitchService(switchService)
- when: "call add method"
- def realRs = complexCalculator.add(num1, num2)
- then: "should return true and should call isOpen() only once"
- // 校验 isOpen() 方法是否只被调用 1 次
- 1 * switchService.isOpen() >> isOpen
- realRs == result
- where: "possible values"
- name | isOpen | num1 | num2 || result
- "when switch open" | true | 2 | 3 || 10
- "when switch close" | false | 2 | 3 || 5
- }
如上代码所示,第 12 行就用于校验 isOpen () 方法是否被调用了 1 次。除了判断是否被调用过之外,Mock 还能判断参数是否是特定类型、是否是特定的值等等。
如果必须要掌握一个功能,那么只掌握 mock 就好。但为了让代码可读性更高,如果只需要返回值,不需要校验对象行为,那还是用 Stub 即可。如果既需要返回值,又需要校验对象行为,那么才用 Mock。
thrown
有时候我们在代码里会抛出异常,那么我们怎么校验抛出异常这种情况呢?Spock 框架提供了 thrown 关键词来对异常抛出做校验。以计算器的例子为例,当我们的分母是 0 的时候会抛出 ArithmeticException 异常,此时我们便可以用 thrown 关键词捕获,如下代码所示。
- // 除法函数
- public int div(int num1, int num2) {
- return num1 / num2;
- }
-
- // 测试用例
- def "test div"() {
- when:
- calculator.div(1, 0)
- then:
- def ex = thrown(ArithmeticException)
- ex.message == "/ by zero"
- }
在 then 代码块中,我们用 thrown (ArithmeticException) 表明调用 calculator.div (1, 0) 时会抛出异常,并且用一个 ex 变量接收该异常,随后还对其返回的信息做了校验。
想了解更多与单测相关的知识点?
想与更多小伙伴交流单测?
扫描下方二维码备注(「单测交流」)我拉你入群交流。
推荐阅读