使用Groovy+Spock輕鬆寫出更簡潔的單測

當沒法避免作一件事時,那就讓它變得更簡單。java

概述

單測是規範的軟件開發流程中的必不可少的環節之一。再偉大的程序員也難以免本身不犯錯,不寫出有BUG的程序。單測就是用來檢測BUG的。Java陣營中,JUnit和TestNG是兩個知名的單測框架。不過,用Java寫單測實在是很繁瑣。本文介紹使用Groovy+Spock輕鬆寫出更簡潔的單測。程序員

Spock是基於JUnit的單測框架,提供一些更好的語法,結合Groovy語言,能夠寫出更爲簡潔的單測。Spock介紹請本身去維基,本文很少言。下面給出一些示例來講明,如何用Groovy+Spock來編寫單測。

spring

準備與基礎

maven依賴

要使用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

  • where: 以表格的形式提供測試數據集合
  • when: 觸發行爲,好比調用指定方法或函數
  • then: 作出斷言表達式
  • expect: 指望的行爲,when-then的精簡版
  • given: mock單測中指定mock數據
  • thrown: 若是在when方法中拋出了異常,則在這個子句中會捕獲到異常並返回
  • def setup() {} :每一個測試運行前的啓動方法
  • def cleanup() {} : 每一個測試運行後的清理方法
  • def setupSpec() {} : 第一個測試運行前的啓動方法
  • def cleanupSpec() {} : 最後一個測試運行後的清理方法

瞭解基本構造塊的用途後,能夠組合它們來編寫單測。

數組

單測示例

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輸出的值是不是指定的數字 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

Unroll

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
    }

運行結果以下。 能夠看到錯誤的測試用例單獨做爲一個子測試運行,且標識得更明顯了。

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: 塊 或 when-then 塊 (別漏了在測試代碼前加個 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();

  /**
   *  發送查詢請求獲取結果
   */
  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的魔法,確實讓單測更加清晰簡明。

相關文章
相關標籤/搜索