可能是全网最好的 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 变量接收该异常,随后还对其返回的信息做了校验。
想了解更多与单测相关的知识点?
想与更多小伙伴交流单测?
扫描下方二维码备注(「单测交流」)我拉你入群交流。
推荐阅读
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· .NET10 - 预览版1新功能体验(一)