使用Groovy+Spock轻松写出更简洁的单测

Posted 编程大观园

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了使用Groovy+Spock轻松写出更简洁的单测相关的知识,希望对你有一定的参考价值。

当无法避免做一件事时,那就让它变得更简单。

概述

单测是规范的软件开发流程中的必不可少的环节之一。再伟大的程序员也难以避免自己不犯错,不写出有BUG的程序。单测就是用来检测BUG的。Java阵营中,JUnit和TestNG是两个知名的单测框架。不过,用Java写单测实在是很繁琐。本文介绍使用Groovy+Spock轻松写出更简洁的单测。

Spock是基于JUnit的单测框架,提供一些更好的语法,结合Groovy语言,可以写出更为简洁的单测。Spock介绍请自己去维基,本文不多言。下面给出一些示例来说明,如何用Groovy+Spock来编写单测。

maven依赖

要使用Groovy+Spock编写单测,首先引入如下Maven依赖,同时安装Groovy插件。

<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>


单测示例

expect-where

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输出的值是否是指定的数字 expect。 Spock单测如下:

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
    }

}

单测类BinarySerchTest.groovy继承了Specification,从而可以使用Spock的一些魔法。expect 非常清晰地表达了要测试的内容,而where子句则给出了每个指定条件值(arr,key)下应该有的输出 result。 注意到 where 中的变量arr, key, result 被 expect 的表达式引用了。是不是非常的清晰简单 ? 可以任意增加一条单测用例,只是加一行被竖线隔开的值。

注意到最后被注释的一行, null | 0 | -1 这个单测会失败,抛出异常,因为实现中没有对 arr 做判空检查,不够严谨。 这体现了写单测时的一大准则:空与临界情况务必要测试到。此外,给出的测试数据集覆盖了实现的每个分支,因此这个测试用例集合是充分的。

typecast

注意到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"
        }
    }

这里有三个注意事项:

  1. 无论多么简单的测试,至少要有一个 expect: 标签, 否则 Spock 会报 “No Test Found” 的错误;
  2. Groovy闭包 { x -> doWith(x) } 必须转成 java.util.[Function|Consumer|BiFunction|BiConsumer|...]
  3. 若要测试抛出异常,Assert.fail("NOT THROW EXCEPTION") 这句是必须的,否则单测可以不抛出异常照样通过,达不到测试异常的目的。

when-then-thrown

上面的单测写得有点难看,可以使用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"
    }


setup-given-when-then-where

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();

  /**
   * 向 ES 发送查询请求获取结果
   */
  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 syncHttpClient 实例来查询数据,并对返回的数据做处理 ;

STEP2: 创建一个 HttpClient 实例 httpClient 以及一个 CloseableHttpClient mock 实例: CloseableHttpClient syncHttpClient = Mock(CloseableHttpClient) ;

STEP3: 在 setup 启动方法中,将 syncHttpClient 设置给 httpClient ;

STEP4: 从代码中可以知道,httpClient 依赖 syncHttpClient 的query方法返回的 CloseableHttpResponse 实例,因此,需要在 given: 标签中构造一个 CloseableHttpResponse 实例。这里费了一点劲,需要深入apacheHttp源代码,了解 CloseableHttpResponse 的继承实现关系, 来最小化地创建一个 CloseableHttpResponse 实例,避开不必要的细节。

STEP5:在 when 方法中调用 syncHttpClient.query(_) >> mockedResponse (specified by given)

STEP6: 在 then 方法中根据 mockedResponse 编写断言表达式,这里 where 是可选的。

嗯,Spock Mock 单测就是这样:setup-given-when-then 四步曲。读者可以打断点观察单测的单步运行。

小结

本文讲解了使用Groovy+Spock编写单测的 expect-where , when-then-thrown, setup-given-when-then[-where] 三种最常见的模式,相信已经可以应对实际应用的大多数场景了。 可以看到,Groovy 的语法结合Spock的魔法,确实让单测更加清晰简明。

以上是关于使用Groovy+Spock轻松写出更简洁的单测的主要内容,如果未能解决你的问题,请参考以下文章

Groovy常用编程知识点简明教程

使用 Spock 进行 Grails 测试 - 选择哪个模拟框架?

Compile Groovy/Spock with GMavenPlus

在 groovy (Spock) 中测试文件结构

Groovy单元测试框架spock数据驱动Demo

Groovy Spock环境的安装