使用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 中引用,適用於不依賴外部的工具類函數
/**
* 二分搜索的非遞歸版本: 在給定有序數組中查找給定的鍵值
* 前提條件: 數組必須有序, 即滿足: 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"
}
}
這裏有三個註意事項:
- 無論多麽簡單的測試,至少要有一個 expect: 標簽, 否則 Spock 會報 “No Test Found” 的錯誤;
- Groovy閉包 { x -> doWith(x) } 必須轉成 java.util.[Function|Consumer|BiFunction|BiConsumer|...]
- 若要測試拋出異常,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輕松寫出更簡潔的單測