spock初探

  1. 1. 背景
  2. 2. Junit单元测试的痛点

背景

项目维护的久了,业务逻辑就会因为需求导致越来越多的分支,也可能在开发过程中cv的时候疏忽,忘记修改了一个参数,直到上线的时候bug才体现出来。这就陷入了一个没有测试导致开发过程中的bug带入了线上环境的尴尬境地。

实际上一直都想找个时间对项目写一份测试,正常的测试框架实在太难用,再加上没有太多的时间,所以这个事情一拖再拖,在前阵子无意中看到了一款测试框架——Spock,稍微了解了一下后想尝试一下这个框架来编写测试。前几天终于忙完了OJ的改造,立马就来玩玩spock了。

网上关于Spock的资料比较简单,包括官网的demo也是如此,并且因为spock2增加了很多特性(目前还在不断的更新中),网上的资料存在过多的过时内容,所以在写的过程中遇见了无数的问题,不过总算是解决了,所以写篇博客来记录一下。

Junit单元测试的痛点

现在我有一个AccountInput的DTO用来接收前端请求,其父类BaseDTO提供了2个final方法:

  • convert:将dto转换成对应的实体类
  • convertAndUpdate:将DTO中的字段对实体的相应字段进行覆盖。

但是有些字段我不需要覆盖怎么办,所以额外提供了一个@CopyIgnore注解用于忽略 convert/update 过程中某些不需要覆盖的字段

AccountInput.class
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Data
public class AccountInput extends BaseDTO<Account> {

// 指定该参数在update时不对实体进行覆盖,避免用户构造请求修改某些不允许修改的字段
@CopyIgnore(groups = IgnoreType.UPDATE)
private String username;

@CopyIgnore(groups = IgnoreType.UPDATE)
private String password;

private String realName;

private String avatarUrl;

private String avatar;
}
BaseDTO.class
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* @author teble
* @date 2020/10/15 10:43
*/
public abstract class BaseDTO<T> {

public final T convert() {
this.convertBefore(this);
Class<?> entityClass = this.getEntityClass();
Object o = CopyPropertyUtils.generateInstance(this, entityClass);
this.convertAfter(CastUtils.cast(o));
return CastUtils.cast(o);
}

public final T convertAndUpdate(T entity) {
this.convertBefore(this);
CopyPropertyUtils.copyProperties(this, entity);
this.convertAfter(CastUtils.cast(entity));
return CastUtils.cast(entity);
}
}

然后我们就针对这部分逻辑取编写一份正常的junit测试来康康,为了确保测试的完善,我们的测试肯定得有多组输入,多组输入的方式有很多种,这里我们先采用一个相对简单方便的@CsvSource来进行多组测试用例的输入。

junit测试代码及结果
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
/**
* @author teble
* @date 2020/11/25 14:02
*/
class AccountInputTest {

/**
* {@link CsvSource}注解导入数据,空字符串默认为null,
* 如果需要注入null参数也可指定{@link CsvSource#nullValues()} 来实现
*/
@ParameterizedTest
@CsvSource(value = {"1,1,1,1", ",,null,"})
@DisplayName("Input类型转换为实体测试")
void testDto2Entity(String username, String password, String realName, String avatar) {
AccountInput accountInput = new AccountInput()
.setUsername(username)
.setPassword(password)
.setRealName(realName)
.setAvatar(avatar);
Account account = accountInput.convert();
assertThat(account.getUsername()).isEqualTo(username);
assertThat(account.getPassword()).isEqualTo(password);
assertThat(account.getRealName()).isEqualTo(realName);
assertThat(account.getAvatar()).isEqualTo(avatar);
}

@ParameterizedTest
@CsvSource(value = {"admin,pass,ZhangSan,110,,new pass,LiSi,", "admin,,,,,,,avatar"})
@DisplayName("Input类型更新实体测试")
void testDtoUpdateEntity(String username, String password, String realName, String phone,
String iUsername, String iPassword, String iRealName, String iAvatar) {
Account old = new Account()
.setUsername(username)
.setPassword(password)
.setRealName(realName)
.setPhone(phone);
AccountInput accountInput = new AccountInput()
.setUsername(iUsername)
.setPassword(iPassword)
.setRealName(iRealName)
.setAvatar(iAvatar);
Account entity = accountInput.convertAndUpdate(old);
assertThat(entity.getUsername()).isEqualTo(username);
assertThat(entity.getPassword()).isEqualTo(password);
assertThat(entity.getRealName()).isEqualTo(iRealName);
assertThat(entity.getAvatar()).isEqualTo(iAvatar);
assertThat(entity.getPhone()).isEqualTo(phone);
}
}
junit

通过上面的代码我们可以发现,所有参数都是通过字符串方式进行传递然后通过切面获取测试的参数类型然后再转换成对应的类型进行注入的。在默认情况下空字符串会视为null进行参数注入,那么如果我们需要注入空字符串给String对象怎么办?手动将CsvSource#nullValues()设置为一个代表null的字符串即可(例如"None"),但是这又引入了一个新的问题,我有些时候需要None这个字符串怎么办,所以这时候就陷入了对象通过字符串表达造成矛盾之中

通过@ParametersDataProvider的方式虽然可以解决,但是无论哪个方法都比较复杂,往往一个十几行的代码,需要几十行甚至上百行的代码进行测试,而且采用@Parameters进行参数注入,需要构造函数进行配合,那么就变成了需要一整个测试类来测试一个方法。

junit测试代码及结果
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
/**
* @author teble
* @date 2021/12/31 10:55
*/
class AccountInputSpec extends Specification {

def "test accountInput convert entity"() {
given:
AccountInput accountInput = new AccountInput(
username: username, password: password, realName: realName, avatar: avatar
)

expect:
Account account = accountInput.convert()
account.username == username
account.password == password
account.realName == realName
account.avatar == avatar

where:
username | password | realName | avatar
"1" | "1" | "1" | "1"
null | null | "null" | null
}

def "test accountInput update entity"() {
given:
Account old = new Account(username: username, password: password, realName: realName, phone: phone)
AccountInput accountInput = new AccountInput(
username: iUsername, password: iPassword, realName: iRealName, avatar: avatar
)

expect:
Account entity = accountInput.convertAndUpdate(old)
entity.username == username
entity.password == password
entity.realName == iRealName
entity.avatar == avatar
entity.phone == phone

where:
username | password | realName | phone || iUsername | iPassword | iRealName | avatar
"admin" | "pass" | "ZhangSan" | "110" || null | "new pass" | "LiSi" | null
"admin" | null | null | null || null | null | "" | "avatar"
}
}

spock

不过最让我动心的还是spock对于条件分支测试的直观程度,这里上个图举个简单的例子(当然这个例子举的可能不够好)

如果在if/else很多的复杂场景下,编写测试代码的成本就很高,为了分支的覆盖率,编写的测试代码长度可能远远超过了被测试的代码。当然JUnit的@Parametered参数化注解或者DataProvider可以解决多数据分支问题,但是编写起来非常麻烦,而且不够直观,如果某个用例出错报错也不够详细。

但是spock的where标签,基于spock得天独厚的数据表语法糖,测试代码覆盖所有的if分支逻辑,我们要做的仅仅是编写一份表格,就能完成一份复杂的分支测试,开发效率更高,更适合敏捷开发。