0. 写在前面
这篇文章会介绍 Spock 测试框架相关的一些基本概念,包括数据驱动测试(Data Driven Testing)、基于交互的测试(Interaction Based Testing)、Mocking 和 Stubbing 等,这其中会带有一些简单的例子。
这篇文章绝大部分内容来自Spock官方文档,说它为官方文档的粗糙翻译也不为过🥲。我把自己使用 Spock 时认为比较重要的内容放在了这里,它们只是概念性的解释,参考资料 - 2是一篇快速上手文档,我也会尽量在下篇文章中展示工程中实际使用 Spock 的场景。如果哪些地方描述的太过晦涩,你可以直接移步至官方文档。
文章主要会从单元测试的角度介绍 Spock,对于集成测试,使用方式不会相差很多。还有你可能需要懂一点 Groovy 语言,如果例子中的某些地方你看起来有些吃力,它们很可能是 Groovy 的特性。现在,让我们开始吧!
1. 为什么要进行单元测试?
你可能很随意地说出单元测试的一些好处,不过我们不妨再复习一遍,你会对单元测试有新的体会。
1.1 什么是单元测试?
单元测试(Unit Testing)是一种软件测试方法,它会测试源代码中的各个单元来确定它们是否适合使用。单元是指最小的可测试软件组件,通常是一个只包括单一功能的方法。相比于更大的代码块,记录和分析测试结果更容易,单元测试揭示的缺陷更容易定位,也相对更容易修复,简单讲,单元测试更容易进行。
1.2 单元测试的几个好处
- 提高代码质量、尽早发现缺陷和降低后期修复成本:软件工程中提到过,越早的暴露缺陷,修复的成本越低,单元测试应该算是发现代码bug的最早过程。同时,这也会间接地提高代码质量,你可能还会思考有没有更好的代码设计,并考虑测试单元的核心功能,因为你需要保证它可测试;
- 开发过程会更敏捷、有利于安全重构:当你向工程中添加越来越多的功能时,有时需要修改旧的设计和代码。然而,更改测试过的代码既危险又昂贵。如果我们有单元测试,你就可以很自信地进行重构,及时地确保重构后的代码没有问题;
- 方便调试、对所写代码更有体感:毫无疑问,单元测试更容易调试,当进行单元测试时,我们的目光会聚焦于单个单元,你不必去考虑依赖和上下文。更重要的是,对于无法在本地启动的较大工程,单元测试能让你在本地执行和调试,这会具体到你需要考虑单元中的每个数据类型和边缘条件。
2. Spock框架介绍
我们首先来看 Spock 官方文档对自己的定义:
Spock is a testing and specification framework for Java and Groovy applications. ...Thanks to its JUnit runner, Spock is compatible with most IDEs, build tools, and continuous integration servers.
和某篇优秀的guide提及的内容:
Mainly, Spock aims to be a more powerful alternative to the traditional JUnit stack, by leveraging Groovy features.
首先,Spock 是用于 Java 和 Groovy 应用程序的测试和规范框架。因为测试代码需要用 Groovy 来写,所以你可以用到 Groovy 的一些灵活的特性,并且 Spock 还有自己的一些很酷的语法。因为其底层依赖 JUnit,所以你不必担心 JUnit 完成的测试 Spock 做不到,而且 Spock 会让测试代码更简洁。我们接着来看与 Spock 有关的一些概念。
2.1 规范 Specification
我们先来看官方文档中的一段介绍:
Spock lets you write specifications that describe expected features (properties, aspects) exhibited by a system of interest.
我们重点说明下划线标记的3个地方。首先,这里 Specification 是指软件需求规范(Software Requirements Specification),它完整描述了期望某个系统怎样运行。我们会使用 Spock 编写 Specification,它会描述一个兴趣系统(System Of Interest,我们这里简称为 SOI ) 的预期 features。SOI 可以是一个简单类或整个应用程序,这主要看你想测试什么,比如想做集成测试或系统测试,SOI 的范围就会相应变大;如果是单元测试,把范围限制到单个类即可。接着 features 就是我们想测试的一个个功能点,后面还会提到它。
class HelloSpecification extends Specification {
// fields
// fixture methods <--
// feature methods <--
// helper methods
}
我们实现的 specification 是一个继承自spock.lang.Specification的 Groovy 类,类Specification包含很多我们编写规范时有用的方法。命名时通常和 SOI 关联,比如PlanetServiceSpec、CustomerSpec。
夹具方法 Fixture Methods
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
Fixture methods 负责初始化和清理运行 feature methods 的环境。你可以将它类比到 JUnit 中的@Before和@After。这些夹具方法会有一定的运行顺序,更详细说明你可以参考官方文档:Fixture Methods。
特征方法 Feature Methods
def "one plus one should equal two"() {
// blocks go here, e.g.
expect:
1 + 1 == 2
}
特征方法是整个 Spock 的核心,现在把目光聚焦到 feature,我们做测试的根本目的是关注某个 feature(或者叫功能/特征)是否可行。实际上,Spock 中的 feature 就是 JUnit 中的 test。你会发现特征方法的方法名可以是字符串,所以你应该起个好名字,来说明这个方法主要做什么测试。
相比于 JUnit,Spock 将一个特征方法拆分为表达更形象的4个阶段(phases):
- Setup:初始化方法运行环境(可选)
- Stimulus:就像它的名字,被测试方法启动的地方
- Response:验证测试结果,就像 JUnit 中的断言
- Cleanup:清理方法运行环境(可选)
块 Blocks
Spock 提供了块(blocks)去支持这些阶段,你可以仔细看上面的图,Blocks 是 Spock使用标签来拆分测试阶段的一种原生方式,它们是
given、when、then、expect、cleanup和where,其中where比较特殊,它没有对应特殊的阶段,而是会和整个特征方法运行时关联。你可以在官方文档:Feature Methods 找到更详细的说明。
我们顺带使用 Groovy 的一些特性,比如:
def "element should be able to remove from list"() {
given:
def list = [1, 2, 3, 4]
when:
list.remove(0)
then:
list == [1, 3, 4]
}
我们在given中提供相关的环境,在when中给出条件,最后在then中判断。通常,given、when和then这样的三段式会很常见。
同时,你会发现上面那个特征方法运行应该是失败的,不过,测试失败时的结果展示也很清晰:
Condition not satisfied:
list == [1, 3, 4]
| |
| false
[2, 3, 4]
<Click to see difference>
at FirstSpecification.element should be able to remove from list(FirstSpecification.groovy:30)
2.2 数据驱动测试 Data Driven Testing
可以说,数据驱动测试是使用不同的参数和断言多次测试相同的行为(behavior)。使用 Spock 会让这种做法操作起来更方便。
数据表格 Data Tables
使用数学例子比较能清晰地说明这种情况:
class MathSpec extends Specification {
def "maximum of two numbers"() {
expect:
Math.max(a, b) == c
where:
a | b | c
1 | 3 | 3
7 | 4 | 7
0 | 0 | 0
}
}
Spock 提供了一种更方便的方式进行参数化测试(parameterized test),那就是数据表格(Data Table),同时它应该在where块中,表格中的值可以是多种类型。
数据管道 Data Pipes
你会发现given块中设置固定的环境,where块中会提供流动的数据。有时,数据表格提供数据的方式并不是那么灵活,比如数据类型有点复杂,或者数据来自外部函数,像是测试用的数据库。其实,数据表格不是 Spock 提供数据的唯一方式,并且它其实是一个或多个数据管道(Data Pipe)组合后的语法糖:
...
where:
a << [1, 7, 0]
b << [3, 4, 0]
c << [3, 7, 0]
这种写法和上面数据表格写法所表达的意思是一样的。数据管道会更灵活一些,比如存在嵌套的数据结构:
...
where:
[a, [b, _, c]] << [
['a1', 'a2'].permutations(),
[
['b1', 'd1', 'c1'],
['b2', 'd2', 'c2']
]
].combinations()
它对应下方的数据表格,_表示该字段无意义。
| a | b | c |
|---|---|---|
| ['a1', 'a2'] | 'b1' | 'c1' |
| ['a2', 'a1'] | 'b1' | 'c1' |
| ['a1', 'a2'] | 'b2' | 'c2' |
| ['a2', 'a1'] | 'b2' | 'c2' |
另外,where块还可以进行变量赋值(Variable Assignments),我们可以把数据表格、数据管道和变量赋值同时放在where块中。
2.3 基于交互测试 Interaction Based Testing
基于交互测试是在极限编程(XP)中出现的一种设计和测试技术,它更加关注对象的行为(behavior)而不是其状态。基于此,我们测试时考虑点可以是规范下的对象(object under specification)怎样通过方法调用的形式和其关联对象交互。
我们来看一个例子,假使我们有一个Publisher,它能向订阅自己的每个Subscriber发送消息:
class Publisher {
List<Subscriber> subscribers = []
int messageCount = 0
void send(String message) {
subscribers*.receive(message)
messageCount++
}
}
interface Subscriber {
void receive(String message)
}
class PublisherSpec extends Specification {
Publisher publisher = new Publisher()
}
我们非常想确定的一点是:Publisher发送消息后,它的Subscriber是否能收到消息,并且收到的消息和发送的消息一致。为了达到这个目的,我们需要一个特殊的Subscriber来监听Publisher和Subscriber之间的交互行为,这个特殊的Subscriber在 Spock 中被称为 mock object。
在 Spock 中创建 mock 对象很容易,可以调用MockingApi.Mock()方法:
def subscriber = Mock(Subscriber)
def subscriber2 = Mock(Subscriber)
// 另外一种写法
Subscriber subscriber = Mock()
Subscriber subscriber2 = Mock()
对于 mock 对象,有两个非常重要的概念,分别是 Mocking 和 Stubbing。我们现在来看一看:
Mocking
Mocking is the act of describing (mandatory) interactions between the object under specification and its collaborators.
上方引用是 mocking 的官方定义,mocking 描述测试对象和其关联对象之间准确的交互行为。拿开始时的Publisher为例:
class PublisherSpec extends Specification {
Publisher publisher = new Publisher()
def setup() {
}
def "subscribers should receive the message"() {
given:
Subscriber subscriber = Mock()
Subscriber subscriber2 = Mock()
publisher.subscribers << subscriber
publisher.subscribers << subscriber2
when:
publisher.send("Hello")
then:
1 * subscriber.receive("Hello") // <-- 看这里
1 * subscriber2.receive("Hello")
}
}
这个唯一的 feature method 表达的意思是:当发布者发送 “Hello” 消息时,两个订阅者都应该只收到一次该消息。我尝试通俗地解释一下 mocking:当测试对象(object of specification)运行某个方法(stimulus)时,对应地,方法内涉及的对象(collaborator)应该出现什么样的行为,mocking 使我们能够准确的验证这些行为。
我们仔细看一下then块中的一个交互:
1 * subscriber.receive("Hello")
| | | |
| | | argument constraint
| | method constraint
| target constraint
基数 cardinality
利用这四个约束,我们能做到准确约束某个行为。更加详细的说明,请参考官方文档:Constraint。
Stubbing
Stubbing is the act of making collaborators respond to method calls in a certain way.
有时,对于某个 mock 对象,我们希望模拟它的行为,也就是说:当 mock 对象的一个方法调用时,它会返回一个模拟的结果,而不是方法真正执行后的返回结果。这也就是 stubbing 的含义,就像上方引用中官方对 stubbing 的定义。
我们来看一个例子:
// Planet.java
public class Planet {
private String name;
}
// xxSpec.groovy
...
given:
def earth = Mock(Planet)
earth.getName() >> "Earth" // <-- 看这里
earth是我们的 mock 对象,我们没有设置其name属性的值,但是我们可以通过 stubbing 的方式伪造name,就是earth.getName() >> "Earth"。
另外,stubbing 可以在when块之前的任何块内。
3. 小结
在这篇文章中,我们首先说明了单元测试的必要性,然后我们介绍 Spock 框架,我们能够根据测试的范围来确定规范下的系统(System Under Specification),Specification 会包含几个部分,其中 fixture methods 可以设置和清理整个 specification 的环境,所以每个 feature method 是在其之中运行的。对于 feature methods,phases 和 blocks 是很重要的概念,一个 feature method 被划分为职责清晰的 block。
使用 Spock,我们能更轻松地伪造测试数据,也就是说,数据驱动测试更容易。Spock 会更关注测试对象的行为(behavior),mocking 验证行为交互是否合理,而 stubbing 可以伪造关联对象方法的返回结果。
最后,祝你好运。