可能是全网最好的 Spock 单测入门文章!

发布时间 2023-12-07 19:05:49作者: sunny123456

可能是全网最好的 Spock 单测入门文章!

01fa081c8d3df49b6fbc08336ebbd7e8.png

Spock 是非常简洁规范的单元测试框架,网上很多资料都不齐全,例子也很难懂。我自己经过一段时间的学习,梳理了这篇文章,不仅讲解层次递进,而且还有非常简洁明了的例子,小白都能懂!

快速入门 Spock

使用 Spock 非常简单,只需要引入对应的 Spock 依赖包就可以写 Spock 单测代码了。下面我将演示一个使用 Spock 进行单测的最小项目,帮助大家最快上手 Spock。本文档所有例子可在 Github 项目中找到,地址:chenyurong/quick-start-of-spock: 深入浅出 Spock 单测

首先,我们使用 Spring Initializr 初始化一个项目,不需要引入任何依赖应用,这里我命名为 quick-start-of-spock。项目初始化完成之后,在 pom.xml 文件中添加 Spock 依赖,如下代码所示。

  1. <dependency>
  2.     <groupId>org.spockframework</groupId>
  3.     <artifactId>spock-core</artifactId>
  4.     <version>1.2-groovy-2.4</version>
  5. </dependency>

接着,我们编写一个计算器类,用来演示 Spock 单测的使用,代码如下所示。

  1. package tech.shuyi.qsos
  2. public class Calculator {
  3.     public int add(int num1, int num2) {  
  4.         return num1 + num2;  
  5.     }
  6.     public int sub(int num1, int num2) {  
  7.         return num1 - num2;  
  8.     }
  9.     public int mul(int num1, int num2) {  
  10.         return num1 * num2;  
  11.     }
  12.     public int div(int num1, int num2) {  
  13.         return num1 / num2;  
  14.     }  
  15. }

接着,我们为 Calculator 生成一个测试类,放在 test 目录下即可,名称命名为 CalculatorTest.groovy,代码如下所示。

  1. package tech.shuyi.qsos
  2. import spock.lang.Specification
  3. class CalculatorTest extends Specification {
  4.     Calculator calculator = new Calculator()
  5.     def "test add method, 1 add 1 should equals 2."() {
  6.         given: "init input data"
  7.             def num1 = 1
  8.             def num2 = 1
  9.         when: "call add method"
  10.             def result = calculator.add(num1, num2)
  11.         then: "result should equals 2"
  12.             result == 2
  13.     }
  14.     def "test sub"() {
  15.         expect:
  16.             calculator.sub(54) == 1
  17.     }
  18.     def "test mul"() {
  19.         expect:
  20.             calculator.mul(54) == 20
  21.     }
  22.     def "test div"() {
  23.         when:
  24.             calculator.div(10)
  25.         then:
  26.             def ex = thrown(ArithmeticException)
  27.             ex.message == "/ by zero"
  28.     }
  29. }

这个测试类中,针对 Calculator 类的 4 个加减乘除方法都配置了对应的单测用例。到这里,Spock 的代码就编写完成了。我们直接点击 CalculatorTest 类左边的运行按钮即可运行整个单测用例,如下图所示。

f63930b0fec01e9ac0d9af8e4a968d95.jpeg

正常情况下,所有单测用例都应该通过测试,都显示绿色的图标,如下图所示。

089bd0167369403552a347128c1529f6.jpeg

我们还可以用来计算一下单测覆盖率,运行入口如下图所示。

66bf5ccdacb793b3f481bf8e54aef709.jpeg

点击运行之后,会弹出单测覆盖率结果,我这里对所有方法都覆盖了,因此覆盖率是 100%,如下图所示。

0ce50af41ce100594cc49064fe9b6d02.jpeg

到这里,一个最小单元的 Spock 示例项目就结束了。

Spock 语法块

对于 Spock 来说,其最大的特点是使用 give-when-then 等结构来规范了单测的写法,这也是一种非常好的单测规范。因此,了解 Spock 的语法块,知道每个关键词代表的意思就显得非常重要了。

基础语法

对于 Spock 来说,最常用的几个语法块关键词有:

  • given

  • when

  • then

  • and

  • expect

given

given 代码块通常用来进行数据准备,以及准备 mock 数据。例如上面计算器加法单测的例子:

  1. def "test add method, 1 add 1 should equals 2."() {
  2.     given: "init input data"
  3.         def num1 = 1
  4.         def num2 = 1
  5.     when: "call add method"
  6.         def result = calculator.add(num1, num2)
  7.     then: "result should equals 2"
  8.         result == 2
  9. }

我们在 given 代码块中初始化了 num1 和 num2 两个数据,用于后续计算加法的入参。一般情况下,given 标签都是位于单测的最前面,但 given 代码块并不是必须的。因为如果初始化的数据并不复杂,那么它可以直接被省略。

例如我们这个例子中,初始化的数据只是两个变量,并且数据很简单,那么我们就可以不需要定义变量,而是直接写在入参处,如下代码所示。

  1. def "test add method, 1 add 1 should equals 2."() {
  2.     when: "call add method"
  3.         def result = calculator.add(11)
  4.     then: "result should equals 2"
  5.         result == 2
  6. }
when

when 代码块主要用于被测试类的调用,例如我们计算器的例子中,我们在 when 代码块中就调用了 Calculator 类的 add 方法,如下代码所示。

  1. def "test add method, 1 add 1 should equals 2."() {
  2.     given: "init input data"
  3.         def num1 = 1
  4.         def num2 = 1
  5.     when: "call add method"
  6.         // 调用 Calculator 类的 add 方法
  7.         def result = calculator.add(num1, num2)
  8.     then: "result should equals 2"
  9.         result == 2
  10. }
then

then 代码块主要用于进行结果的判断,例如我们计算器的例子中,我们就在 then 代码块中判断了 result 的结果,如下代码所示。

  1. def "test add method, 1 add 1 should equals 2."() {
  2.     given: "init input data"
  3.         def num1 = 1
  4.         def num2 = 1
  5.     when: "call add method"
  6.         def result = calculator.add(num1, num2)
  7.     then: "result should equals 2"
  8.         // 判断 result 结果
  9.         result == 2
  10. }
and

and 代码块主要用于跟在 given、when、then 代码块后,用于将大块的代码分割开来,易于阅读。例如我们计算器的例子,我们假设初始化的数据很多,那么都堆在 given 代码中不易于理解,那么我们可以将其拆分成多个代码块。同理,我们在 when 和 then 代码块中的代码也可以进行同样的拆分,如下代码所示。

  1. def "test add method, 1+1=2, 2+3=5"() {
  2.     given: "init num1 and num2"
  3.         def num1 = 1
  4.         def num2 = 1
  5.     and: "init num3 and num4"
  6.         def num3 = 2
  7.         def num4 = 3
  8.     when: "call add method(num1, num2)"
  9.         def result1 = calculator.add(num1, num2)
  10.     and: "call add method(num3, num4)"
  11.         def result2 = calculator.add(num3, num4)
  12.     then: "1 add 1 should equals 2"
  13.         result1 == 2
  14.     and: "2 add 3 should equals 5"
  15.         result2 == 5
  16. }
expect

expect 代码块是 when-then 代码块的精简版本,有时候我们的测试逻辑很简单,并不需要把触发被测试类和校验结果的逻辑分开,这时候就可以用 expect 替代 when-then 代码块。例如计算器的例子中,我们就可以用如下的 expect 代码块来替换 when-then 代码块。

  1. def "test add method, 1 add 1 should equals."() {
  2.     given: "init input data"
  3.         def num1 = 1
  4.         def num2 = 1
  5.     expect: "1 add 1 should equals 2"
  6.         calculator.add(num1, num2) == 2
  7. }

到这里,关于 Spock 语法块的基础语法介绍就结束了。

最佳实践

看完了 Spock 语法块的介绍之后,是不是觉得有点懵,不知道应该怎样搭配使用?没关系,其实你用多了之后就会发现,其实常用的搭配就那几种。这里我总结几种代码块的最佳实践,记住这几种就可以了。

given-when-then

given-when-then 组合是使用最多的一种,也是普适性最强的一种。你可以不记得其他的语法块,但这一种你必须记住。对于 given-when-then 组合来说,它的用法如下:

  • given:用来定义初始数据、以及 Mock 信息。

  • when:用来触发被测试类的方法。

  • then:用来进行结果的校验。

根据测试逻辑的复杂程度,我们可以自由地在这三个代码块的后面加上 and 代码块,从而使得代码更加地简洁易读。given-when-then 组合的示例如下代码所示。

  1. def "test add method, 1 add 1 should equals 2."() {
  2.     given: "init input data"
  3.         def num1 = 1
  4.         def num2 = 1
  5.     when: "call add method"
  6.         def result = calculator.add(num1, num2)
  7.     then: "result should equals 2"
  8.         result == 2
  9. }
given-expect

given-expect 是 given-when-then 的简化版本,主要用于简化代码,提升我们写代码的效率。本质上来说,其就是把 when-then 组合在一起,换成了 expect 代码块。对于 given-expect 组合来说,它的用法如下:

  • given:用来定义初始数据、以及 Mock 信息。

  • expect:用来触发被测试类的方法,并进行结果校验。

如果触发被测试类以及结果校验的逻辑很简单,那么你可以尝试用 given-expect 组合来简化代码。given-expect 组合的示例如下代码所示。

  1. def "test add method, 1 add 1 should equals."() {
  2.     given: "init input data"
  3.         def num1 = 1
  4.         def num2 = 1
  5.     expect: "1 add 1 should equals 2"
  6.         calculator.add(num1, num2) == 2
  7. }

更进一步,如果单测逻辑中初始化数据的逻辑也很简单,那么你可以直接省略 given 代码块,直接写一个 expect 代码块即可!

  1. def "test add method, 1 add 1 should equals 2."() {
  2.     expect: "1 add 1 should equals 2"
  3.         calculator.add(11) == 2
  4. }

高级语法

where

where 代码块是 Spock 用于简化代码的又一利器,它能以数据表格的形式一次性写多个测试用例。还是拿上面的计算器加法函数的例子,我们可能会测试正数是否运算正确,也需要测试负数是否运算正确。如果没有用 where 代码块,那么我们需要重复写两个测试函数,如下代码所示:

  1. def "test add method, 1 add 1 should equals 2."() {
  2.     expect: "1 add 1 should equals 2"
  3.         calculator.add(11) == 2
  4. }
  5. def "test add method, -1 add -1 should equals -2."() {
  6.     expect: "1 add 1 should equals 2"
  7.         calculator.add(-1-1) == -2
  8. }

如果使用了 where 代码块,那么可以将其合并成一个测试函数,如下代码所示:

  1. def "test add method with multi inputs and outputs"() {
  2.     expect: "1 add 1 should equals 2"
  3.         calculator.add(num1, num2) == result
  4.     where: "some possible situation"
  5.         num1 | num2 || result
  6.         1    | 1    || 2
  7.         -1   | -1   || -2
  8. }

上面代码运行的结果如下图所示:

f365e6bb12741e687f0c7829ed153f5e.jpeg

可以看到两个测试用例都整合在一行了,这样不当某行数据出错的时候,我们不知道到底是哪个出错。其实我们可以使用 @Unroll 注解给每个行测试数据起个名字,这样方便后续知道哪个用例出错,如下代码所示:

  1. @Unroll
  2. def "test add method #name"() {
  3.     expect: "1 add 1 should equals 2"
  4.         calculator.add(num1, num2) == result
  5.     where: "some possible situation"
  6.         name              | num1 | num2 || result
  7.         "positive number" | 1    | 1    || 2
  8.         "negative number" | -1   | -1   || -2
  9. }

这样每个测试用例都会独自成为一行,如下图所示:

64873a3c46310fe23ba3c6ae5a58126b.jpeg

一般来说 where 代码块可以放在 expect 后,也可以跟在 then 后,其执行效果都一样。

stub

在单测中会有很多外部依赖,我们需要把外部依赖排除掉,其中有一个很常见的场景是:需要让外部接口返回特定的值。而单测中的 stub 就是用来解决这个问题的,通过 stub 可以让外部接口返回特定的值。

说起 stub 这个单词,一开始很不理解。但后面查了查它的英文单词,再联想一下其使用场景,就很容易理解了。stub 英文是树桩的意思,啥是树桩,就是像下面的玩意。单测的 stub 就是在外部依赖接口那里立一个树桩,当你跑到那个位置遇到了桩子(单测执行),就自动弹回来(返回特定值)。

545d8d22f0fcdb415d4b56b999da7fdb.jpeg

在 Spock 中使用 stub 非常简单,只需要两步即可:

  1. 确定需要 stub 的对象

  2. 指定 stub 对象被调用方法的行为 举个例子,现在我们有一个更加复杂的计算器,里面有一个加法函数。该加法函数调用了开关服务的 isOpen 接口用于判断开关是否打开。当开关打开时,我们需要将最终的结果再乘以 2。当开关服务关闭时,直接返回原来的值。这个复杂计算器类的代码如下所示:

  1. public class ComplexCalculator {
  2.     SwitchService switchService;
  3.     public int add(int num1, int num2) {
  4.         return switchService.isOpen()
  5.                 ? (num1 + num2) * 2
  6.                 : num1 + num2;
  7.     }
  8.     public void setSwitchService(SwitchService switchService) {
  9.         this.switchService = switchService;
  10.     }
  11. }

我们并不知道 SwitchService 的具体逻辑是什么,但我们只知道开关打开时结果乘以 2,开关关闭时返回原来的结果。那么我们如何测试我们的加法函数是否编写正确呢?这时候就需要用到 stub 功能去让 SwitchService 接口返回特定的值,以此来测试我们的 add 函数是否正确了。此时的测试类代码如下所示:

  1. import spock.lang.Specification
  2. import spock.lang.Unroll
  3. class ComplexCalculatorTest extends Specification {
  4.     @Unroll
  5.     def "complex calculator with Stub #name"() {
  6.         given: "a complex calculator"
  7.             ComplexCalculator complexCalculator = new ComplexCalculator()
  8.         and: "stub switch service"
  9.             // stub a switch service return with isOpen
  10.             SwitchService switchService = Stub(SwitchService)
  11.             switchService.isOpen() >> isOpen
  12.             // set switch service to calculator
  13.             complexCalculator.setSwitchService(switchService)
  14.         expect: "should return true"
  15.             complexCalculator.add(num1, num2) == result
  16.         where: "possible values"
  17.             name                | isOpen | num1 | num2 || result
  18.             "when switch open"  | true   | 2    | 3    || 10
  19.             "when switch close" | false  | 2    | 3    || 5
  20.     }
  21. }

如上代码所示,我们在 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 例子中代码的功能是一样的。

  1. @Unroll
  2. def "complex calculator with Mock #name "() {
  3.     given: "a complex calculator"
  4.         ComplexCalculator complexCalculator = new ComplexCalculator()
  5.     and: "stub switch service"
  6.         // replace Stub with Mock
  7.         SwitchService switchService = Mock(SwitchService)
  8.         switchService.isOpen() >> isOpen
  9.         complexCalculator.setSwitchService(switchService)
  10.     expect: "should return true"
  11.         complexCalculator.add(num1, num2) == result
  12.     where: "possible values"
  13.         name                | isOpen | num1 | num2 || result
  14.         "when switch open"  | true   | 2    | 3    || 10
  15.         "when switch close" | false  | 2    | 3    || 5
  16. }

接着,我们讲第二个点,即:Mock 可以校验对象的行为,而 stub 不行。举个例子,在上面的例子中,我们知道 add () 方法需要去调用 1 次 switchService.isOpen () 方法。但实际上有没有调用,我们其实不知道。

虽然我们可以去看代码,但是如果调用层级和链路很复杂呢?我们还是要一行行、一层层去调用链路吗?这时候 Mock 的校验对象行为功能就发挥出价值了!

  1. @Unroll
  2. def "complex calculator with Mock examine action #name "() {
  3.     given: "a complex calculator"
  4.         ComplexCalculator complexCalculator = new ComplexCalculator()
  5.     and: "stub switch service"
  6.         SwitchService switchService = Mock(SwitchService)
  7.         complexCalculator.setSwitchService(switchService)
  8.     when: "call add method"
  9.         def realRs = complexCalculator.add(num1, num2)
  10.     then: "should return true and should call isOpen() only once"
  11.         // 校验 isOpen() 方法是否只被调用 1 次
  12.         1 * switchService.isOpen() >> isOpen
  13.         realRs == result
  14.     where: "possible values"
  15.         name                | isOpen | num1 | num2 || result
  16.         "when switch open"  | true   | 2    | 3    || 10
  17.         "when switch close" | false  | 2    | 3    || 5
  18. }

如上代码所示,第 12 行就用于校验 isOpen () 方法是否被调用了 1 次。除了判断是否被调用过之外,Mock 还能判断参数是否是特定类型、是否是特定的值等等。

如果必须要掌握一个功能,那么只掌握 mock 就好。但为了让代码可读性更高,如果只需要返回值,不需要校验对象行为,那还是用 Stub 即可。如果既需要返回值,又需要校验对象行为,那么才用 Mock。

thrown

有时候我们在代码里会抛出异常,那么我们怎么校验抛出异常这种情况呢?Spock 框架提供了 thrown 关键词来对异常抛出做校验。以计算器的例子为例,当我们的分母是 0 的时候会抛出 ArithmeticException 异常,此时我们便可以用 thrown 关键词捕获,如下代码所示。

  1. // 除法函数
  2. public int div(int num1, int num2) {  
  3.     return num1 / num2;  
  4. }  
  5. // 测试用例
  6. def "test div"() {
  7.     when:
  8.         calculator.div(10)
  9.     then:
  10.         def ex = thrown(ArithmeticException)
  11.         ex.message == "/ by zero"
  12. }

在 then 代码块中,我们用 thrown (ArithmeticException) 表明调用 calculator.div (1, 0) 时会抛出异常,并且用一个 ex 变量接收该异常,随后还对其返回的信息做了校验。

想了解更多与单测相关的知识点?

想与更多小伙伴交流单测?

扫描下方二维码备注(「单测交流」)我拉你入群交流。

f7930debea9b5099b914e0796c4b1acc.jpeg


推荐阅读

原文链接:https://blog.csdn.net/csywwx2008/article/details/130234380