1. 程式人生 > >使用Groovy+Spock輕松寫出更簡潔的單測

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

port 覆蓋 fast 可選 static apach 它的 head 二分搜索

當無法避免做一件事時,那就讓它變得更簡單。

概述

單測是規範的軟件開發流程中的必不可少的環節之一。再偉大的程序員也難以避免自己不犯錯,不寫出有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輕松寫出更簡潔的單測