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

基本構造塊####

Spock主要提供了如下基本構造塊:

  • 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單測如下:

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 做判空檢查,不夠嚴謹。 這體現了寫單測時的一大准則:務必測試空與臨界情況。此外,給出的測試數據集覆蓋了實現的每個分支,因此這個測試用例集合是充分的。

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的魔法,確實讓單測更加清晰簡明。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM