這是Spock系列的第一篇文章,整個專輯會介紹Spock的用途,為什么使用Spock?它能給我們帶來什么好處?它和JUnit、JMock、Mockito有什么區別?我們平時寫單元測試代碼的常見問題和痛點,Spock又是如何解決的,Spock的代碼怎么編寫以及Spock的優勢和缺點等內容,讓大家對Spock有個客觀的了解。
Spock是什么?
斯波克是國外一款優秀的測試框架,基於BDD思想,功能強大,能夠讓我們的測試代碼規范化,結構層次清晰,結合groovy動態語言的特點以及自身提供的各種標簽讓編寫測試代碼更加高效和簡潔,提供一種通用、簡單、結構化的描述語言
引用官網的介紹如下(http://spockframework.org)
“ Spock是一個Java和Groovy應用程序的測試和規范框架。
它之所以能在人群中脫穎而出,是因為它優美而富有表現力的規范語言。
斯波克的靈感來自JUnit、RSpec、jMock、Mockito、Groovy、Scala、Vulcans ”
簡單說Spock的特點如下:
- 讓我們的測試代碼更規范,內置多種標簽來規范單測代碼的語義,從而讓我們的測試代碼結構清晰,更具可讀性,降低后期維護難度
- 提供多種標簽,比如:
where
、with
、thrown
... 幫助我們應對復雜的測試場景 - 再加上使用groovy這種動態語言來編寫測試代碼,可以讓我們編寫的測試代碼更簡潔,適合敏捷開發,提高編寫單測代碼的效率
- 遵從BDD行為驅動開發模式,不單是為了測試覆蓋率而測試,有助於提升代碼質量
- IDE兼容性好,自帶mock功能
為什么使用Spock? Spock和JUnit、JMock、Mockito的區別在哪里?
收到現有的單測框架比如junit、jmock、mockito都是相對獨立的工具,只是針對不同的業務場景提供特定的解決方案。
Junit單純用於測試,不提供mock功能
微服務已經是互聯網公司的主流技術架構,大部分的系統都是分布式,服務與服務之間一般通過接口的方式交互,甚至服務內部也划分成多個module,很多業務功能需要依賴底層接口返回的數據才能繼續剩下的流程,或者從數據庫/Redis等存儲設備上獲取,或是從配置中心的某個配置獲取。
這樣就導致如果我們想要測試代碼邏輯是否正確,就必須把這些依賴項(接口、Redis、DB、配置中心...)給mock掉。
如果接口不穩定或有問題則會影響我們代碼的正常測試,所以我們要把調用接口的地方給模擬掉,讓它返回指定的結果(提前准備好的數據),這樣才能往下驗證我們自己的代碼是否正確,符合預期邏輯和結果。
JMock或Mockito雖然提供了mock功能,可以把接口等依賴屏蔽掉,但不提供對靜態類靜態方法的mock,PowerMock或Jmockit雖然提供靜態類和方法的mock,但它們之間需要整合(junit+mockito+powermock),語法繁瑣,而且這些工具並沒有告訴你“單元測試代碼到底應該怎么寫?”
工具多了也會導致不同的人寫出的單元測試代碼五花八門,風格迥異。。。
Spock通過提供規范描述,定義多種標簽(given
、when
、then
、where
等)去描述代碼“應該做什么”,輸入條件是什么,輸出是否符合預期,從語義層面規范代碼的編寫。
Spock自帶Mock功能,使用簡單方便(也支持擴展其他mock框架,比如power mock),再加上groovy動態語言的強大語法,能寫出簡潔高效的測試代碼,同時更方便直觀的驗證業務代碼行為流轉,增強我們對代碼執行邏輯的可控性。
背景和初衷
網上關於Spock的資料比較簡單,包括官網的demo,無法解決我們項目中的復雜業務場景,需要找到一套適合自己項目的成熟解決方案,所以覺得有必要把我們項目中使用Spock的經驗分享出來, 幫助大家提升單測開發的效率和驗證代碼質量。
在熟練掌握Spock后我們項目組整體的單測開發效率提升了50%以上,代碼可讀性和維護性都得到了改善和提升。
適合人群
寫Java單元測試的開發小伙伴和測試同學,所有的演示代碼運行在IntelliJ IDEA中,spring-boot項目,基於Spock 1.3-groovy-2.5版本
Spock如何解決傳統單元測試開發中的痛點
這篇主要講下我們平時寫單元測試過程中遇到的幾種常見問題,分別使用JUnit和Spock如何解決,通過對比的方式給大家一個整體認識。
一. 單元測試代碼開發的成本和效率
復雜場景的業務代碼,在分支(if/else
)很多的情況下,編寫單測代碼的成本會相應增加,正常的業務代碼或許只有幾十行,但為了測試這個功能,要覆蓋大部分的分支場景,寫的測試代碼可能遠遠不止幾十行
舉個我們生產環境前不久發生的一起事故:有個功能上線1年多一直都正常,沒有出過問題,但最近有個新的調用方請求的數據不一樣,走到了代碼中一個不常用的分支邏輯,導致了bug,直接拋出異常阻斷了主流程,好在調用方請求量不大。。。
估計當初寫這段代碼的同學也認為很小幾率會走到這個分支,雖然當時也寫了單元測試代碼,但分支較多,剛好漏掉了這個分支邏輯的測試,給日后上線留下了隱患
這也是我們平時寫單元測試最常遇到的問題:要達到分支覆蓋率高要求的情況下,if/else
有不同的結果,傳統的單測寫法可能要多次調用,才能覆蓋全部的分支場景,一個是寫單測麻煩,同時也會增加單測代碼的冗余度
雖然可以使用junit的@parametered
參數化注解或者dataprovider的方式,但還是不夠方便直觀,而且如果其中一次分支測試case出錯的情況下,報錯信息也不夠詳盡。
比如下面的示例演示代碼,根據輸入的身份證號碼識別出生日期、性別、年齡等信息,這個方法的特點就是有很多if...else...
的分支嵌套邏輯
/**
* 身份證號碼工具類<p>
* 15位:6位地址碼+6位出生年月日(900101代表1990年1月1日出生)+3位順序碼
* 18位:6位地址碼+8位出生年月日(19900101代表1990年1月1日出生)+3位順序碼+1位校驗碼
* 順序碼奇數分給男性,偶數分給女性。
* @author 公眾號:Java老K
* 個人博客:www.javakk.com
*/
public class IDNumberUtils {
/**
* 通過身份證號碼獲取出生日期、性別、年齡
* @param certificateNo
* @return 返回的出生日期格式:1990-01-01 性別格式:F-女,M-男
*/
public static Map<String, String> getBirAgeSex(String certificateNo) {
String birthday = "";
String age = "";
String sex = "";
int year = Calendar.getInstance().get(Calendar.YEAR);
char[] number = certificateNo.toCharArray();
boolean flag = true;
if (number.length == 15) {
for (int x = 0; x < number.length; x++) {
if (!flag) return new HashMap<>();
flag = Character.isDigit(number[x]);
}
} else if (number.length == 18) {
for (int x = 0; x < number.length - 1; x++) {
if (!flag) return new HashMap<>();
flag = Character.isDigit(number[x]);
}
}
if (flag && certificateNo.length() == 15) {
birthday = "19" + certificateNo.substring(6, 8) + "-"
+ certificateNo.substring(8, 10) + "-"
+ certificateNo.substring(10, 12);
sex = Integer.parseInt(certificateNo.substring(certificateNo.length() - 3,
certificateNo.length())) % 2 == 0 ? "女" : "男";
age = (year - Integer.parseInt("19" + certificateNo.substring(6, 8))) + "";
} else if (flag && certificateNo.length() == 18) {
birthday = certificateNo.substring(6, 10) + "-"
+ certificateNo.substring(10, 12) + "-"
+ certificateNo.substring(12, 14);
sex = Integer.parseInt(certificateNo.substring(certificateNo.length() - 4,
certificateNo.length() - 1)) % 2 == 0 ? "女" : "男";
age = (year - Integer.parseInt(certificateNo.substring(6, 10))) + "";
}
Map<String, String> map = new HashMap<>();
map.put("birthday", birthday);
map.put("age", age);
map.put("sex", sex);
return map;
}
}
針對上面這種場景,spock提供了where標簽,讓我們可以通過表格的方式方便測試多種分支
下面的對比圖是針對"根據身份證號碼獲取出生日期、性別、年齡"方法實現的單元測試,左邊是我們常用的Junit的寫法,右邊是Spock的寫法,紅框圈出來的是一樣的功能在Junit和Spock上的代碼實現 (兩邊執行的單測結果一樣,點擊放大查看差異)
對比結果:
右邊一欄使用Spock寫的單測代碼上語法簡潔,表格方式測試覆蓋多分支場景也更直觀,提升開發效率,更適合敏捷開發
(關於Spock代碼的具體語法會在后續文章講解)
二. 單元測試代碼的可讀性和后期維護
微服務架構下,很多場景需要依賴其他接口返回的結果才能驗證自己代碼的邏輯,這樣就需要使用mock工具,但JMock或Mockito的語法比較繁瑣,再加上單測代碼不像業務代碼那么直觀,不能完全按照業務流程的思路寫單測,以及開發同學對單測代碼可讀性的不重視,最終導致測試代碼難於閱讀,維護起來更是難上加難
可能自己寫完的測試,過幾天再看就雲里霧里了(當然添加注釋會好很多),再比如改了原來的代碼邏輯導致單測執行失敗,或者新增了分支邏輯,單測沒有覆蓋到,隨着后續版本的迭代,會導致單測代碼越來越臃腫和難以維護
Spock提供多種語義標簽,如: given、when、then、expect、where、with、and 等,從行為上規范單測代碼,每一種標簽對應一種語義,讓我們的單測代碼結構具有層次感,功能模塊划分清晰,便於后期維護
Spock自帶mock功能,使用上簡單方便(Spock也支持擴展第三方mock框架,比如power mock)保證代碼更加規范,結構模塊化,邊界范圍清晰,可讀性強,便於擴展和維護,用自然語言描述測試步驟,讓非技術人員也能看懂測試代碼
比如下面的業務代碼:
調用用戶接口或者從數據庫獲取用戶信息,然后做一些轉換和判斷邏輯(這里的業務代碼只是列舉常見的業務場景,方便演示)
/**
* 用戶服務
* @author 公眾號:Java老K
* 個人博客:www.javakk.com
*/
@Service
public class UserService {
@Autowired
UserDao userDao;
@Autowired
MoneyDAO moneyDAO;
public UserVO getUserById(int uid){
List<UserDTO> users = userDao.getUserInfo();
UserDTO userDTO = users.stream().filter(u -> u.getId() == uid).findFirst().orElse(null);
UserVO userVO = new UserVO();
if(null == userDTO){
return userVO;
}
userVO.setId(userDTO.getId());
userVO.setName(userDTO.getName());
userVO.setSex(userDTO.getSex());
userVO.setAge(userDTO.getAge());
// 顯示郵編
if("上海".equals(userDTO.getProvince())){
userVO.setAbbreviation("滬");
userVO.setPostCode(200000);
}
if("北京".equals(userDTO.getProvince())){
userVO.setAbbreviation("京");
userVO.setPostCode(100000);
}
// 手機號處理
if(null != userDTO.getTelephone() && !"".equals(userDTO.getTelephone())){
userVO.setTelephone(userDTO.getTelephone().substring(0,3)+"****"+userDTO.getTelephone().substring(7));
}
return userVO;
}
}
下面的對比圖是分別使用Junit和Spock實現的單元測試,左邊是Junit的寫法,右邊是Spock,紅框圈出來的是一樣的功能在Junit和Spock上的實現 (兩邊執行的單測結果一樣,點擊放大查看差異)
對比結果:
左邊的junit單測代碼冗余,缺少結構層次,可讀性差,隨着后續迭代勢必會導致代碼的堆積,后期維護成本會越來越高。
右邊的單測代碼spock會強制要求使用given
、when
、then
這樣的語義標簽(至少一個),否則編譯不通過,這樣保證代碼更加規范,結構模塊化,邊界范圍清晰,可讀性強,便於擴展和維護,用自然語言描述測試步驟,讓非技術人員也能看懂測試代碼(given
表示輸入條件,when
觸發動作,then
驗證輸出結果)
Spock自帶的mock
語法也非常簡單:
userDao.getUserInfo() >> [user1, user2]
兩個右箭頭">>"表示即模擬getUserInfo
接口的返回結果,再加上使用的groovy語言,可以直接使用"[]"中括號表示返回的是List類型(具體語法會在下一篇講到)
三. 單元測試不僅僅是為了達到覆蓋率統計,更重要的是驗證業務代碼的健壯性、邏輯的嚴謹性以及設計的合理性
在項目初期為了趕進度,可能沒時間寫單測,或者這個時期寫的單測只是為了達到覆蓋率要求(因為有些公司在發布前會使用jacoco等單測覆蓋率工具來設置一個標准,比如新增代碼必須達到80%的覆蓋率才能發布)
再加上傳統的單測是使用java這種強類型語言寫的,以及各種底層接口的mock導致寫起單測來繁瑣費時
這時寫的單測代碼比較粗糙,顆粒度比較大,缺少對單測結果值的有效驗證,這樣的單元測試對代碼質量的驗證和提升無法完全發揮作用,更多的是為了測試而測試
最后大家不得不接受“雖然寫了單測,但卻沒什么鳥用”的結果
比如下面這段業務代碼示例:
void
方法,沒有返回結果,如何寫單測測試這段代碼的邏輯是否正確?即如何知道單測代碼是否執行到了for
循環里面的語句(可以通過查看覆蓋率或打斷點的方式確認,但這樣太麻煩了),如何確保循環里面的金額是否計算正確?
大家可以想下使用junit的方式寫單元測試如何驗證這幾點?
/**
* 用戶服務
* @author 公眾號:Java老K
* 個人博客:www.javakk.com
*/
@Service
public class UserService {
@Autowired
MoneyDAO moneyDAO;
/**
* 根據匯率計算金額
* @param userVO
*/
public void setOrderAmountByExchange(UserVO userVO){
if(null == userVO.getUserOrders() || userVO.getUserOrders().size() <= 0){
return ;
}
for(OrderVO orderVO : userVO.getUserOrders()){
BigDecimal amount = orderVO.getAmount();
// 獲取匯率(調用匯率接口)
BigDecimal exchange = moneyDAO.getExchangeByCountry(userVO.getCountry());
amount = amount.multiply(exchange); // 根據匯率計算金額
orderVO.setAmount(amount);
}
}
}
使用Spock寫的話就會方便很多,如下圖所示:
其中:
2 * moneyDAO.getExchangeByCountry(_) >> 0.1413 >> 0.1421
這行代碼表示在for
循環中一共調用了2次獲取匯率的接口,第一次匯率結果是0.1413,第二次是0.1421,(模擬匯率接口的實時變動),然后在with
里驗證,類似於junit里的assert
斷言,驗證匯率折算后的人民幣價格是否正確(完整代碼會在后續文章中列出)
這樣的好處就是:
提升單測代碼的可控性,方便驗證業務代碼的邏輯正確和是否合理, 這正是BDD(行為驅動開發)思想的一種體現
因為代碼的可測試性是衡量代碼質量的重要標准, 如果代碼不容易測試, 那就要考慮重構了, 這也是單元測試的一種正向作用
這一篇文章從3個方面對比展示了Spock的特點和優勢,后面會詳細講解Spock的各種用法(結合具體的業務場景),以及groovy的一些語法和注意事項