在以前的关于swagger文章里提到过,程序员最讨厌的两件事,一件是别人不写文档,另外一件就是本身写文档。这里若是把文档换成单元测试也一样成立。 每一个开发人员都明白单元测试的做用,也都知道代码覆盖率越高越好。高覆盖率的代码,相对来讲出现 BUG 的几率就越低,在线上运行就越稳定,接的锅也就越少,就也不会惧怕测试同事忽然的关心。 既然这么多好处,为何还会讨厌他呢?至少在我看来,单测有以下几点让我喜欢不起来的理由。 第一,要额外写不少不少的代码,一个高覆盖率的单测代码,每每比你要测试的,真正开发的业务代码要多,甚至是业务代码的好几倍。这让人以为难以接受,你想一想开发 5 分钟,单测 2 小时是什么样的心情。并且并非单测写完就没事了,后面业务要是变动了,你所写的单测代码也要同步维护。 第二,即便你有那个耐心去写单测,可是在当前这个拼速度挤时间的大环境下,会给你那么多写单测的时间吗?写一个单测的时间能够实现一个需求,你会如何去选? 第三,写单测一般是一件很无趣的事,由于他比较死,主要目的就是为了验证,相比之下他更像是个体力活,没有真正写业务代码那种创造的成就感。写出来,验证不出bug很失落,白写了,验证出bug又感到本身是在打本身脸。css
因此获得的结论就是不写单测?那么问题又来了,出来混早晚是要还的,上线出了问题,最终责任人是谁?不是提需求的产品、不是没发现问题的测试同窗,他们顶多就是连带责任。最该负责的确定是写这段代码的你。特别是对于那些从事金融、交易、电商等息息相关业务的开发人员,跟每行代码打交通的都是真金白银。每次明星搞事,微博就挂,已经被传为笑谈,毕竟只是娱乐相关,若是挂的是支付宝、微信,那用户就没有那么大的包容度了。这些业务若是出现严重问题,轻则扫地出门,而后整个职业生涯背负这个污点,重则直接从面向对象开发变成面向监狱开发。因此单元测试保护的不只仅是程序,更保护的是写程序的你。 最后得出了一个迫不得已的结论,单测是个让人又爱又恨的东西,是不想作但又不得不作的事情。虽然咱们没办法改变要写单测这件事,可是咱们能够改变怎么去写单元测试这件事。html
固然,本文不是教你用旁门左道的方法提升代码覆盖率。而是经过一个神奇的框架 spock 去提升你编写单元测试的效率。spock 这名称来源,我的猜想是由于《星际迷航》的同名人物(封面图)。那么spock 是如何提升编写单测的效率呢?我以为有如下几点: 第一,他能够用更少的代码去实现单元测试,让你能够更加专一于去验证结果而不是写单测代码的过程。那么他又是如何作到少写代码这件事呢?原来他使用一种叫作 groovy 的魔法。 groovy 实际上是一门基于 jvm 的动态语言。能够简单的理解成跑在 jvm 上的 python 或 js。说到这里,可能没有接触过动态语言的同窗,对它们都会有一个比较刻板的印象,太过于灵活,很容易出现问题,且可维护性差,因此有了那一句『动态一时爽,全家 xxx』的梗。首先,这些的确是他的问题,严格的说是使用不当时才带来的问题。因此主要仍是看使用的人。好比安卓领域的官方依赖管理工具 gradle 就是基于 groovy 开发的。 另外不要误觉得我学这门框架,还要多学一门语言,成本太大。其实大可没必要担忧,你若是会 groovy 固然更好,若是不会也没有关系。由于 groovy 是基于 java 的,因此彻底能够放心大胆的使用 java 的语法,某些要用到的 groovy 独有的语法不多,并且后面都会告诉你。 第二,他有更好的语义化,让你的单测代码可读性更高。 语义化这个词可能不太好理解。举两个例子来讲吧,第一个是语义化比较好的语言 -- HTML。他的语法特色就是标签,不一样的类型放在不一样的标签里。好比 head 就是头部的信息,body 是主体内容的信息,table 就是表格的信息,对于没有编程经验的人来讲,也能够很容易理解。第二个是语义化比较差的语言 -- 正则。他能够说基本上没有语义这种东西,由此致使的直接问题就是,即便是你本身的写的正则,几天以后你都不知道当时写的是什么。好比下面这个正则,你能猜出他是什么意思吗?(能够留言回复)java
((?:(?:25[0-5]|2[0-4]\d|[01]?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d?\d))
<!--若是没有使得 spring boot,如下包能够省略--> <dependency> <groupid>org.springframework.boot</groupid> <artifactid>spring-boot-starter-test</artifactid> <scope>test</scope> </dependency> <!--引入spock 核心包--> <dependency> <groupid>org.spockframework</groupid> <artifactid>spock-core</artifactid> <version>1.3-groovy-2.5</version> <scope>test</scope> </dependency> <!--引入spock 与 spring 集成包--> <dependency> <groupid>org.spockframework</groupid> <artifactid>spock-spring</artifactid> <version>1.3-groovy-2.5</version> <scope>test</scope> </dependency> <!--引入 groovy 依赖--> <dependency> <groupid>org.codehaus.groovy</groupid> <artifactid>groovy-all</artifactid> <version>2.5.7</version> <scope>test</scope> </dependency>
注释已经标明,第一个包是 spring boot 项目须要使用的,若是你只是想使用 spock,只要最下面 3 个便可。其中第一个包 spock-core 提供了 spock 的核心功能,第二个包 spock-spring 提供了与 spring 的集成(不用 spring 的状况下也能够不引入)。 注意这两个包的版本号 -> 1.3-groovy-2.5。第一个版本号 1.3 其实表明是 spock 的版本,第二个版本号表明的是 spock 所要依赖的 groovy 环境的版本。最后一个包就是咱们要依赖的 groovy 。python
/* * * * * blog.coder4j.cn * * Copyright (C) 2016-2019 All Rights Reserved. * */ package cn.coder4j.study.example.spock; /** * @author buhao * @version Calculator.java, v 0.1 2019-10-30 10:34 buhao */ public class Calculator { /** * 加操做 * * @param num1 * @param num2 * @return */ public static int add(int num1, int num2) { return num1 + num2; } /** * 整型除操做 * * @param num1 * @param num2 * @return */ public static int divideInt(int num1, int num2) { return num1 / num2; } /** * 浮点型操做 * @param num1 * @param num2 * @return */ public static double divideDouble(double num1, double num2){ return num1 / num2; } }
这是一个很简单的计算器类。只写了三个方法,一个是加法的操做、一个整型的除法操做、一个浮点类型的除法操做。git
class CalculatorTest extends Specification { }
这里必定要注意,以前咱们已经说了 spock 是基于 groovy 。因此单测类的后缀不是 .java 而** .groovy**。千万不要建立成普通 java 类了。不然建立没有问题,可是写一些 groovy 语法会报错。若是你用的是 IDEA 能够经过以下方式建立,之前建立 Java 类咱们都是选择第一个选项,如今咱们选择第三个 Groovy Class 就能够了。 另外就是 spock 的测试类须要继承 **spock.lang.Specification **类。程序员
def "test add"(){ expect: Calculator.add(1, 1) == 2 }
def 是 groovy 的关键字,能够用来定义变量跟方法名。后面 "test add" 是你单元测试的名称,也能够用中文。最后重点说明的是 expect 这个关键字。 expect 字面上的意思是指望,咱们指望什么样的事情发生。在使用其它单测框架时,与之相似的是 assert 。好比 _Assert.assertEquals(_Calculator.add(_1 + 1), 2) _这样,表示咱们断言加操做传入1 与 1 相加结果为 2。若是结果是这样则用例经过,若是不是则用例失败。这与咱们上面的代码功能上完成一致。 expect 的语法意义就是在 expect 的块内,全部表达式成立则验证经过,反之有任一个不成立则验证失败。这里引入了一个块的概念。怎么理解 spock 的块呢?咱们上面说 spock 有良好的语义化及更好的阅读性就是由于这个块的做用。能够类比成 html 中的标签。html 的标签的范围是两个标签之间,而 spock 更简洁一点,从这个标签开始到下一个标签开始或代码结束的地方,就是他的范围。咱们只要看到 expect 这个标签就明白,他的范围内都是咱们预期要获得的结果。github
这里代码比较简单,参数我只使用了一次,因此直接写死。若是想复用,我就得把这些参数抽成变量。这个时候可使用 spock 的 given 块。given 的语法意义至关因而一个初始化的代码块。spring
def "test add with given"(){ given: def num1 = 1 def num2 = 1 def result = 2 expect: Calculator.add(num1, num2) == result }
固然你也能够像下面这样写,可是严重不推荐,由于虽然能够达到一样的效果,可是不符合 spock 的语义。就像咱们通常是在 head 里面引入 js、css,可是你在 body 或者任何标签里均可以引入,语法没有问题可是破坏了语义,不便理解与维护。数据库
// 反倒 def "test add with given"(){ expect: def num1 = 1 def num2 = 1 def result = 2 Calculator.add(num1, num2) == result }
若是你还想让语义更好一点,咱们能够把参数与结果分开定义,这个时候可使用 and 块。它的语法功能能够理解成同他上面最近的一个标签。编程
def "test add with given and"(){ given: def num1 = 1 def num2 = 1 and: def result = 2 expect: Calculator.add(num1, num2) == result }
看了上面例子,可能以为 spock 只是语义比较好,可是没有少写几行代码呀。别急,下面咱们就来看 spock 的一大杀器 where。
def "test add with expect where"(){ expect: Calculator.add(num1, num2) == result where: num1 | num2 | result 1 | 1 | 2 1 | 2 | 3 1 | 3 | 4 }
where 块能够理解成准备测试数据的地方,他能够跟 expect 组合使用。上面代码里 expect 块里面定义了三个变量 num一、num二、result。这些数据咱们能够在 where 块里定义。where 块使用了一种很像 markdown 中表格的定义方法。第一行或者说表头,列出了咱们要传数据的变量名称,这里要与 expect 中对应,不能少可是能够多。其它行都是数据行,与表头同样都是经过 『 | 』 号分隔。经过这样,spock 就会跑 3 次用例,分别是 1 + 2 = 二、1 + 2 = 三、1 + 3 = 4 这些用例。怎么样?是否是很方便,后面再扩充用例只要再加一行数据就能够了。
上面这些用例都是正常能够跑通的,若是是 IDEA 跑完以后会以下所示: 那么如今咱们看看若是有用例不经过会怎么样,把上面代码的最后一个 4 改为 5
def "test add with expect where"(){ expect: Calculator.add(num1, num2) == result where: num1 | num2 | result 1 | 1 | 2 1 | 2 | 3 1 | 3 | 5 }
再跑一次,IDEA 会出现以下显示 左边标注出来的是用例执行结果,能够看出来虽然有 3 条数据,其中 2 条数据是成功,可是只会显示总体的成功与否,因此显示未经过。可是 3 条数据,我怎么知道哪条没经过呢? 右边标注出来的是 spock 打印的的错误日志。能够很清楚的看到,在 num1 为 1,num2 为 3,result 为 5 而且 他们之间的判断关系为 == 的结果是 false 才是正确的。 spock 的这个日志打印的是至关历害,若是是比较字符串,还会计算异常字符串与正确字符串之间的匹配度,有兴趣的同窗,能够自行测试。 嗯,虽然能够经过日志知道哪一个用例没经过,可是仍是以为有点麻烦。spock 也知道这一点。因此他还同时提供了一个** @Unroll **注解。咱们在上面的代码上再加上这个注解:
@Unroll def "test add with expect where unroll"(){ expect: Calculator.add(num1, num2) == result where: num1 | num2 | result 1 | 1 | 2 1 | 2 | 3 1 | 3 | 5 }
运行结果以下: 经过添加** @Unroll** 注解,spock 自动把上面的代码拆分红了 3 个独立的单测测试,分别运行,运行结果更清晰了。 那么还能更清晰吗?固然能够,咱们发现 spock 拆分后,每一个用例的名称其实都是你写的单测方法的名称,而后后面加一个数组下标,不是很直观。咱们能够经过 groovy 的字符串语法,把变量放入用例名称中,代码以下:
@Unroll def "test add with expect where unroll by #num1 + #num2 = #result"(){ expect: Calculator.add(num1, num2) == result where: num1 | num2 | result 1 | 1 | 2 1 | 2 | 3 1 | 3 | 5 }
如上,咱们在方法名后加了一句 #num1 + #num2 = #result。这里有点相似咱们在 mybatis 或者一些模板引擎中使用的方法。# 号拼接声明的变量就能够了,执行后结果以下。 这下更清晰了。 另一点,就是 where 默认使用的是表格的这种形式:
where: num1 | num2 | result 1 | 1 | 2 1 | 2 | 3 1 | 3 | 5
很直观,可是这种形式有一个弊端。上面 『 | 』 号对的这么整齐。都是我一个空格一个 TAG 按出来的。虽然语法不要求对齐,可是逼死强迫症。不过,好在还能够有另外一种形式:
@Unroll def "test add with expect where unroll arr by #num1 + #num2 = #result"(){ expect: Calculator.add(num1, num2) == result where: num1 << [1, 1, 2] num2 << [1, 2, 3] result << [1, 3, 4] }
能够经过 『<<』 符(注意方向),把一个数组赋给变量,等同于上面的数据表格,没有表格直观,可是比较简洁也不用考虑对齐问题,这两种形式看我的喜爱了。
咱们都知道一个整数除以0 会有抛出一个『/ by zero』异常,那么若是断言这个异常呢。用上面 expect 不太好操做,咱们可使用另外一个相似的块** when ... then**。
@Unroll def "test int divide zero exception"(){ when: Calculator.divideInt(1, 0) then: def ex = thrown(ArithmeticException) ex.message == "/ by zero" }
**when ... then **一般是成对出现的,它表明着当执行了 when 块中的操做,会出现 then 块中的指望。好比上面的代码说明了,当执行了 Calculator.divideInt(1, 0) 的操做,就必定会抛出 ArithmeticException 异常,而且异常信息是 / by zero。
上面咱们已经学会了 spock 的基础用法,下面咱们将学习与 spring 整合的知识,首先建立几个用于测试的demo 类
/* * * * * blog.coder4j.cn * * Copyright (C) 2016-2019 All Rights Reserved. * */ package cn.coder4j.study.example.spock.model; import java.util.Objects; /** * @author buhao * @version User.java, v 0.1 2019-10-30 16:23 buhao */ public class User { private String name; private Integer age; private String passwd; public User(String name, Integer age, String passwd) { this.name = name; this.age = age; this.passwd = passwd; } /** * Getter method for property <tt>passwd</tt>. * * @return property value of passwd */ public String getPasswd() { return passwd; } /** * Setter method for property <tt>passwd</tt>. * * @param passwd value to be assigned to property passwd */ public void setPasswd(String passwd) { this.passwd = passwd; } /** * Getter method for property <tt>name</tt>. * * @return property value of name */ public String getName() { return name; } /** * Setter method for property <tt>name</tt>. * * @param name value to be assigned to property name */ public void setName(String name) { this.name = name; } /** * Getter method for property <tt>age</tt>. * * @return property value of age */ public Integer getAge() { return age; } /** * Setter method for property <tt>age</tt>. * * @param age value to be assigned to property age */ public void setAge(Integer age) { this.age = age; } public User() { } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; User user = (User) o; return Objects.equals(name, user.name) && Objects.equals(age, user.age) && Objects.equals(passwd, user.passwd); } @Override public int hashCode() { return Objects.hash(name, age, passwd); } }
/* * * * * blog.coder4j.cn * * Copyright (C) 2016-2019 All Rights Reserved. * */ package cn.coder4j.study.example.spock.dao; import cn.coder4j.study.example.spock.model.User; import org.springframework.stereotype.Component; import java.util.HashMap; import java.util.Map; /** * @author buhao * @version UserDao.java, v 0.1 2019-10-30 16:24 buhao */ @Component public class UserDao { /** * 模拟数据库 */ private static Map<string, user> userMap = new HashMap<>(); static { userMap.put("k",new User("k", 1, "123")); userMap.put("i",new User("i", 2, "456")); userMap.put("w",new User("w", 3, "789")); } /** * 经过用户名查询用户 * @param name * @return */ public User findByName(String name){ return userMap.get(name); } }
/* * * * * blog.coder4j.cn * * Copyright (C) 2016-2019 All Rights Reserved. * */ package cn.coder4j.study.example.spock.service; import cn.coder4j.study.example.spock.dao.UserDao; import cn.coder4j.study.example.spock.model.User; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; /** * @author buhao * @version UserService.java, v 0.1 2019-10-30 16:29 buhao */ @Service public class UserService { @Autowired private UserDao userDao; public User findByName(String name){ return userDao.findByName(name); } public void loginAfter(){ System.out.println("登陆成功"); } public void login(String name, String passwd){ User user = findByName(name); if (user == null){ throw new RuntimeException(name + "不存在"); } if (!user.getPasswd().equals(passwd)){ throw new RuntimeException(name + "密码输入错误"); } loginAfter(); } }
/* * * * * blog.coder4j.cn * * Copyright (C) 2016-2019 All Rights Reserved. * */ package cn.coder4j.study.example.spock; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }
/* * * * * blog.coder4j.cn * * Copyright (C) 2016-2019 All Rights Reserved. * */ package cn.coder4j.study.example.spock.service import cn.coder4j.study.example.spock.model.User import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest import spock.lang.Specification import spock.lang.Unroll @SpringBootTest class UserServiceFunctionTest extends Specification { @Autowired UserService userService @Unroll def "test findByName with input #name return #result"() { expect: userService.findByName(name) == result where: name << ["k", "i", "kk"] result << [new User("k", 1, "123"), new User("i", 2, "456"), null] } @Unroll def "test login with input #name and #passwd throw #errMsg"() { when: userService.login(name, passwd) then: def e = thrown(Exception) e.message == errMsg where: name | passwd | errMsg "kd" | "1" | "${name}不存在" "k" | "1" | "${name}密码输入错误" } }
spock 与 spring 集成特别的简单,只要你加入了开头所说的 spock-spring 和 spring-boot-starter-test。再于测试代码的类上加上 @SpringBootTest 注解就能够了。想用的类直接注入进来就能够了,可是要注意的是这里只能算功能测试或集成测试,由于在跑用例时是会启动 spring 容器的,外部依赖也必须有。很耗时,并且有时候外部依赖本地也跑不了,因此咱们一般都是经过 mock 来完成单元测试。
/* * * * * blog.coder4j.cn * * Copyright (C) 2016-2019 All Rights Reserved. * */ package cn.coder4j.study.example.spock.service import cn.coder4j.study.example.spock.dao.UserDao import cn.coder4j.study.example.spock.model.User import spock.lang.Specification import spock.lang.Unroll class UserServiceUnitTest extends Specification { UserService userService = new UserService() UserDao userDao = Mock(UserDao) def setup(){ userService.userDao = userDao } def "test login with success"(){ when: userService.login("k", "p") then: 1 * userDao.findByName("k") >> new User("k", 12,"p") } def "test login with error"(){ given: def name = "k" def passwd = "p" when: userService.login(name, passwd) then: 1 * userDao.findByName(name) >> null then: def e = thrown(RuntimeException) e.message == "${name}不存在" } @Unroll def "test login with "(){ when: userService.login(name, passwd) then: userDao.findByName("k") >> null userDao.findByName("k1") >> new User("k1", 12, "p") then: def e = thrown(RuntimeException) e.message == errMsg where: name | passwd | errMsg "k" | "k" | "${name}不存在" "k1" | "p1" | "${name}密码输入错误" } }
spock 使用 mock 也很简单,直接使用 Mock(类) 就能够了。如上代码 _UserDao userDao = Mock(UserDao) 。_上面写的例子中有几点要说明一下,以以下这个方法为例:
def "test login with error"(){ given: def name = "k" def passwd = "p" when: userService.login(name, passwd) then: 1 * userDao.findByName(name) >> null then: def e = thrown(RuntimeException) e.message == "${name}不存在" }
given、when、then 不用说了,你们已经很熟悉了,可是第一个 then 里面的 **1 * userDao.findByName(name) >> null **是什么鬼? 首先,咱们能够知道的是,一个用例中能够有多个 then 块,对于多个指望能够分别放在多个 then 中。 第二, 1 * xx 表示 指望 xx 操做执行了 1 次。1 * userDao.findByName(name) 就表现当执行 _userService.login(name, passwd) 时我指望执行 1 次 userDao.findByName(name) 方法。若是指望不执行这个方法就是_0 * xx,这在条件代码的验证中颇有用,而后 >> null 又是什么意思?他表明当执行了 userDao.findByName(name) 方法后,我让他结果返回 null。由于 userDao 这个对象是咱们 mock 出来的,他就是一个假对象,为了让后续流程按咱们的想法进行,我能够经过『 >>』 让 spock 模拟返回指定数据。 第三,要注意第二个 then 代码块使用 ${name} 引用变量,跟标题的 #name 是不一样的。
方法名 | 做用 |
---|---|
setup() | 每一个方法执行前调用 |
cleanup() | 每一个方法执行后调用 |
setupSpec() | 每一个方法类加载前调用一次 |
cleanupSpec() | 每一个方法类执行完调用一次 |
这些方法一般用于测试开始前的一些初始化操做,和测试完成后的清理操做,以下:
def setup() { println "方法开始前初始化" } def cleanup() { println "方法执行完清理" } def setupSpec() { println "类加载前开始前初始化" } def cleanupSpec() { println "因此方法执行完清理" }
对于某些方法,须要规定他的时间,若是运行时间超过了指定时间就算失败,这时可使用 timeout 注解
@Timeout(value = 900, unit = TimeUnit.MILLISECONDS) def "test timeout"(){ expect: Thread.sleep(1000) 1 == 1 }
注解有两个值,一个是 value 咱们设置的数值,unit 是数值的单位。
def "test findByName by verity"() { given: def userDao = Mock(UserDao) when: userDao.findByName("kk") >> new User("kk", 12, "33") then: def user = userDao.findByName("kk") with(user) { name == "kk" age == 12 passwd == "33" } }
with 算是一个语法糖,没有他以前咱们要判断对象的值只能,user.getXxx() == xx。若是属性过多也是挺麻烦的,用 with 包裹以后,只要在花括号内直接写属性名称便可,如上代码所示。
由于篇幅有限,没法贴完全部代码,完整代码已上传 github。
本文在瞻仰了以下博主的精彩博文后,再加上自身的学习总结加工而来,若是本文在看的时候有不明白的地方能够看一下下方连接。