當沒法避免作一件事時,那就讓它變得更簡單。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的魔法,確實讓單測更加清晰簡明。