这是Spock系列的第一篇文章,整个专辑会介绍Spock的用途,为什么使用Spock?它能给我们带来什么好处?它和JUnit、JMock、Mockito有什么区别?我们平时写单元测试代码的常见问题和痛点,Spock又是如何解决的,Spock的代码怎么编写以及Spock的优势和缺点等内容,让大家对Spock有个客观的了解。
Spock是什么?
斯波克是国外一款优秀的测试框架,基于BDD思想,功能强大,能够让我们的测试代码规范化,结构层次清晰,结合groovy动态语言的特点以及自身提供的各种标签让编写测试代码更加高效和简洁,提供一种通用、简单、结构化的描述语言
引用官网的介绍如下(http://spockframework.org)
“ Spock是一个Java和Groovy应用程序的测试和规范框架。
它之所以能在人群中脱颖而出,是因为它优美而富有表现力的规范语言。
斯波克的灵感来自JUnit、RSpec、jMock、Mockito、Groovy、Scala、Vulcans ”
简单说Spock的特点如下:
- 让我们的测试代码更规范,内置多种标签来规范单测代码的语义,从而让我们的测试代码结构清晰,更具可读性,降低后期维护难度
- 提供多种标签,比如:
where
、with
、thrown
... 帮助我们应对复杂的测试场景 - 再加上使用groovy这种动态语言来编写测试代码,可以让我们编写的测试代码更简洁,适合敏捷开发,提高编写单测代码的效率
- 遵从BDD行为驱动开发模式,不单是为了测试覆盖率而测试,有助于提升代码质量
- IDE兼容性好,自带mock功能
为什么使用Spock? Spock和JUnit、JMock、Mockito的区别在哪里?
收到现有的单测框架比如junit、jmock、mockito都是相对独立的工具,只是针对不同的业务场景提供特定的解决方案。
Junit单纯用于测试,不提供mock功能
微服务已经是互联网公司的主流技术架构,大部分的系统都是分布式,服务与服务之间一般通过接口的方式交互,甚至服务内部也划分成多个module,很多业务功能需要依赖底层接口返回的数据才能继续剩下的流程,或者从数据库/Redis等存储设备上获取,或是从配置中心的某个配置获取。
这样就导致如果我们想要测试代码逻辑是否正确,就必须把这些依赖项(接口、Redis、DB、配置中心...)给mock掉。
如果接口不稳定或有问题则会影响我们代码的正常测试,所以我们要把调用接口的地方给模拟掉,让它返回指定的结果(提前准备好的数据),这样才能往下验证我们自己的代码是否正确,符合预期逻辑和结果。
JMock或Mockito虽然提供了mock功能,可以把接口等依赖屏蔽掉,但不提供对静态类静态方法的mock,PowerMock或Jmockit虽然提供静态类和方法的mock,但它们之间需要整合(junit+mockito+powermock),语法繁琐,而且这些工具并没有告诉你“单元测试代码到底应该怎么写?”
工具多了也会导致不同的人写出的单元测试代码五花八门,风格迥异。。。
Spock通过提供规范描述,定义多种标签(given
、when
、then
、where
等)去描述代码“应该做什么”,输入条件是什么,输出是否符合预期,从语义层面规范代码的编写。
Spock自带Mock功能,使用简单方便(也支持扩展其他mock框架,比如power mock),再加上groovy动态语言的强大语法,能写出简洁高效的测试代码,同时更方便直观的验证业务代码行为流转,增强我们对代码执行逻辑的可控性。
背景和初衷
网上关于Spock的资料比较简单,包括官网的demo,无法解决我们项目中的复杂业务场景,需要找到一套适合自己项目的成熟解决方案,所以觉得有必要把我们项目中使用Spock的经验分享出来, 帮助大家提升单测开发的效率和验证代码质量。
在熟练掌握Spock后我们项目组整体的单测开发效率提升了50%以上,代码可读性和维护性都得到了改善和提升。
适合人群
写Java单元测试的开发小伙伴和测试同学,所有的演示代码运行在IntelliJ IDEA中,spring-boot项目,基于Spock 1.3-groovy-2.5版本
Spock如何解决传统单元测试开发中的痛点
这篇主要讲下我们平时写单元测试过程中遇到的几种常见问题,分别使用JUnit和Spock如何解决,通过对比的方式给大家一个整体认识。
一. 单元测试代码开发的成本和效率
复杂场景的业务代码,在分支(if/else
)很多的情况下,编写单测代码的成本会相应增加,正常的业务代码或许只有几十行,但为了测试这个功能,要覆盖大部分的分支场景,写的测试代码可能远远不止几十行
举个我们生产环境前不久发生的一起事故:有个功能上线1年多一直都正常,没有出过问题,但最近有个新的调用方请求的数据不一样,走到了代码中一个不常用的分支逻辑,导致了bug,直接抛出异常阻断了主流程,好在调用方请求量不大。。。
估计当初写这段代码的同学也认为很小几率会走到这个分支,虽然当时也写了单元测试代码,但分支较多,刚好漏掉了这个分支逻辑的测试,给日后上线留下了隐患
这也是我们平时写单元测试最常遇到的问题:要达到分支覆盖率高要求的情况下,if/else
有不同的结果,传统的单测写法可能要多次调用,才能覆盖全部的分支场景,一个是写单测麻烦,同时也会增加单测代码的冗余度
虽然可以使用junit的@parametered
参数化注解或者dataprovider的方式,但还是不够方便直观,而且如果其中一次分支测试case出错的情况下,报错信息也不够详尽。
比如下面的示例演示代码,根据输入的身份证号码识别出生日期、性别、年龄等信息,这个方法的特点就是有很多if...else...
的分支嵌套逻辑
/**
* 身份证号码工具类<p>
* 15位:6位地址码+6位出生年月日(900101代表1990年1月1日出生)+3位顺序码
* 18位:6位地址码+8位出生年月日(19900101代表1990年1月1日出生)+3位顺序码+1位校验码
* 顺序码奇数分给男性,偶数分给女性。
* @author 公众号:Java老K
* 个人博客:www.javakk.com
*/
public class IDNumberUtils {
/**
* 通过身份证号码获取出生日期、性别、年龄
* @param certificateNo
* @return 返回的出生日期格式:1990-01-01 性别格式:F-女,M-男
*/
public static Map<String, String> getBirAgeSex(String certificateNo) {
String birthday = "";
String age = "";
String sex = "";
int year = Calendar.getInstance().get(Calendar.YEAR);
char[] number = certificateNo.toCharArray();
boolean flag = true;
if (number.length == 15) {
for (int x = 0; x < number.length; x++) {
if (!flag) return new HashMap<>();
flag = Character.isDigit(number[x]);
}
} else if (number.length == 18) {
for (int x = 0; x < number.length - 1; x++) {
if (!flag) return new HashMap<>();
flag = Character.isDigit(number[x]);
}
}
if (flag && certificateNo.length() == 15) {
birthday = "19" + certificateNo.substring(6, 8) + "-"
+ certificateNo.substring(8, 10) + "-"
+ certificateNo.substring(10, 12);
sex = Integer.parseInt(certificateNo.substring(certificateNo.length() - 3,
certificateNo.length())) % 2 == 0 ? "女" : "男";
age = (year - Integer.parseInt("19" + certificateNo.substring(6, 8))) + "";
} else if (flag && certificateNo.length() == 18) {
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<>();
map.put("birthday", birthday);
map.put("age", age);
map.put("sex", sex);
return map;
}
}
针对上面这种场景,spock提供了where标签,让我们可以通过表格的方式方便测试多种分支
下面的对比图是针对"根据身份证号码获取出生日期、性别、年龄"方法实现的单元测试,左边是我们常用的Junit的写法,右边是Spock的写法,红框圈出来的是一样的功能在Junit和Spock上的代码实现 (两边执行的单测结果一样,点击放大查看差异)
对比结果:
右边一栏使用Spock写的单测代码上语法简洁,表格方式测试覆盖多分支场景也更直观,提升开发效率,更适合敏捷开发
(关于Spock代码的具体语法会在后续文章讲解)
二. 单元测试代码的可读性和后期维护
微服务架构下,很多场景需要依赖其他接口返回的结果才能验证自己代码的逻辑,这样就需要使用mock工具,但JMock或Mockito的语法比较繁琐,再加上单测代码不像业务代码那么直观,不能完全按照业务流程的思路写单测,以及开发同学对单测代码可读性的不重视,最终导致测试代码难于阅读,维护起来更是难上加难
可能自己写完的测试,过几天再看就云里雾里了(当然添加注释会好很多),再比如改了原来的代码逻辑导致单测执行失败,或者新增了分支逻辑,单测没有覆盖到,随着后续版本的迭代,会导致单测代码越来越臃肿和难以维护
Spock提供多种语义标签,如: given、when、then、expect、where、with、and 等,从行为上规范单测代码,每一种标签对应一种语义,让我们的单测代码结构具有层次感,功能模块划分清晰,便于后期维护
Spock自带mock功能,使用上简单方便(Spock也支持扩展第三方mock框架,比如power mock)保证代码更加规范,结构模块化,边界范围清晰,可读性强,便于扩展和维护,用自然语言描述测试步骤,让非技术人员也能看懂测试代码
比如下面的业务代码:
调用用户接口或者从数据库获取用户信息,然后做一些转换和判断逻辑(这里的业务代码只是列举常见的业务场景,方便演示)
/**
* 用户服务
* @author 公众号:Java老K
* 个人博客:www.javakk.com
*/
@Service
public class UserService {
@Autowired
UserDao userDao;
@Autowired
MoneyDAO moneyDAO;
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和Spock实现的单元测试,左边是Junit的写法,右边是Spock,红框圈出来的是一样的功能在Junit和Spock上的实现 (两边执行的单测结果一样,点击放大查看差异)
对比结果:
左边的junit单测代码冗余,缺少结构层次,可读性差,随着后续迭代势必会导致代码的堆积,后期维护成本会越来越高。
右边的单测代码spock会强制要求使用given
、when
、then
这样的语义标签(至少一个),否则编译不通过,这样保证代码更加规范,结构模块化,边界范围清晰,可读性强,便于扩展和维护,用自然语言描述测试步骤,让非技术人员也能看懂测试代码(given
表示输入条件,when
触发动作,then
验证输出结果)
Spock自带的mock
语法也非常简单:
userDao.getUserInfo() >> [user1, user2]
两个右箭头">>"表示即模拟getUserInfo
接口的返回结果,再加上使用的groovy语言,可以直接使用"[]"中括号表示返回的是List类型(具体语法会在下一篇讲到)
三. 单元测试不仅仅是为了达到覆盖率统计,更重要的是验证业务代码的健壮性、逻辑的严谨性以及设计的合理性
在项目初期为了赶进度,可能没时间写单测,或者这个时期写的单测只是为了达到覆盖率要求(因为有些公司在发布前会使用jacoco等单测覆盖率工具来设置一个标准,比如新增代码必须达到80%的覆盖率才能发布)
再加上传统的单测是使用java这种强类型语言写的,以及各种底层接口的mock导致写起单测来繁琐费时
这时写的单测代码比较粗糙,颗粒度比较大,缺少对单测结果值的有效验证,这样的单元测试对代码质量的验证和提升无法完全发挥作用,更多的是为了测试而测试
最后大家不得不接受“虽然写了单测,但却没什么鸟用”的结果
比如下面这段业务代码示例:
void
方法,没有返回结果,如何写单测测试这段代码的逻辑是否正确?即如何知道单测代码是否执行到了for
循环里面的语句(可以通过查看覆盖率或打断点的方式确认,但这样太麻烦了),如何确保循环里面的金额是否计算正确?
大家可以想下使用junit的方式写单元测试如何验证这几点?
/**
* 用户服务
* @author 公众号:Java老K
* 个人博客:www.javakk.com
*/
@Service
public class UserService {
@Autowired
MoneyDAO moneyDAO;
/**
* 根据汇率计算金额
* @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);
}
}
}
使用Spock写的话就会方便很多,如下图所示:
其中:
2 * moneyDAO.getExchangeByCountry(_) >> 0.1413 >> 0.1421
这行代码表示在for
循环中一共调用了2次获取汇率的接口,第一次汇率结果是0.1413,第二次是0.1421,(模拟汇率接口的实时变动),然后在with
里验证,类似于junit里的assert
断言,验证汇率折算后的人民币价格是否正确(完整代码会在后续文章中列出)
这样的好处就是:
提升单测代码的可控性,方便验证业务代码的逻辑正确和是否合理, 这正是BDD(行为驱动开发)思想的一种体现
因为代码的可测试性是衡量代码质量的重要标准, 如果代码不容易测试, 那就要考虑重构了, 这也是单元测试的一种正向作用
这一篇文章从3个方面对比展示了Spock的特点和优势,后面会详细讲解Spock的各种用法(结合具体的业务场景),以及groovy的一些语法和注意事项
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。