当没法避免作一件事时,那就让它变得更简单。java
单测是规范的软件开发流程中的必不可少的环节之一。再伟大的程序员也难以免本身不犯错,不写出有BUG的程序。单测就是用来检测BUG的。Java阵营中,JUnit和TestNG是两个知名的单测框架。不过,用Java写单测实在是很繁琐。本文介绍使用Groovy+Spock轻松写出更简洁的单测。程序员
Spock是基于JUnit的单测框架,提供一些更好的语法,结合Groovy语言,能够写出更为简洁的单测。Spock介绍请本身去维基,本文很少言。下面给出一些示例来讲明,如何用Groovy+Spock来编写单测。
spring
要使用Groovy+Spock编写单测,首先引入以下Maven依赖,同时安装Groovy插件。apache
<dependency> <groupId>org.codehaus.groovy</groupId> <artifactId>groovy-all</artifactId> <version>2.4.12</version> </dependency> <dependency> <groupId>junit</groupId> <artifactId>junit</artifactId> <version>4.12</version> <scope>test</scope> </dependency> <dependency> <groupId>org.spockframework</groupId> <artifactId>spock-core</artifactId> <version>1.1-groovy-2.4</version> <scope>test</scope>
Spock主要提供了以下基本构造块:json
了解基本构造块的用途后,能够组合它们来编写单测。
数组
expect-where组合是最简单的单测模式。也就是在 where 子句中以表格形式给出一系列输入输出的值,而后在 expect 中引用,适用于不依赖外部的工具类函数。这里的Where子句相似于TestNG里的DataProvider,比之更简明。 以下代码给出了二分搜索的一个实现:闭包
/** * 二分搜索的非递归版本: 在给定有序数组中查找给定的键值 * 前提条件: 数组必须有序, 即知足: A[0] <= A[1] <= ... <= A[n-1] * @param arr 给定有序数组 * @param key 给定键值 * @return 若是查找成功,则返回键值在数组中的下标位置,不然,返回 -1. */ public static int search(int[] arr, int key) { int low = 0; int high = arr.length-1; while (low <= high) { int mid = (low + high) / 2; if (arr[mid] > key) { high = mid - 1; } else if (arr[mid] == key) { return mid; } else { low = mid + 1; } } return -1; }
要验证这段代码是否OK,须要指定arr, key, 而后看Search输出的值是不是指定的数字 result。 Spock单测以下:app
class BinarySearchTest extends Specification { def "testSearch"() { expect: BinarySearch.search(arr as int[], key) == result where: arr | key | result [] | 1 | -1 [1] | 1 | 0 [1] | 2 | -1 [3] | 2 | -1 [1, 2, 9] | 2 | 1 [1, 2, 9] | 9 | 2 [1, 2, 9] | 3 | -1 //null | 0 | -1 } }
单测类BinarySearchTest.groovy 继承了Specification ,从而可使用Spock的一些魔法。expect: 块很是清晰地表达了要测试的内容,而where: 块则给出了每一个指定条件值(arr,key)下应该有的输出 result。 注意到 where 中的变量arr, key, result 被 expect 的表达式引用了。是否是很是的清晰简单 ? 能够任意增长一条单测用例,只是加一行被竖线隔开的值。框架
注意到最后被注释的一行, null | 0 | -1 这个单测会失败,抛出异常,由于实现中没有对 arr 作判空检查,不够严谨。 这体现了写单测时的一大准则:务必测试空与临界状况。此外,给出的测试数据集覆盖了实现的每一个分支,所以这个测试用例集合是充分的。
maven
testSearch的测试用例都写在where子句里。有时,里面的某个测试用例失败了,却难以查到是哪一个失败了。这时候,可使用Unroll注解,该注解会将where子句的每一个测试用例转化为一个 @Test 独立测试方法来执行,这样就很容易找到错误的用例。 方法名还能够更可读些。好比写成:
@Unroll def "testSearch(#key in #arr index=#result)"() { expect: BinarySearch.search(arr as int[], key) == result where: arr | key | result [] | 1 | -1 [1, 2, 9] | 9 | 2 [1, 2, 9] | 3 | 0 }
运行结果以下。 能够看到错误的测试用例单独做为一个子测试运行,且标识得更明显了。
注意到expect中使用了 arr as int[] ,这是由于 groovy 默认将 [xxx,yyy,zzz] 形式转化为列表,必须强制类型转换成数组。 若是写成 BinarySearch.search(arr, key) == result
就会报以下错误:
Caused by: groovy.lang.MissingMethodException: No signature of method: static zzz.study.algorithm.search.BinarySearch.search() is applicable for argument types: (java.util.ArrayList, java.lang.Integer) values: [[1, 2, 9], 3] Possible solutions: search([I, int), each(groovy.lang.Closure), recSearch([I, int)
相似的,还有Java的Function使用闭包时也要作强制类型转换。来看下面的代码:
public static <T> void tryDo(T t, Consumer<T> func) { try { func.accept(t); } catch (Exception e) { throw new RuntimeException(e.getCause()); } }
这里有个通用的 try-catch 块,捕获消费函数 func 抛出的异常。 使用 groovy 的闭包来传递给 func 时, 必须将闭包转换成 Consumer 类型。 单测代码以下:
def "testTryDo"() { expect: try { CatchUtil.tryDo(1, { throw new IllegalArgumentException(it.toString())} as Consumer) Assert.fail("NOT THROW EXCEPTION") } catch (Exception ex) { ex.class.name == "java.lang.RuntimeException" ex.cause.class.name == "java.lang.IllegalArgumentException" } }
这里有三个注意事项:
上面的单测写得有点难看,可使用Spock的thrown子句写得更简明一些。以下所示: 在 when 子句中调用了会抛出异常的方法,而在 then 子句中,使用 thrown 接收方法抛出的异常,并赋给指定的变量 ex, 以后就能够对 ex 进行断言了。
def "testTryDoWithThrown"() { when: CatchUtil.tryDo(1, { throw new IllegalArgumentException(it.toString())} as Consumer) then: def ex = thrown(Exception) ex.class.name == "java.lang.RuntimeException" ex.cause.class.name == "java.lang.IllegalArgumentException" }
Mock外部依赖的单测一直是传统单测的一个头疼点。使用过Mock框架的同窗知道,为了Mock一个服务类,必须当心翼翼地把整个应用的全部服务类都Mock好,并经过Spring配置文件注册好。一旦有某个服务类的依赖有变更,就不得不去排查相应的依赖,每每单测还没怎么写,一个小时就过去了。
Spock容许你只Mock须要的服务类。假设要测试的类为 S,它依赖类 D 提供的服务 m 方法。 使用Spock作单测Mock能够分为以下步骤:
STEP1: 能够经过 Mock(D) 来获得一个类D的Mock实例 d;
STEP2:在 setup() 方法中将 d 设置为 S 要使用的实例;
STEP3:在 given 子句中,给出 m 方法的模拟返回数据 sdata;
STEP4: 在 when 子句中,调用 D 的 m 方法,使用 >> 将输出指向 sdata ;
STEP5: 在 then 子句中,给出断定表达式,其中断定表达式能够引用 where 子句的变量。
例如,下面是一个 HTTP 调用类的实现。
package zzz.study.tech.batchcall; import com.alibaba.fastjson.JSONObject; import org.apache.http.Header; import org.apache.http.HttpEntity; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpPost; import org.apache.http.entity.StringEntity; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.message.BasicHeader; import org.apache.http.util.EntityUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; import java.nio.charset.Charset; /** * Created by shuqin on 18/3/12. */ @Component("httpClient") public class HttpClient { private static Logger logger = LoggerFactory.getLogger(HttpClient.class); private CloseableHttpClient syncHttpClient = SyncHttpClientFactory.getInstance(); /** * 发送查询请求获取结果 */ public JSONObject query(String query, String url) throws Exception { StringEntity entity = new StringEntity(query, "utf-8"); HttpPost post = new HttpPost(url); Header header = new BasicHeader("Content-Type", "application/json"); post.setEntity(entity); post.setHeader(header); CloseableHttpResponse resp = null; JSONObject rs = null; try { resp = syncHttpClient.execute(post); int code = resp.getStatusLine().getStatusCode(); HttpEntity respEntity = resp.getEntity(); String response = EntityUtils.toString(respEntity, Charset.forName("utf-8")); if (code != 200) { logger.warn("request failed resp:{}", response); } rs = JSONObject.parseObject(response); } finally { if (resp != null) { resp.close(); } } return rs; } }
它的单测类以下所示:
package zzz.study.batchcall import com.alibaba.fastjson.JSON import org.apache.http.ProtocolVersion import org.apache.http.entity.BasicHttpEntity import org.apache.http.impl.client.CloseableHttpClient import org.apache.http.impl.execchain.HttpResponseProxy import org.apache.http.message.BasicHttpResponse import org.apache.http.message.BasicStatusLine import spock.lang.Specification import zzz.study.tech.batchcall.HttpClient /** * Created by shuqin on 18/3/12. */ class HttpClientTest extends Specification { HttpClient httpClient = new HttpClient() CloseableHttpClient syncHttpClient = Mock(CloseableHttpClient) def setup() { httpClient.syncHttpClient = syncHttpClient } def "testHttpClientQuery"() { given: def statusLine = new BasicStatusLine(new ProtocolVersion("Http", 1, 1), 200, "") def resp = new HttpResponseProxy(new BasicHttpResponse(statusLine), null) resp.statusCode = 200 def httpEntity = new BasicHttpEntity() def respContent = JSON.toJSONString([ "code": 200, "message": "success", "total": 1200 ]) httpEntity.content = new ByteArrayInputStream(respContent.getBytes("utf-8")) resp.entity = httpEntity when: syncHttpClient.execute(_) >> resp then: def callResp = httpClient.query("query", "http://127.0.0.1:80/xxx/yyy/zzz/list") callResp.size() == 3 callResp[field] == value where: field | value "code" | 200 "message" | "success" "total" | 1200 } }
让我来逐一讲解:
STEP1: 首先梳理依赖关系。 HttpClient 依赖 CloseableHttpClient 实例来查询数据,并对返回的数据作处理 ;
STEP2: 建立一个 HttpClient 实例 httpClient 以及一个 CloseableHttpClient mock 实例: CloseableHttpClient syncHttpClient = Mock(CloseableHttpClient) ;
STEP3: 在 setup 启动方法中,将 syncHttpClient 设置给 httpClient ;
STEP4: 从代码中能够知道,httpClient 依赖 syncHttpClient 的 execute 方法返回的 CloseableHttpResponse 实例,所以,须要在 given: 块中构造一个 CloseableHttpResponse 实例 resp 。这里费了一点劲,须要深刻apacheHttp源代码,了解 CloseableHttpResponse 的继承实现关系, 来最小化地建立一个 CloseableHttpResponse 实例 ,避开没必要要的细节。不过这并非 SpockMock单测的重点。
STEP5:在 when 块中调用 syncHttpClient.execute(_) >> resp ;
STEP6: 在 then 块中根据 resp 编写断言表达式,这里 where 是可选的。
嗯,Spock Mock 单测就是这样:setup-given-when-then 四步曲。读者能够打断点观察单测的单步运行。
本文讲解了使用Groovy+Spock编写单测的 expect-where , when-then-thrown, setup-given-when-then[-where] 三种最多见的模式,相信已经能够应对实际应用的大多数场景了。 能够看到,Groovy 的语法结合Spock的魔法,确实让单测更加清晰简明。