你所熟知的单元测试工具有什么呢? JUnit? JMock? 还是 Mockito? 相较于以上的传统单元测试框架,Spock 是一个更加优雅的测试框架,在开发效率、可读性和维护性方面都有很大的提升。

Spock 结合了 Groovy动态语言的特点,提供了各种各样的标签,如 given, when, then, expect, where 等,并且采用简单、通用的结构化的描述语言,使得测试用例更加易读、高效。

对于分布式微服务架构,服务与服务之间的依赖关系错综复杂。即使是在同一个服务内也会耦合多个模块,业务功能的结果需要依赖于其他模块的数据。所以,如果我们想要测试自己的代码时,就需要将其他模块进行mock,这样才可以验证我们的代码是否正确是否符合逻辑结果的预期。

由于JUnit单纯用于测试,并不提供Mock功能,以及JMock、Mockito等框架的Mock功能相对繁琐。Spock通过提供规范性的描述,定义多种标签等,从语义层面规范了代码的编写。

让我们来试试Spock吧!

导入Spock依赖

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

导入可以运行Groovy代码的插件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<build>
<plugins>
<plugin>
<groupId>org.codehaus.gmavenplus</groupId>
<artifactId>gmavenplus-plugin</artifactId>
<version>1.13.1</version>
<executions>
<execution>
<goals>
<goal>compile</goal>
<goal>compileTests</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>

对于下面一个需要测试的模块

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
public class SpockTest {
public static double calc(double income) {
BigDecimal tax;
BigDecimal salary = BigDecimal.valueOf(income);
if (income <= 0) {
return 0;
}
if (income > 0 && income <= 3000) {
BigDecimal taxLevel = BigDecimal.valueOf(0.03);
tax = salary.multiply(taxLevel);
} else if (income > 3000 && income <= 12000) {
BigDecimal taxLevel = BigDecimal.valueOf(0.1);
BigDecimal base = BigDecimal.valueOf(210);
tax = salary.multiply(taxLevel).subtract(base);
} else if (income > 12000 && income <= 25000) {
BigDecimal taxLevel = BigDecimal.valueOf(0.2);
BigDecimal base = BigDecimal.valueOf(1410);
tax = salary.multiply(taxLevel).subtract(base);
} else if (income > 25000 && income <= 35000) {
BigDecimal taxLevel = BigDecimal.valueOf(0.25);
BigDecimal base = BigDecimal.valueOf(2660);
tax = salary.multiply(taxLevel).subtract(base);
} else if (income > 35000 && income <= 55000) {
BigDecimal taxLevel = BigDecimal.valueOf(0.3);
BigDecimal base = BigDecimal.valueOf(4410);
tax = salary.multiply(taxLevel).subtract(base);
} else if (income > 55000 && income <= 80000) {
BigDecimal taxLevel = BigDecimal.valueOf(0.35);
BigDecimal base = BigDecimal.valueOf(7160);
tax = salary.multiply(taxLevel).subtract(base);
} else {
BigDecimal taxLevel = BigDecimal.valueOf(0.45);
BigDecimal base = BigDecimal.valueOf(15160);
tax = salary.multiply(taxLevel).subtract(base);
}
return tax.setScale(2, BigDecimal.ROUND_HALF_UP).doubleValue();
}
}

我们可以使用Spock来进行测试,编写一个groovy程序

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
@Subject(SpockTest)
class TestSpock extends Specification {
@Unroll
def "个税计算,收入:#income, 个税:#result"() {
expect: "when + then 的组合"
SpockTest.calc(income) == result

where: "表格方式测试不同的分支逻辑"
income || result
-1 || 0
0 || 0
2999 || 89.97
3000 || 90.0
3001 || 90.1
11999 || 989.9
12000 || 990.0
12001 || 990.2
24999 || 3589.8
25000 || 3590.0
25001 || 3590.25
34999 || 6089.75
35000 || 6090.0
35001 || 6090.3
54999 || 12089.7
55000 || 12090
55001 || 12090.35
79999 || 20839.65
80000 || 20840.0
80001 || 20840.45
}
}

使用Spock提供的where标签,可以让我们通过表格的方式来测试多种分支。表格方式测试覆盖分支场景更加直观,开发效率高,更适合敏捷开发。

除了where标签,Spock还提供了很多其他标签,如 given, when, then, expect 等,每一种标签对应一种语义,让单元测试代码结构具有层次感,功能模块划分更加清晰,也便于后期的维护。

来看这样一个待测试方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public StudentVO getStudentById(int id) {
List<StudentDTO> students = studentDao.getStudentInfo();
StudentDTO studentDTO = students.stream().filter(u -> u.getId() == id).findFirst().orElse(null);
StudentVO studentVO = new StudentVO();
if (studentDTO == null) {
return studentVO;
}
studentVO.setId(studentDTO.getId());
studentVO.setName(studentDTO.getName());
studentVO.setSex(studentDTO.getSex());
studentVO.setAge(studentDTO.getAge());
// 邮编
if ("上海".equals(studentDTO.getProvince())) {
studentVO.setAbbreviation("沪");
studentVO.setPostCode("200000");
}
if ("北京".equals(studentDTO.getProvince())) {
studentVO.setAbbreviation("京");
studentVO.setPostCode("100000");
}
return studentVO;
}

我们可以直接使用Spock来进行测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class StudentSerivceSpec extends Specification {
def dao = Mock(StudentDao)
def studentService = new StudentService(dao)

def "测试 getStudentById"() {
given: "设置请求参数"
def student1 = new StudentDTO(1, "张三", "男", 18, "上海")
def student2 = new StudentDTO(2, "李四", "女", 20, "北京")

and: "mock studentDao的返回值"
dao.getStudentInfo() >> [student1, student2]

when: "获取学生信息"
def response = studentService.getStudentById(1)

then: "验证返回值"
with(response) {
id == 1
abbreviation == "沪"
postCode == "200000"
}
}
}

Spock会强制要求编程人员使用given、when、then这样的语义标签(至少一个),否则编译不通过,这样就能保证代码更加规范,结构模块化,边界范围清晰,可读性强,便于扩展和维护。而且使用了自然语言描述测试步骤,让非技术人员也能看懂测试代码(given表示输入条件,when触发动作,then验证输出结果)。

dao.getStudentInfo() >> [student1, student2] 表示当调用 dao.getStudentInfo() 方法时,返回 student1student2 两个对象,以列表的形式。即两个右箭头>>表示模拟getStudentInfo接口的返回结果,[]中括号表示返回的是List类型。

接下来看看循环测试

1
2
3
4
5
6
7
8
9
10
11
public void calculatePrice(OrderVO order){
BigDecimal amount = BigDecimal.ZERO;
for (SkuVO sku : order.getSkus()) {
Integer skuId = sku.getSkuId();
BigDecimal skuPrice = sku.getSkuPrice();
BigDecimal discount = BigDecimal.valueOf(discountDao.getDiscount(skuId));
BigDecimal price = skuPrice * discount;
amount = amount.add(price);
}
order.setAmount(amount.setScale(2, BigDecimal.ROUND_HALF_DOWN));
}

使用Spock的循环测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def "测试 calculatePrice"() {
given: "设置请求参数"
def sku1 = new SkuVO(1, BigDecimal.valueOf(100))
def sku2 = new SkuVO(2, BigDecimal.valueOf(200))
def order = new OrderVO()
order.setSkus([sku1, sku2])

and: "mock discountDao的返回值"
def discountDao = Mock(DiscountDao)
2 * discountDao.getDiscount(1) >> 0.9 >> 0.8

when: "计算价格"
service.calculatePrice(order)

then: "验证返回值"
order.amount == 260
}

2 * discountDao.getDiscount(_) >> 0.95 >> 0.8在for循环中一共调用了2次,第一次返回结果0.9,第二次返回结果0.8,最后再进行验证。

接下来考虑使用Spring自动注入的对象

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
@Service
public class StudentService {
@Autowired
private StudentDao studentDao;
public StudentVO getStudentById(int id) {
List<StudentDTO> students = studentDao.getStudentInfo();
StudentDTO studentDTO = students.stream().filter(u -> u.getId() == id).findFirst().orElse(null);
StudentVO studentVO = new StudentVO();
if (studentDTO == null) {
return studentVO;
}
studentVO.setId(studentDTO.getId());
studentVO.setName(studentDTO.getName());
studentVO.setSex(studentDTO.getSex());
studentVO.setAge(studentDTO.getAge());
// 邮编
if ("上海".equals(studentDTO.getProvince())) {
studentVO.setAbbreviation("沪");
studentVO.setPostCode("200000");
}
if ("北京".equals(studentDTO.getProvince())) {
studentVO.setAbbreviation("京");
studentVO.setPostCode("100000");
}
return studentVO;
}
}

其中studentDao是使用Spring注入的实例对象,加上我们并不知道studentDao的具体实现,我们依旧可以使用Spock进行测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class StudentServiceSpec extends Specification {
def studentDao = Mock(StudentDao)
def tester = new StudentService(studentDao: studentDao)

def "test getStudentById"() {
given: "设置请求参数"
def student1 = new StudentDTO(id: 1, name: "张三", province: "北京")
def student2 = new StudentDTO(id: 2, name: "李四", province: "上海")

and: "mock studentDao返回值"
studentDao.getStudentInfo() >> [student1, student2]

when: "获取学生信息"
def response = tester.getStudentById(1)

then: "结果验证"
with(response) {
id == 1
abbreviation == "京"
postCode == "100000"
}
}
}

def studentDao = Mock(StudentDao)这一行代码使用Spock自带的Mock方法,构造一个studentDao的Mock对象。
在given块中,我们使用了new StudentDTO(id: 1, name: "张三", province: "北京")来构造一个StudentDTO对象,这是Groovy帮助我们实现的,Groovy默认会提供一个包含所有对象属性的构造方法。而且调用方式上可以指定属性名,类似于key:value的语法,非常人性化,方便在属性多的情况下构造对象,这样即使我们不知道StudentDTO有哪些构造函数,也能够构造出对象。

对于要指定返回多个值的话,可以使用3个右箭头>>>,比如:studentDao.getStudentInfo() >>> [[student1,student2],[student3,student4],[student5,student6]]

也可以写成这样:studentDao.getStudentInfo() >> [student1,student2] >> [student3,student4] >> [student5,student6]

对于函数getStudentInfo(String id)方法,有个参数id,这种情况下如果使用Spock的Mock模拟调用的话,可以使用下划线_匹配参数,表示任何类型的参数,多个逗号隔开,如果出现了函数重载,即存在多个同名函数,可以使用_ as参数类型的方式区别调用。