Spock 是什么?

斯波克是国外一款优秀的测试框架,基于 BDD 思想,功能强大,能够让我们的测试代码规范化,结构层次清晰,结合 groovy 动态语言的特点以及自身提供的各种标签让编写测试代码更加高效和简洁,提供一种通用、简单、结构化的描述语言。

引用官网的介绍如下(http://spockframework.org)

image-20201231173144975

1
Spock 是一个 Java 和 Groovy 应用程序的测试和规范框架。 它之所以能在人群中脱颖而出,是因为它优美而富有表现力的规范语言。 斯波克的灵感来自 JUnit、RSpec、jMock、Mockito、Groovy、Scala、Vulcans

简单说 Spock 的特点如下:

  • 让我们的测试代码更规范,内置多种标签来规范单测代码的语义,从而让我们的测试代码结构清晰,更具可读性,降低后期维护难度
  • 提供多种标签,比如: where、with、thrown…… 帮助我们应对复杂的测试场景
  • 使用 groovy 这种动态语言来编写测试代码,可以让我们编写的测试代码更简洁,适合敏捷开发,提高编写单测代码的效率
  • 遵从 BDD 行为驱动开发模式,不单是为了测试覆盖率而测试,有助于提升代码质量
  • IDE 兼容性好,自带 mock 功能

Spock 和 JUnit、JMock、Mockito 的区别在哪里?

现有的单测框架比如 junit、jmock、mockito 都是相对独立的工具,只是针对不同的业务场景提供特定的解决方案。

  • Junit 单纯用于测试,不提供 mock 功能。
    • 微服务已经是互联网公司的主流技术架构,大部分的系统都是分布式,很多业务功能需要依赖底层接口返回的数据才能继续剩下的流程,或者从数据库/Redis 等存储设备上获取,或是从配置中心的某个配置获取。
    • 这样就导致如果我们想要测试代码逻辑是否正确,就必须把这些依赖项(接口、Redis、DB、配置中心…)给 mock 掉。
    • 如果接口不稳定或有问题则会影响我们代码的正常测试,所以我们要把调用接口的地方给模拟掉,让它返回指定的结果(提前准备好的数据,而不是真实的调用接口),这样才能往下验证我们自己的代码是否正确,符合预期逻辑和结果。
  • JMockMockito 虽然提供了 mock 功能,可以把接口等依赖屏蔽掉,但不提供对静态类静态方法的 mock,PowerMock 或 Jmockit 虽然提供静态类和方法的 mock,但它们之间需要整合(junit+mockito+powermock),语法繁琐,而且这些工具并没有告诉你“单元测试代码到底应该怎么写?”。
  • Spock 通过提供规范描述,定义多种标签(given、when、then、where 等)去描述代码“应该做什么”,输入条件是什么,输出是否符合预期,从语义层面规范代码的编写。
  • Spock 自带 Mock 功能,使用简单方便(也支持扩展其他 mock 框架,比如 power mock),再加上 groovy 动态语言的强大语法,能写出简洁高效的测试代码,同时更方便直观的验证业务代码行为流转,增强我们对代码执行逻辑的可控性。

Spock 如何解决传统单元测试开发中的痛点

单元测试代码开发的成本和效率

复杂场景的业务代码,在分支(if/else)很多的情况下,编写单测代码的成本会相应增加,势必增加写单元测试的成本。

举个我们生产环境发生的一起事故:有个功能上线 1 年多一直都正常,没有出过问题,但最近有个新的调用方请求的数据不一样,走到了代码中一个不常用的分支逻辑,导致了 bug,直接抛出异常阻断了主流程,好在调用方请求量不大。。。。。。

估计当初写这段代码的同学也认为很小几率会走到这个分支,虽然当时也写了单元测试代码,但分支较多,刚好漏掉了这个分支逻辑的测试,给日后上线留下了隐患

这也是我们平时写单元测试最常遇到的问题:要达到分支覆盖率高要求的情况下,if/else 有不同的结果,传统的单测写法可能要多次调用,才能覆盖全部的分支场景,一个是写单测麻烦,同时也会增加单测代码的冗余度。

虽然可以使用 junit 的@parametered 参数化注解或者 dataprovider 的方式,但还是不够方便直观,而且如果其中一次分支测试 case 出错的情况下,报错信息也不够详尽。

示例

被测试类

比如下面的示例演示代码,根据输入的身份证号码识别出生日期、性别、年龄等信息,这个方法的特点就是有很多 if…else…的分支嵌套逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
package com.spock.example.utils;

import java.util.Calendar;
import java.util.HashMap;
import java.util.Map;

/**
* 身份证号码工具类
* 15 位:6 位地址码+6 位出生年月日(900101 代表 1990 年 1 月 1 日出生)+3 位顺序码
* 18 位:6 位地址码+8 位出生年月日(19900101 代表 1990 年 1 月 1 日出生)+3 位顺序码+1 位校验码
* 顺序码奇数分给男性,偶数分给女性
*
* @author jinglv
* @date 2021/7/20 4:21 下午
*/
public class IDNumberUtils {
/**
* 通过身份证号码获取出生日期、性别、年龄
*
* @param certificateNo 身份证号码
* @return 返回的出生日期格式:1990-01-01 性别格式:F-女,M-男
*/
public static Map<String, String> getBirAgeSex(String certificateNo) {
int noLengthFifteen = 15;
int noLengthEighteen = 18;
String birthday = "";
String age = "";
String sex = "";

int year = Calendar.getInstance().get(Calendar.YEAR);
char[] number = certificateNo.toCharArray();
boolean flag = true;
if (number.length == noLengthFifteen) {
for (char c : number) {
if (!flag) {
return new HashMap<>(16);
}
flag = Character.isDigit(c);
}
} else if (number.length == noLengthEighteen) {
for (int x = 0; x < number.length - 1; x++) {
if (!flag) {
return new HashMap<>(16);
}
flag = Character.isDigit(number[x]);
}
}
if (flag && certificateNo.length() == noLengthFifteen) {
birthday = "19" + certificateNo.substring(6, 8) + "-"
+ certificateNo.substring(8, 10) + "-"
+ certificateNo.substring(10, 12);
sex = Integer.parseInt(certificateNo.substring(certificateNo.length() - 3)) % 2 == 0 ? "女" : "男";
age = (year - Integer.parseInt("19" + certificateNo.substring(6, 8))) + "";
} else if (flag && certificateNo.length() == noLengthEighteen) {
birthday = certificateNo.substring(6, 10) + "-"
+ certificateNo.substring(10, 12) + "-"
+ certificateNo.substring(12, 14);
sex = Integer.parseInt(certificateNo.substring(certificateNo.length() - 4,
certificateNo.length() - 1)) % 2 == 0 ? "女" : "男";
age = (year - Integer.parseInt(certificateNo.substring(6, 10))) + "";
}
Map<String, String> map = new HashMap<>(16);
map.put("birthday", birthday);
map.put("age", age);
map.put("sex", sex);
return map;
}
}

JUnit4测试方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
package com.spock.example.java;

import com.spock.example.utils.IDNumberUtils;
import junitparams.JUnitParamsRunner;
import junitparams.Parameters;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;

import java.util.Map;
import java.util.function.Predicate;

/**
* 使用junit的Parameters参数化注解测试多分支的方法,可以对比IDNumberUtilsTest.groovy,两个单测的测试结果一样, 但语法上没有Spock的写法简洁和直观,报错信息也不够详细
*
* @author jinglv
* @date 2021/7/20 4:49 下午
*/
@RunWith(JUnitParamsRunner.class)
public class IDNumberUtilsTest {

@Test
@Parameters(method = "getBirAgeSexParams")
public void getBirAgeSex(String certificateNo, Predicate<Map<String, String>> predicate) {
Map<String, String> minuteMap = IDNumberUtils.getBirAgeSex(certificateNo);
Assert.assertTrue(predicate.test(minuteMap));
}

private Object[] getBirAgeSexParams() {
return new Object[]{
new Object[]{
"310168199809187333", (Predicate<Map<String, String>>) map -> "{birthday=1998-09-18, sex=男, age=22}".equals(map.toString())
},
new Object[]{
"320168200212084268", (Predicate<Map<String, String>>) map -> "{birthday=2002-12-08, sex=女, age=18}".equals(map.toString())
},
new Object[]{
"330168199301214267", (Predicate<Map<String, String>>) map -> "{birthday=1993-01-21, sex=女, age=27}".equals(map.toString())
},
new Object[]{
"411281870628201", (Predicate<Map<String, String>>) map -> "{birthday=1987-06-28, sex=男, age=33}".equals(map.toString())
},
new Object[]{
"427281730307862", (Predicate<Map<String, String>>) map -> "{birthday=1973-03-07, sex=女, age=47}".equals(map.toString())
},
new Object[]{
"479281691111377", (Predicate<Map<String, String>>) map -> "{birthday=1969-11-11, sex=男, age=51}".equals(map.toString())
}
};
}
}

执行结果

image-20210727160700052

JUnit5测试方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package com.spock.example.java;

import com.spock.example.utils.IDNumberUtils;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;

import java.util.Map;
import java.util.stream.Stream;

import static org.junit.jupiter.params.provider.Arguments.arguments;

/**
* 使用JUnit5的ParameterizedTest进行参数化测试
*
* @author jinglv
* @date 2021/7/27 3:04 下午
*/
class IDNumberUtilsJunit5Test {

@ParameterizedTest
@MethodSource("idNos")
void getBirAgeSex(String certificateNo, String result) {
Map<String, String> minuteMap = IDNumberUtils.getBirAgeSex(certificateNo);
Assertions.assertEquals(String.valueOf(minuteMap), result);
}

static Stream<Arguments> idNos() {
return Stream.of(
arguments("310168199809187333", "{birthday=1998-09-18, sex=男, age=23}"),
arguments("320168200212084268", "{birthday=2002-12-08, sex=女, age=19}"),
arguments("330168199301214267", "{birthday=1993-01-21, sex=女, age=28}"),
arguments("411281870628201", "{birthday=1987-06-28, sex=男, age=34}"),
arguments("427281730307862", "{birthday=1973-03-07, sex=女, age=48}"),
arguments("479281691111377", "{birthday=1969-11-11, sex=男, age=52}")
);
}
}

执行结果:

image-20210727160941088

Spock测试方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package com.spock.example.groovy

import spock.lang.Specification
import spock.lang.Unroll

/**
* 使用Groovy Spock的测试
* @author jinglv* @date 2021/7/20 5:12 下午
*/
class IDNumberUtilsTest extends Specification {
@Unroll
def "身份证号:#idNo 的生日,性别,年龄是:#result"() {
expect: "when + then 组合"
IDNumberUtils.getBirAgeSex(idNo) == result

where: "表格方式测试不同的分支逻辑"
idNo || result
"310168199809187333" || ["birthday": "1998-09-18", "sex": "男", "age": "22"]
"320168200212084268" || ["birthday": "2002-12-08", "sex": "女", "age": "18"]
"330168199301214267" || ["birthday": "1993-01-21", "sex": "女", "age": "27"]
"411281870628201" || ["birthday": "1987-06-28", "sex": "男", "age": "33"]
"427281730307862" || ["birthday": "1973-03-07", "sex": "女", "age": "47"]
"479281691111377" || ["birthday": "1969-11-11", "sex": "男", "age": "51"]
}
}

执行结果

image-20210727161118082

JUnit4与Spock对比

下面的对比图是针对”根据身份证号码获取出生日期、性别、年龄”方法实现的单元测试,左边是我们常用的Junit的写法,右边是Spock的写法,红框圈出来的是一样的功能在Junit和Spock上的代码实现

image-20210720173003590

对比结果:

右边一栏使用Spock写的单测代码上语法简洁,表格方式测试覆盖多分支场景也更直观,提升开发效率,更适合敏捷开发

JUnit5与Spock对比

image-20210727162020349

单元测试代码的可读性和后期维护

微服务架构下,很多场景需要依赖其他接口返回的结果才能验证自己代码的逻辑,这样就需要使用 mock 工具,但 JMock 或 Mockito 的语法比较繁琐,再加上单测代码不像业务代码那么直观,不能完全按照业务流程的思路写单测,以及开发同学对单测代码可读性的不重视,最终导致测试代码难于阅读,维护起来更是难上加难。

可能自己写完的测试,过几天再看就云里雾里了(当然添加注释会好很多),再比如改了原来的代码逻辑导致单测执行失败,或者新增了分支逻辑,单测没有覆盖到,随着后续版本的迭代,会导致单测代码越来越臃肿和难以维护。

Spock 提供多种语义标签,如: given、when、then、expect、where、with、and 等,从行为上规范单测代码,每一种标签对应一种语义,让我们的单测代码结构具有层次感,功能模块划分清晰,便于后期维护。

Spock 自带 mock 功能,使用上简单方便(Spock 也支持扩展第三方 mock 框架,比如 power mock)保证代码更加规范,结构模块化,边界范围清晰,可读性强,便于扩展和维护,用自然语言描述测试步骤,让非技术人员也能看懂测试代码。

示例

被测试类

比如下面的业务代码:调用用户接口或者从数据库获取用户信息,然后做一些转换和判断逻辑(只做简单的示例)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
package com.spock.example.service;

import com.spock.example.dao.MoneyDAO;
import com.spock.example.dao.UserDAO;
import com.spock.example.dto.UserDTO;
import com.spock.example.vo.UserVO;
import org.springframework.beans.factory.annotation.Autowired;

import java.util.List;

/**
* 用户服务
*
* @author jinglv
* @date 2021/7/20 5:46 下午
*/
public class UserService {
@Autowired
private UserDAO userDAO;

@Autowired
private MoneyDAO moneyDAO;

/**
* 通过userId获取用户信息
*
* @param uid 用户id
* @return 返回用户信息
*/
public UserVO getUserById(int uid) {
List<UserDTO> users = userDAO.getUserInfo();
UserDTO userDTO = users.stream().filter(u -> u.getId() == uid).findFirst().orElse(null);
UserVO userVO = new UserVO();
if (null == userDTO) {
return userVO;
}
userVO.setId(userDTO.getId());
userVO.setName(userDTO.getName());
userVO.setSex(userDTO.getSex());
userVO.setAge(userDTO.getAge());
// 显示邮编
if ("上海".equals(userDTO.getProvince())) {
userVO.setAbbreviation("沪");
userVO.setPostCode(200000);
}
if ("北京".equals(userDTO.getProvince())) {
userVO.setAbbreviation("京");
userVO.setPostCode(100000);
}
// 手机号处理
if (null != userDTO.getTelephone() && !"".equals(userDTO.getTelephone())) {
userVO.setTelephone(userDTO.getTelephone().substring(0, 3) + "****" + userDTO.getTelephone().substring(7));
}
return userVO;
}
}

JUnit测试方法

pom引入依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
<!-- jmockit,只是为了演示对比,Spock不需要引入该依赖 -->
<dependency>
<groupId>org.jmockit</groupId>
<artifactId>jmockit</artifactId>
<version>1.39</version>
<exclusions>
<exclusion>
<groupId>com.google.code.findbugs</groupId>
<artifactId>jsr305</artifactId>
</exclusion>
</exclusions>
<scope>test</scope>
</dependency>

注意:当前使用版本是1.39,如果使用高版本则不支持以下语法,需要查询资料进行修改

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
package com.spock.example.java;

import com.spock.example.dao.UserDAO;
import com.spock.example.dto.UserDTO;
import com.spock.example.service.UserService;
import com.spock.example.vo.UserVO;
import mockit.Injectable;
import mockit.Mock;
import mockit.MockUp;
import mockit.integration.junit4.JMockit;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;

import java.util.ArrayList;
import java.util.List;

import static mockit.Deencapsulation.setField;

/**
* 用户服务测试类-JUnit
*
* @author jinglv
* @date 2021/7/20 5:52 下午
*/
@RunWith(JMockit.class)
public class UserServiceTest {

private UserService processor = new UserService();

@Injectable
private UserDAO userDAO;

@Test
public void getUserById() {
new MockUp<UserDAO>() {
@Mock
List<UserDTO> getUserInfo() {
List<UserDTO> users = new ArrayList<>();
UserDTO user1 = new UserDTO();
user1.setId(1);
user1.setName("张三");
user1.setProvince("上海");
users.add(user1);
UserDTO user2 = new UserDTO();
user2.setId(2);
user2.setName("李四");
user2.setProvince("江苏");
users.add(user2);
return users;
}
};
setField(processor, "userDAO", userDAO);
UserVO response = processor.getUserById(1);
Assert.assertEquals("张三", response.getName());
Assert.assertEquals("沪", response.getAbbreviation());
Assert.assertEquals(200000, response.getPostCode());
}
}

执行结果

image-20210727163243271

Spock测试方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package com.spock.example.groovy

import com.spock.example.dao.MoneyDAO
import com.spock.example.dao.UserDAO
import com.spock.example.dto.UserDTO
import com.spock.example.service.UserService
import spock.lang.Specification

/**
* 用户服务测试类
* @author jinglv* @date 2021/7/20 6:02 下午
*/
class UserServiceTest extends Specification {
def userService = new UserService()
def userDAO = Mock(UserDAO)
def moneyDAO = Mock(MoneyDAO)

void setup() {
userService.userDAO = userDAO
userService.moneyDAO = moneyDAO
}

def "GetUserById"() {
given: "设置请求参数"
def user1 = new UserDTO(id: 1, name: "张三", province: "上海")
def user2 = new UserDTO(id: 2, name: "李四", province: "江苏")

and: "mock掉接口返回的用户信息"
userDAO.getUserInfo() >> [user1, user2]

when: "调用获取用户信息方法"
def response = userService.getUserById(1)

then: "验证返回结果是否符合预期值"
with(response) {
name == "张三"
abbreviation == "沪"
postCode == 200000
}
}
}

Junit与Spock对比

image-20210720181558661

对比结果:

  • 左边的 junit 单测代码冗余,缺少结构层次,可读性差,随着后续迭代势必会导致代码的堆积,后期维护成本会越来越高。
  • 右边的单测代码 spock 会强制要求使用 given、when、then 这样的语义标签(至少一个),否则编译不通过,这样保证代码更加规范,结构模块化,边界范围清晰,可读性强,便于扩展和维护,用自然语言描述测试步骤,让非技术人员也能看懂测试代码(given 表示输入条件,when 触发动作,then 验证输出结果)
  • Spock 自带的 mock 语法也非常简单:”userDAO.getUserInfo() >> [user1, user2]”
  • 两个右箭头”>>”表示即模拟 getUserInfo 接口的返回结果,再加上使用的 groovy 语言,可以直接使用”[]”中括号表示返回的是 List 类型(具体语法会在下一篇讲到)

单元测试不仅仅是为了达到覆盖率统计,更重要的是验证业务代码的健壮性、逻辑的严谨性以及设计的合理性

在项目初期为了赶进度,可能没时间写单测,或者这个时期写的单测只是为了达到覆盖率要求(因为有些公司在发布前会使用 jacoco 等单测覆盖率工具来设置一个标准,比如新增代码必须达到 80%的覆盖率才能发布)

再加上传统的单测是使用 java 这种强类型语言写的,以及各种底层接口的 mock 导致写起单测来繁琐费时。

这时写的单测代码比较粗糙,颗粒度比较大,缺少对单测结果值的有效验证,这样的单元测试对代码质量的验证和提升无法完全发挥作用,更多的是为了测试而测试

最后大家不得不接受“虽然写了单测,但却没什么鸟用”的结果。

示例

比如下面这段业务代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 根据汇率计算金额
*
* @param userVO 用户信息
*/
public void setOrderAmountByExchange(UserVO userVO) {
if (null == userVO.getUserOrders() || userVO.getUserOrders().size() <= 0) {
return;
}
for (OrderVO orderVO : userVO.getUserOrders()) {
BigDecimal amount = orderVO.getAmount();
// 获取汇率(调用汇率接口)
BigDecimal exchange = moneyDAO.getExchangeByCountry(userVO.getCountry());
// 根据汇率计算金额
amount = amount.multiply(exchange);
orderVO.setAmount(amount);
}
}

void 方法,没有返回结果,如何写单测测试这段代码的逻辑是否正确?即如何知道单测代码是否执行到了 for 循环里面的语句(可以通过查看覆盖率或打断点的方式确认,但这样太麻烦了,如何确保循环里面的金额是否计算正确?

使用Spock写的话就会方便很多,如下代码所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def "SetOrderAmountByExchange"() {
given: "设置请求参数"
def userVO = new UserVO(name: "James", country: "美国")
userVO.userOrders = [new OrderVO(orderNum: "1", amount: 10000), new OrderVO(orderNum: "2", amount: 1000)]

when: "调用设置订单金额的方法"
userService.setOrderAmountByExchange(userVO)

then: "验证调用获取最新汇率接口的行为是否符合预期: 一共调用2次, 第一次输出的汇率是0.1413, 第二次是0.1421"
// 验证for循环里的代码逻辑是否符合预期
2 * moneyDAO.getExchangeByCountry(_) >> 0.1413 >> 0.1421

and: "验证根据汇率计算后的金额结果是否正确"
with(userVO) {
userOrders[0].amount == 1413
userOrders[1].amount == 142.1
}
}

代码:2 * moneyDAO.getExchangeByCountry(_) >> 0.1413 >> 0.1421

表示在for循环中一共调用了2次获取汇率的接口,第一次汇率结果是0.1413,第二次是0.1421(模拟汇率接口的实时变动),然后在with里验证,类似于junit里的assert断言,验证汇率折算后的人民币的价格是否正确。

这样的好处就是:

  • 提升单测代码的可控性,方便验证业务代码的逻辑正确和是否合理, 这正是**BDD(行为驱动开发)**思想的一种体现
  • 因为代码的可测试性是衡量代码质量的重要标准, 如果代码不容易测试, 那就要考虑重构了, 这也是单元测试的一种正向作用

学习大佬文章:https://javakk.com/264.html