Spock單元測試框架介紹以及在美團優選的實踐

美團技術團隊 2021-08-15 08:22:17 阅读数:932

本文一共[544]字,预计阅读时长:1分钟~
spock 框架

1. 背景

​XML之父Tim Bray最近在博客裏有個好玩的說法:“代碼不寫測試就像上了廁所不洗手……單元測試是對軟件未來的一項必不可少的投資。”具體來說,單元測試有哪些收益呢?

  • 它是最容易保證代碼覆蓋率達到100%的測試。
  • 可以⼤幅降低上線時的緊張指數。
  • 單元測試能更快地發現問題(見下圖左)。
  • 單元測試的性價比最高,因為錯誤發現的越晚,修複它的成本就越高,而且難度呈指數式增長,所以我們要盡早地進行測試(見下圖右)。
  • 編碼人員,一般也是單元測試的主要執行者,是唯一能够做到生產出無缺陷程序的人,其他任何人都無法做到這一點。
  • 有助於源碼的優化,使之更加規範,快速反饋,可以放心進行重構。
pic2
這張圖來自微軟的統計數據:Bug在單元測試階段被發現,平均耗時3.25小時,如果漏到系統測試階段,要花費11.5小時。 這張圖,旨在說明兩個問題:85%的缺陷都在代碼設計階段產生,而發現Bug的階段越靠後,耗費成本就越高,指數級別的增高。

盡管單元測試有如此的收益,但在我們日常的工作中,仍然存在不少項目它們的單元測試要麼是不完整要麼是缺失的。常見的原因總結如下:代碼邏輯過於複雜;寫單元測試時耗費的時間較長;任務重、工期緊,或者幹脆就不寫了。

基於以上問題,相較於傳統的JUnit單元測試,今天為大家推薦一款名為Spock的測試框架。目前,美團優選物流技術團隊絕大部分後端服務已經采用了Spock作為測試框架,在開發效率、可讀性和維護性方面取得了不錯的收益。

不過網上Spock資料比較簡單,甚至包括官網的Demo,無法解决我們項目中複雜業務場景面臨的問題,通過深入學習和實踐之後,本文會將一些經驗分享出來,希望能够幫助大家提高開發測試的效率。

2. Spock是什麼?和JUnit、jMock有什麼區別?

Spock是一款國外優秀的測試框架,基於BDD(行為驅動開發)思想實現,功能非常强大。Spock結合Groovy動態語言的特點,提供了各種標簽,並采用簡單、通用、結構化的描述語言,讓編寫測試代碼更加簡潔、高效。官方的介紹如下:

What is it? Spock is a testing and specification framework for Java and Groovy applications. What makes it stand out from the crowd is its beautiful and highly expressive specification language. Thanks to its JUnit runner, Spock is compatible with most IDEs, build tools, and continuous integration servers. Spock is inspired from JUnit, RSpec, jMock, Mockito, Groovy, Scala, Vulcans, and other fascinating life forms.

Spock是一個Java和Groovy`應用的測試和規範框架。之所以能够在眾多測試框架中脫穎而出,是因為它優美而富有錶現力的規範語言。Spock的靈感來自JUnit、RSpec、jMock、Mockito、Groovy、Scala、Vulcans。

簡單來講,Spock主要特點如下:

  • 讓測試代碼更規範,內置多種標簽來規範單元測試代碼的語義,測試代碼結構清晰,更具可讀性,降低後期維護難度。
  • 提供多種標簽,比如:givenwhenthenexpectwherewiththrown……幫助我們應對複雜的測試場景。
  • 使用Groovy這種動態語言來編寫測試代碼,可以讓我們編寫的測試代碼更簡潔,適合敏捷開發,提高編寫單元測試代碼的效率。
  • 遵從BDD(行為驅動開發)模式,有助於提昇代碼的質量。
  • IDE兼容性好,自帶Mock功能。

為什麼使用Spock? Spock和JUnit、jMock、Mockito的區別在哪裏?

總的來說,JUnit、jMock、Mockito都是相對獨立的工具,只是針對不同的業務場景提供特定的解决方案。其中JUnit單純用於測試,並不提供Mock功能。

我們的服務大部分是分布式微服務架構。服務與服務之間通常都是通過接口的方式進行交互。即使在同一個服務內也會分為多個模塊,業務功能需要依賴下遊接口的返回數據,才能繼續後面的處理流程。這裏的下遊不限於接口,還包括中間件數據存儲比如Squirrel、DB、MCC配置中心等等,所以如果想要測試自己的代碼邏輯,就必須把這些依賴項Mock掉。因為如果下遊接口不穩定可能會影響我們代碼的測試結果,讓下遊接口返回指定的結果集(事先准備好的數據),這樣才能驗證我們的代碼是否正確,是否符合邏輯結果的預期。

盡管jMock、Mockito提供了Mock功能,可以把接口等依賴屏蔽掉,但不能對靜態方法Mock。雖然PowerMock、jMockit能够提供靜態方法的Mock,但它們之間也需要配合(JUnit + Mockito PowerMock)使用,並且語法上比較繁瑣。工具多了就會導致不同的人寫出的單元測試代碼“五花八門”,風格相差較大。

Spock通過提供規範性的描述,定義多種標簽(givenwhenthenwhere等),去描述代碼“應該做什麼”,“輸入條件是什麼”,“輸出是否符合預期”,從語義層面規範了代碼的編寫。

Spock自帶Mock功能,使用簡單方便(也支持擴展其他Mock框架,比如PowerMock),再加上Groovy動態語言的强大語法,能寫出簡潔高效的測試代碼,同時能方便直觀地驗證業務代碼的行為流轉,增强工程師對代碼執行邏輯的可控性。

3. 使用Spock解决單元測試開發中的痛點

如果在(if/else)分支很多的複雜場景下,編寫單元測試代碼的成本會變得非常高,正常的業務代碼可能只有幾十行,但為了測試這個功能覆蓋大部分的分支場景,編寫的測試代碼可能遠不止幾十行。

之前有遇到過某個功能上線很久一直都很正常,沒有出現過問題,但後來有個調用請求的數據不一樣,走到了代碼中一個不常用的邏輯分支時,出現了Bug。當時寫這段代碼的同學也認為只有很小幾率才能走到這個分支,盡管當時寫了單元測試,但因為時間比較緊張,分支又多,就漏掉了這個分支的測試。

盡管使用JUnit的@Parametered參數化注解或者DataProvider方式可以解决多數據分支問題,但不够直觀,而且如果其中某一次分支測試Case出錯了,它的報錯信息也不够詳盡。

這就需要一種編寫測試用例高效、可讀性强、占用工時少、維護成本低的測試框架。首先不能讓業務人員排斥編寫單元測試,更不能讓工程師覺得寫單元測試是在浪費時間。而且使用JUnit做測試工作量不算小。據初步統計,采用JUnit的話,它的測試代碼行和業務代碼行能到3:1。如果采用Spock作為測試框架的話,它的比例可縮减到1:1,能够大大提高編寫測試用例的效率。

下面借用《編程珠璣》中一個計算稅金的例子。

public double calc(double income) {
BigDecimal tax;
BigDecimal salary = BigDecimal.valueOf(income);
if (income <= 0) {
return 0;
}
if (income > 0 && income <= 3000) {
BigDecimal taxLevel = BigDecimal.valueOf(0.03);
tax = salary.multiply(taxLevel);
} else if (income > 3000 && income <= 12000) {
BigDecimal taxLevel = BigDecimal.valueOf(0.1);
BigDecimal base = BigDecimal.valueOf(210);
tax = salary.multiply(taxLevel).subtract(base);
} else if (income > 12000 && income <= 25000) {
BigDecimal taxLevel = BigDecimal.valueOf(0.2);
BigDecimal base = BigDecimal.valueOf(1410);
tax = salary.multiply(taxLevel).subtract(base);
} else if (income > 25000 && income <= 35000) {
BigDecimal taxLevel = BigDecimal.valueOf(0.25);
BigDecimal base = BigDecimal.valueOf(2660);
tax = salary.multiply(taxLevel).subtract(base);
} else if (income > 35000 && income <= 55000) {
BigDecimal taxLevel = BigDecimal.valueOf(0.3);
BigDecimal base = BigDecimal.valueOf(4410);
tax = salary.multiply(taxLevel).subtract(base);
} else if (income > 55000 && income <= 80000) {
BigDecimal taxLevel = BigDecimal.valueOf(0.35);
BigDecimal base = BigDecimal.valueOf(7160);
tax = salary.multiply(taxLevel).subtract(base);
} else {
BigDecimal taxLevel = BigDecimal.valueOf(0.45);
BigDecimal base = BigDecimal.valueOf(15160);
tax = salary.multiply(taxLevel).subtract(base);
}
return tax.setScale(2, BigDecimal.ROUND_HALF_UP).doubleValue();
}

能够看到上面的代碼中有大量的if-else語句,Spock提供了where標簽,可以讓我們通過錶格的方式來測試多種分支。

@Unroll
def "個稅計算,收入:#income, 個稅:#result"() {
expect: "when + then 的組合"
CalculateTaxUtils.calc(income) == result
where: "錶格方式測試不同的分支邏輯"
income || result
-1 || 0
0 || 0
2999 || 89.97
3000 || 90.0
3001 || 90.1
11999 || 989.9
12000 || 990.0
12001 || 990.2
24999 || 3589.8
25000 || 3590.0
25001 || 3590.25
34999 || 6089.75
35000 || 6090.0
35001 || 6090.3
54999 || 12089.7
55000 || 12090
55001 || 12090.35
79999 || 20839.65
80000 || 20840.0
80001 || 20840.45
}

上圖中左邊使用Spock寫的單元測試代碼,語法簡潔,錶格方式測試覆蓋分支場景更加直觀,開發效率高,更適合敏捷開發。

單元測試代碼的可讀性和後期維護

我們微服務場景很多時候需要依賴其他接口返回的結果,才能驗證自己的代碼邏輯。Mock工具是必不可少的。但jMock、Mockito的語法比較繁瑣,再加上單元測試代碼不像業務代碼那麼直觀,又不能完全按照業務流程的思路寫單元測試,這就讓不少同學對單元測試代碼可讀性不够重視,最終導致測試代碼難以閱讀,維護起來更是難上加難。甚至很多同學自己寫的單元測試,過幾天再看也一樣覺得“雲裏霧裏”的。也有改了原來的代碼邏輯導致單元測試執行失敗的;或者新增了分支邏輯,單元測試沒有覆蓋到的;最終隨著業務的快速迭代單元測試代碼越來越難以維護。

Spock提供多種語義標簽,如:givenwhenthenexpectwherewithand等,從行為上規範了單元測試代碼,每一種標簽對應一種語義,讓單元測試代碼結構具有層次感,功能模塊劃分更加清晰,也便於後期的維護。

Spock自帶Mock功能,使用上簡單方便(Spock也支持擴展第三方Mock框架,比如PowerMock)。我們可以再看一個樣例,對於如下的代碼邏輯進行單元測試:

public StudentVO getStudentById(int id) {
List<StudentDTO> students = studentDao.getStudentInfo();
StudentDTO studentDTO = students.stream().filter(u -> u.getId() == id).findFirst().orElse(null);
StudentVO studentVO = new StudentVO();
if (studentDTO == null) {
return studentVO;
}
studentVO.setId(studentDTO.getId());
studentVO.setName(studentDTO.getName());
studentVO.setSex(studentDTO.getSex());
studentVO.setAge(studentDTO.getAge());
// 郵編
if ("上海".equals(studentDTO.getProvince())) {
studentVO.setAbbreviation("滬");
studentVO.setPostCode("200000");
}
if ("北京".equals(studentDTO.getProvince())) {
studentVO.setAbbreviation("京");
studentVO.setPostCode("100000");
}
return studentVO;
}

比較明顯,左邊的JUnit單元測試代碼冗餘,缺少結構層次,可讀性差,隨著後續的迭代,勢必會導致代碼的堆積,維護成本會變得越來越高。右邊的單元測試代碼Spock會强制要求使用givenwhenthen這樣的語義標簽(至少一個),否則編譯不通過,這樣就能保證代碼更加規範,結構模塊化,邊界範圍清晰,可讀性强,便於擴展和維護。而且使用了自然語言描述測試步驟,讓非技術人員也能看懂測試代碼(given錶示輸入條件,when觸發動作,then驗證輸出結果)。

Spock自帶的Mock語法也非常簡單:dao.getStudentInfo() >> [student1, student2]

兩個右箭頭>>錶示模擬getStudentInfo接口的返回結果,再加上使用的Groovy語言,可以直接使用[]中括號錶示返回的是List類型。

單元測試不僅僅是為了統計代碼覆蓋率,更重要的是驗證業務代碼的健壯性、業務邏輯的嚴謹性以及設計的合理性

在項目初期階段,可能為了追趕進度而沒有時間寫單元測試,或者這個時期寫的單元測試只是為了達到覆蓋率的要求(比如為了滿足新增代碼行或者分支覆蓋率統計要求)。

很多工程師寫的單元測試基本都是采用Java這種强類型語言編寫,各種底層接口的Mock寫起來不僅繁瑣而且耗時。這時的單元測試代碼可能就寫得比較粗糙,有粒度過大的,也有缺少單元測試結果驗證的。這樣的單元測試對代碼的質量幫助不大,更多是為了測試而測試。最後時間沒少花,可效果卻沒有達到。

針對有效測試用例方面,我們測試基礎組件組開發了一些檢測工具(作為抓手),比如去掃描大家寫的單元測試,檢測單元測試的斷言有效性等。另外在結果校驗方面,Spock錶現也是十分優异的。我們可以來看接下來的場景:void方法,沒有返回結果,如何寫測試這段代碼的邏輯是否正確?

如何確保單元測試代碼是否執行到了for循環裏面的語句,循環裏面的打折計算又是否正確呢?

 public void calculatePrice(OrderVO order){
BigDecimal amount = BigDecimal.ZERO;
for (SkuVO sku : order.getSkus()) {
Integer skuId = sku.getSkuId();
BigDecimal skuPrice = sku.getSkuPrice();
BigDecimal discount = BigDecimal.valueOf(discountDao.getDiscount(skuId));
BigDecimal price = skuPrice * discount;
amount = amount.add(price);
}
order.setAmount(amount.setScale(2, BigDecimal.ROUND_HALF_DOWN));
}

如果用Spock寫的話,就會方便很多,如下圖所示:

這裏,2 * discountDao.getDiscount(_) >> 0.95 >> 0.8for循環中一共調用了2次,第一次返回結果0.95,第二次返回結果0.8,最後再進行驗證,類似於JUnit中的Assert斷言。

這樣的收益還是比較明顯的,不僅提高了單元測試的可控性,而且方便驗證業務代碼的邏輯正確性和合理性,這也是BDD思想的一種體現。

4. Mock模擬

考慮如下場景,代碼如下:

@Service
public class StudentService {
@Autowired
private StudentDao studentDao;
public StudentVO getStudentById(int id) {
List<StudentDTO> students = studentDao.getStudentInfo();
StudentDTO studentDTO = students.stream().filter(u -> u.getId() == id).findFirst().orElse(null);
StudentVO studentVO = new StudentVO();
if (studentDTO == null) {
return studentVO;
}
studentVO.setId(studentDTO.getId());
studentVO.setName(studentDTO.getName());
studentVO.setSex(studentDTO.getSex());
studentVO.setAge(studentDTO.getAge());
// 郵編
if ("上海".equals(studentDTO.getProvince())) {
studentVO.setAbbreviation("滬");
studentVO.setPostCode("200000");
}
if ("北京".equals(studentDTO.getProvince())) {
studentVO.setAbbreviation("京");
studentVO.setPostCode("100000");
}
return studentVO;
}
}

其中studentDao是使用Spring注入的實例對象,我們只有拿到了返回的students,才能繼續下面的邏輯(根據id篩選學生,DTOVO轉換,郵編等)。所以正常的做法是把studentDaogetStudentInfo()方法Mock掉,模擬一個指定的值,因為我們真正關心的是拿到students後自己代碼的邏輯,這是需要重點驗證的地方。按照上面的思路使用Spock編寫的測試代碼如下:

class StudentServiceSpec extends Specification {
def studentDao = Mock(StudentDao)
def tester = new StudentService(studentDao: studentDao)
def "test getStudentById"() {
given: "設置請求參數"
def student1 = new StudentDTO(id: 1, name: "張三", province: "北京")
def student2 = new StudentDTO(id: 2, name: "李四", province: "上海")
and: "mock studentDao返回值"
studentDao.getStudentInfo() >> [student1, student2]
when: "獲取學生信息"
def response = tester.getStudentById(1)
then: "結果驗證"
with(response) {
id == 1
abbreviation == "京"
postCode == "100000"
}
}
}

這裏主要講解Spock的代碼(從上往下)。

def studentDao = Mock(StudentDao) 這一行代碼使用Spock自帶的Mock方法,構造一個studentDao的Mock對象,如果要模擬studentDao方法的返回,只需studentDao.方法名() >> "模擬值"的方式,兩個右箭頭的方式即可。test getStudentById方法是單元測試的主要方法,可以看到分為4個模塊:givenandwhenthen,用來區分不同單元測試代碼的作用:

  • given:輸入條件(前置參數)。
  • when:執行行為(Mock接口、真實調用)。
  • then:輸出條件(驗證結果)。
  • and:銜接上個標簽,補充的作用。

每個標簽後面的雙引號裏可以添加描述,說明這塊代碼的作用(非强制),如when:"獲取信息"。因為Spock使用Groovy作為單元測試開發語言,所以代碼量上比使用Java寫的會少很多,比如given模塊裏通過構造函數的方式創建請求對象。

實際上StudentDTO.java 這個類並沒有3個參數的構造方法,是Groovy幫我們實現的。Groovy默認會提供一個包含所有對象屬性的構造方法。而且調用方式上可以指定屬性名,類似於key:value的語法,非常人性化,方便在屬性多的情况下構造對象,如果使用Java寫,可能就要調用很多的setXxx()方法,才能完成對象初始化的工作。

這個就是Spock的Mock用法,當調用studentDao.getStudentInfo()方法時返回一個ListList的創建也很簡單,中括號[]即錶示List,Groovy會根據方法的返回類型,自動匹配是數組還是List,而List裏的對象就是之前given塊裏構造的user對象,其中 >> 就是指定返回結果,類似Mockitowhen().thenReturn()語法,但更簡潔一些。

如果要指定返回多個值的話,可以使用3個右箭頭>>>,比如:studentDao.getStudentInfo() >>> [[student1,student2],[student3,student4],[student5,student6]]

也可以寫成這樣:studentDao.getStudentInfo() >> [student1,student2] >> [student3,student4] >> [student5,student6]

每次調用studentDao.getStudentInfo()方法返回不同的值。

public List<StudentDTO> getStudentInfo(String id){
List<StudentDTO> students = new ArrayList<>();
return students;
}

這個getStudentInfo(String id)方法,有個參數id,這種情况下如果使用Spock的Mock模擬調用的話,可以使用下劃線_匹配參數,錶示任何類型的參數,多個逗號隔開,類似於Mockitoany()方法。如果類中存在多個同名方法,可以通過 _ as參數類型 的方式區別調用,如下面的語法:

// _ 錶示匹配任意類型參數
List<StudentDTO> students = studentDao.getStudentInfo(_);
// 如果有同名的方法,使用as指定參數類型區分
List<StudentDTO> students = studentDao.getStudentInfo(_ as String);

when模塊裏是真正調用要測試方法的入口tester.getStudentById()then模塊作用是驗證被測方法的結果是否正確,符合預期值,所以這個模塊裏的語句必須是boolean錶達式,類似於JUnit的assert斷言機制,但不必顯示地寫assert,這也是一種約定優於配置的思想。then塊中使用了Spock的with功能,可以驗證返回結果response對象內部的多個屬性是否符合預期值,這個相對於JUnit的assertNotNullassertEquals的方式更簡單一些。

强大的Where

上面的業務代碼有2個if判斷,是對郵編處理邏輯:

 // 郵編
if ("上海".equals(studentDTO.getProvince())) {
studentVO.setAbbreviation("滬");
studentVO.setPostCode("200000");
}
if ("北京".equals(studentDTO.getProvince())) {
studentVO.setAbbreviation("京");
studentVO.setPostCode("100000");
}

如果要完全覆蓋這2個分支就需要構造不同的請求參數,多次調用被測試方法才能走到不同的分支。在前面,我們介紹了Spock的where標簽可以很方便的實現這種功能,代碼如下所示:

 @Unroll
def "input 學生id:#id, 返回的郵編:#postCodeResult, 返回的省份簡稱:#abbreviationResult"() {
given: "Mock返回的學生信息"
studentDao.getStudentInfo() >> students
when: "獲取學生信息"
def response = tester.getStudentById(id)
then: "驗證返回結果"
with(response) {
postCode == postCodeResult
abbreviation == abbreviationResult
}
where: "經典之處:錶格方式驗證學生信息的分支場景"
id | students || postCodeResult | abbreviationResult
1 | getStudent(1, "張三", "北京") || "100000" | "京"
2 | getStudent(2, "李四", "上海") || "200000" | "滬"
}
def getStudent(def id, def name, def province) {
return [new StudentDTO(id: id, name: name, province: province)]
}

where模塊第一行代碼是錶格的列名,多個列使用|單豎線隔開,||雙豎線區分輸入和輸出變量,即左邊是輸入值,右邊是輸出值。格式如下:

輸入參數1 | 輸入參數2 || 輸出結果1 | 輸出結果2

而且IntelliJ IDEA支持format格式化快捷鍵,因為錶格列的長度不一樣,手動對齊比較麻煩。錶格的每一行代錶一個測試用例,即被測方法執行了2次,每次的輸入和輸出都不一樣,剛好可以覆蓋全部分支情况。比如idstudents都是輸入條件,其中students對象的構造調用了getStudent方法,每次測試業務代碼傳入不同的student值,postCodeResultabbreviationResult錶示對返回的response對象的屬性判斷是否正確。第一行數據的作用是驗證返回的郵編是否是100000,第二行是驗證郵編是否是200000。這個就是where+with的用法,更符合我們實際測試的場景,既能覆蓋多種分支,又可以對複雜對象的屬性進行驗證,其中在定義的測試方法名,使用了Groovy的字面值特性:

即把請求參數值和返回結果值的字符串動態替換掉,#id#postCodeResult#abbreviationResult#號後面的變量是在方法內部定義的,實現占比特符的功能。

@Unroll注解,可以把每一次調用作為一個單獨的測試用例運行,這樣運行後的單元測試結果更加直觀:

而且如果其中某行測試結果不對,Spock的錯誤提示信息也很詳細,方便進行排查(比如我們把第1條測試用例返回的郵編改成100001):

可以看出,第1條測試用例失敗,錯誤信息是postCodeResult的預期結果和實際結果不符,業務代碼邏輯返回的郵編是100000,而我們預期的郵編是100001,這樣就可以排查是業務代碼邏輯有問題,還是我們的斷言不對。

5. 异常測試

我們再看下异常方面的測試,例如下面的代碼:

 public void validateStudent(StudentVO student) throws BusinessException {
if(student == null){
throw new BusinessException("10001", "student is null");
}
if(StringUtils.isBlank(student.getName())){
throw new BusinessException("10002", "student name is null");
}
if(student.getAge() == null){
throw new BusinessException("10003", "student age is null");
}
if(StringUtils.isBlank(student.getTelephone())){
throw new BusinessException("10004", "student telephone is null");
}
if(StringUtils.isBlank(student.getSex())){
throw new BusinessException("10005", "student sex is null");
}
}

BusinessException是封裝的業務异常,主要包含codemessage屬性:

/**
* 自定義業務异常
*/
public class BusinessException extends RuntimeException {
private String code;
private String message;
setXxx...
getXxx...
}

這個大家應該都很熟悉,針對這種拋出多個不同錯誤碼和錯誤信息的异常。如果使用JUnit的方式測試,會比較麻煩。如果是單個异常還好,如果是多個的話,測試代碼就不太好寫。

 @Test
public void testException() {
StudentVO student = null;
try {
service.validateStudent(student);
} catch (BusinessException e) {
Assert.assertEquals(e.getCode(), "10001");
Assert.assertEquals(e.getMessage(), "student is null");
}
student = new StudentVO();
try {
service.validateStudent(student);
} catch (BusinessException e) {
Assert.assertEquals(e.getCode(), "10002");
Assert.assertEquals(e.getMessage(), "student name is null");
}
}

當然可以使用JUnit的ExpectedException方式:

@Rule
public ExpectedException exception = ExpectedException.none();
exception.expect(BusinessException.class); // 驗證异常類型
exception.expectMessage("xxxxxx"); //驗證异常信息

或者使用@Test(expected = BusinessException.class) 注解,但這兩種方式都有缺陷。

@Test方式不能指定斷言的异常屬性,比如codemessageExpectedException的方式也只提供了expectMessage的API,對自定義的code不支持,尤其像上面的有很多分支拋出多種不同异常碼的情况。接下來我們看下Spock是如何解决的。Spock內置thrown()方法,可以捕獲調用業務代碼拋出的預期异常並驗證,再結合where錶格的功能,可以很方便地覆蓋多種自定義業務异常,代碼如下:

 @Unroll
def "validate student info: #expectedMessage"() {
when: "校驗"
tester.validateStudent(student)
then: "驗證"
def exception = thrown(expectedException)
exception.code == expectedCode
exception.message == expectedMessage
where: "測試數據"
student || expectedException | expectedCode | expectedMessage
getStudent(10001) || BusinessException | "10001" | "student is null"
getStudent(10002) || BusinessException | "10002" | "student name is null"
getStudent(10003) || BusinessException | "10003" | "student age is null"
getStudent(10004) || BusinessException | "10004" | "student telephone is null"
getStudent(10005) || BusinessException | "10005" | "student sex is null"
}
def getStudent(code) {
def student = new StudentVO()
def condition1 = {
student.name = "張三"
}
def condition2 = {
student.age = 20
}
def condition3 = {
student.telephone = "12345678901"
}
def condition4 = {
student.sex = "男"
}
switch (code) {
case 10001:
student = null
break
case 10002:
student = new StudentVO()
break
case 10003:
condition1()
break
case 10004:
condition1()
condition2()
break
case 10005:
condition1()
condition2()
condition3()
break
}
return student
}

then標簽裏用到了Spock的thrown()方法,這個方法可以捕獲我們要測試的業務代碼裏拋出的异常。thrown()方法的入參expectedException,是我們自己定義的异常變量,這個變量放在where標簽裏就可以實現驗證多種异常情况的功能(Intellij Idea格式化快捷鍵,可以自動對齊錶格)。expectedException類型調用validateUser方法裏定義的BusinessException异常,可以驗證它所有的屬性,codemessage是否符合預期值。

6. Spock靜態方法測試

接下來,我們一起看下Spock如何擴展第三方PowerMock對靜態方法進行測試。

Spock的單元測試代碼繼承自Specification基類,而Specification又是基於JUnit的注解@RunWith()實現的,代碼如下:

PowerMock的PowerMockRunner也是繼承自JUnit,所以使用PowerMock的@PowerMockRunnerDelegate()注解,可以指定Spock的父類Sputnik去代理運行PowerMock,這樣就可以在Spock裏使用PowerMock去模擬靜態方法、final方法、私有方法等。其實Spock自帶的GroovyMock可以對Groovy文件的靜態方法Mock,但對Java代碼支持不完整,只能Mock當前Java類的靜態方法,官方給出的解釋如下:

如下代碼:

 public StudentVO getStudentByIdStatic(int id) {
List<StudentDTO> students = studentDao.getStudentInfo();
StudentDTO studentDTO = students.stream().filter(u -> u.getId() == id).findFirst().orElse(null);
StudentVO studentVO = new StudentVO();
if (studentDTO == null) {
return studentVO;
}
studentVO.setId(studentDTO.getId());
studentVO.setName(studentDTO.getName());
studentVO.setSex(studentDTO.getSex());
studentVO.setAge(studentDTO.getAge());
// 靜態方法調用
String abbreviation = AbbreviationProvinceUtil.convert2Abbreviation(studentDTO.getProvince());
studentVO.setAbbreviation(abbreviation);
studentVO.setPostCode(studentDTO.getPostCode());
return studentVO;
}

上面使用了AbbreviationProvinceUtil.convert2Abbreviation()靜態方法,對應的測試用例代碼如下:

@RunWith(PowerMockRunner.class)
@PowerMockRunnerDelegate(Sputnik.class)
@PrepareForTest([AbbreviationProvinceUtil.class])
@SuppressStaticInitializationFor(["example.com.AbbreviationProvinceUtil"])
class StudentServiceStaticSpec extends Specification {
def studentDao = Mock(StudentDao)
def tester = new StudentService(studentDao: studentDao)
void setup() {
// mock靜態類
PowerMockito.mockStatic(AbbreviationProvinceUtil.class)
}
def "test getStudentByIdStatic"() {
given: "創建對象"
def student1 = new StudentDTO(id: 1, name: "張三", province: "北京")
def student2 = new StudentDTO(id: 2, name: "李四", province: "上海")
and: "Mock掉接口返回的學生信息"
studentDao.getStudentInfo() >> [student1, student2]
and: "Mock靜態方法返回值"
PowerMockito.when(AbbreviationProvinceUtil.convert2Abbreviation(Mockito.any())).thenReturn(abbreviationResult)
when: "調用獲取學生信息方法"
def response = tester.getStudentByIdStatic(id)
then: "驗證返回結果是否符合預期值"
with(response) {
abbreviation == abbreviationResult
}
where:
id || abbreviationResult
1 || "京"
2 || "滬"
}
}

StudentServiceStaticSpec類的頭部使用@PowerMockRunnerDelegate(Sputnik.class)注解,交給Spock代理執行,這樣既可以使用Spock +Groovy的各種功能,又可以使用PowerMock的對靜態,final等方法的Mock。@SuppressStaticInitializationFor(["example.com.AbbreviationProvinceUtil"]),這行代碼的作用是限制AbbreviationProvinceUtil類裏的靜態代碼塊初始化,因為AbbreviationProvinceUtil類在第一次調用時可能會加載一些本地資源配置,所以可以使用PowerMock禁止初始化。然後在setup()方法裏對靜態類進行Mock設置,PowerMockito.mockStatic(AbbreviationProvinceUtil.class)。最後在test getStudentByIdStatic測試方法裏對convert2Abbreviation()方法指定返回默認值:PowerMockito.when(AbbreviationProvinceUtil.convert2Abbreviation(Mockito.any())).thenReturn(abbreviationResult)

運行時在控制臺會輸出:

Notifications are not supported for behaviour ALL_TESTINSTANCES_ARE_CREATED_FIRST

這是Powermock的警告信息,不影響運行結果。

如果單元測試代碼不需要對靜態方法、final方法Mock,就沒必要使用PowerMock,使用Spock自帶的Mock()就足够了。因為PowerMock的原理是在編譯期通過ASM字節碼修改工具修改代碼,然後使用自己的ClassLoader加載,而加載的靜態方法越多,測試耗時就會越長。

7. 動態Mock靜態方法

考慮場景,讓靜態方法每次調用返回不同的值。

以下代碼:

public List<OrderVO> getOrdersBySource(){
List<OrderVO> orderList = new ArrayList<>();
OrderVO order = new OrderVO();
if ("APP".equals(HttpContextUtils.getCurrentSource())) {
if("CNY".equals(HttpContextUtils.getCurrentCurrency())){
System.out.println("source -> APP, currency -> CNY");
} else {
System.out.println("source -> APP, currency -> !CNY");
}
order.setType(1);
} else if ("WAP".equals(HttpContextUtils.getCurrentSource())) {
System.out.println("source -> WAP");
order.setType(2);
} else if ("ONLINE".equals(HttpContextUtils.getCurrentSource())) {
System.out.println("source -> ONLINE");
order.setType(3);
}
orderList.add(order);
return orderList;
}

這段代碼的if else分支邏輯,主要是依據HttpContextUtils這個工具類的靜態方法getCurrentSource()getCurrentCurrency()的返回值來决定流程。這樣的業務代碼也是我們平時寫單元測試時經常遇到的場景,如果能讓HttpContextUtils.getCurrentSource()靜態方法每次Mock出不同的值,就可以很方便地覆蓋if else的全部分支邏輯。Spock的where標簽可以方便地和PowerMock結合使用,讓PowerMock模擬的靜態方法每次返回不同的值,代碼如下:

PowerMock的thenReturn方法返回的值是sourcecurrency等2個變量,不是具體的數據,這2個變量對應where標簽裏的前兩列source|currency。這樣的寫法,就可以在每次測試業務方法時,讓HttpContextUtils.getCurrentSource()HttpContextUtils.getCurrentCurrency()返回不同的來源和幣種,就能輕松的覆蓋ifelse的分支代碼。即Spock使用where錶格的方式讓PowerMock具有了動態Mock的功能。接下來,我們再看一下如何對於final變量進行Mock。

public List<OrderVO> convertOrders(List<OrderDTO> orders){
List<OrderVO> orderList = new ArrayList<>();
for (OrderDTO orderDTO : orders) {
OrderVO orderVO = OrderMapper.INSTANCE.convert(orderDTO);
if (1 == orderVO.getType()) {
orderVO.setOrderDesc("App端訂單");
} else if(2 == orderVO.getType()) {
orderVO.setOrderDesc("H5端訂單");
} else if(3 == orderVO.getType()) {
orderVO.setOrderDesc("PC端訂單");
}
orderList.add(orderVO);
}
return orderList;
}

這段代碼裏的for循環第一行調用了OrderMapper.INSTANCE.convert()轉換方法,將orderDTO轉換為orderVO,然後根據type值走不同的分支,而OrderMapper是一個接口,代碼如下:

@Mapper
public interface OrderMapper {
// 即使不用static final修飾,接口裏的變量默認也是靜態、final的
static final OrderMapper INSTANCE = Mappers.getMapper(OrderMapper.class);
@Mappings({})
OrderVO convert(OrderDTO requestDTO);
}

INSTANCE是接口OrderMapper裏定義的變量,接口裏的變量默認都是static final的,所以我們要先把這個INSTANCE靜態final變量Mock掉,這樣才能調用它的方法convert()返回我們想要的值。OrderMapper這個接口是mapstruct工具的用法,mapstruct是做對象屬性映射的一個工具,它會自動生成OrderMapper接口的實現類,生成對應的setget方法,把orderDTO的屬性值賦給orderVO屬性,通常情况下會比使用反射的方式好不少。看下Spock如何寫這個單元測試:

@Unroll
def "test convertOrders"() {
given: "Mock掉OrderMapper的靜態final變量INSTANCE,並結合Spock設置動態返回值"
def orderMapper = Mock(OrderMapper.class)
Whitebox.setInternalState(OrderMapper.class, "INSTANCE", orderMapper)
orderMapper.convert(_) >> order
when:
def orders = service.convertOrders([new OrderDTO()])
then: "驗證結果"
with(orders) {
it[0].orderDesc == desc
}
where: "測試數據"
order || desc
new OrderVO(type: 1) || "App端訂單"
new OrderVO(type: 2) || "H5端訂單"
new OrderVO(type: 3) || "PC端訂單"
}
  • 首先使用Spock自帶的Mock()方法,將OrderMapper類Mock為一個模擬對象orderMapperdef orderMapper = Mock(OrderMapper.class)
  • 然後使用PowerMock的Whitebox.setInternalState(),對OrderMapper接口的static final常量INSTANCE賦值(Spock不支持靜態常量的Mock),賦的值正是使用SpockMock的對象orderMapper
  • 使用Spock的Mock模擬convert()方法調用,orderMapper.convert(_) >> order,再結合where錶格,實現動態Mock接口的功能。

主要是這3行代碼:

def orderMapper = Mock(OrderMapper.class) // 先使用Spock的Mock
Whitebox.setInternalState(OrderMapper.class, "INSTANCE", orderMapper) // 通過PowerMock把Mock對象orderMapper賦值給靜態常量INSTANCE
orderMapper.convert(_) >> order // 結合where模擬不同的返回值

這樣就可以使用Spock結合PowerMock測試靜態常量,達到覆蓋if else不同分支邏輯的功能。

8. 覆蓋率

Jacoco是統計單元測試覆蓋率的一種工具,當然Spock也自帶了覆蓋率統計的功能,這裏使用第三方Jacoco的原因主要是國內公司使用的比較多一些,包括美團很多技術團隊現在使用的也是Jacoco,所以為了兼容就以Jacoco來查看單元測試覆蓋率。這裏說下如何通過Jacoco確認分支是否完全覆蓋到。

在pom文件裏引用Jacoco的插件:jacoco-maven-plugin,然後執行mvn package 命令,成功後會在target目錄下生成單元測試覆蓋率的報告,點開報告找到對應的被測試類查看覆蓋情况。

綠色背景錶示完全覆蓋,黃色是部分覆蓋,紅色沒有覆蓋到。比如第34行黃色背景的else if() 判斷,提示有二分之一的分支缺失,雖然它下面的代碼也被覆蓋了(顯示為綠色),這種情况跟具體使用哪種單元測試框架沒關系,因為這只是分支覆蓋率統計的規則,只不過使用Spock的話,解决起來會更簡單,只需在where下增加一行針對的測試數據即可。

9. DAO層測試

DAO層的測試有些不太一樣,不能再使用Mock,否則無法驗證SQL是否正確。對於DAO測試有一般最簡的方式是直接使用@SpringBootTest注解啟動測試環境,通過Spring創建Mybatis、Mapper實例,但這種方式並不屬於單元測試,而是集成測試範疇了,因為當啟用@SpringBootTest時,會把整個應用的上下文加載進來。不僅耗時時間長,而且一旦依賴環境上有任何問題,可能會影響啟動,進而影響DAO層的測試。最後,需要到數據庫盡可能隔離,因為如果大家都使用同一個Test環境的數據的話,一旦測試用例編寫有問題,就可能會污染Test環境的數據。

針對以上場景,可采用以下方案: 1. 通過MyBatis的SqlSession啟動mapper實例(避免通過Spring啟動加載上下文信息)。 2. 通過內存數據庫(如H2)隔離大家的數據庫連接(完全隔離不會存在互相幹擾的現象)。 3. 通過DBUnit工具,用作對於數據庫層的操作訪問工具。 4. 通過擴展Spock的注解,提供對於數據庫Schema創建和數據Data加載的方式。如csv、xml或直接Closure編寫等。

在pom文件增加相應的依賴。

<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>1.4.200</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.dbunit</groupId>
<artifactId>dbunit</artifactId>
<version>2.5.1</version>
<scope>test</scope>
</dependency>

增加Groovy的maven插件、資源文件拷貝以及測試覆蓋率統計插件。

<!-- 測試插件 -->
<plugin>
<groupId>org.codehaus.gmavenplus</groupId>
<artifactId>gmavenplus-plugin</artifactId>
<version>1.8.1</version>
<executions>
<execution>
<goals>
<goal>addSources</goal>
<goal>addTestSources</goal>
<goal>generateStubs</goal>
<goal>compile</goal>
<goal>generateTestStubs</goal>
<goal>compileTests</goal>
<goal>removeStubs</goal>
<goal>removeTestStubs</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.0.0-M3</version>
<configuration>
<useFile>false</useFile>
<includes>
<include>**/*Spec.java</include>
</includes>
<parallel>methods</parallel>
<threadCount>10</threadCount>
<testFailureIgnore>true</testFailureIgnore>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-resources-plugin</artifactId>
<version>2.6</version>
<executions>
<execution>
<id>copy-resources</id>
<phase>compile</phase>
<goals>
<goal>copy-resources</goal>
</goals>
<configuration>
<outputDirectory>${basedir}/target/resources</outputDirectory>
<resources>
<resource>
<directory>${basedir}/src/main/resources</directory>
<filtering>true</filtering>
</resource>
</resources>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.2</version>
<executions>
<execution>
<id>prepare-agent</id>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>prepare-package</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
<execution>
<id>post-unit-test</id>
<phase>test</phase>
<goals>
<goal>report</goal>
</goals>
<configuration>
<dataFile>target/jacoco.exec</dataFile>
<outputDirectory>target/jacoco-ut</outputDirectory>
</configuration>
</execution>
</executions>
</plugin>

加入對於Spock擴展的自動處理框架(用於數據SchemaData初始化操作)。

這裏介紹一下主要內容,注解@MyDbUnit

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@ExtensionAnnotation(MyDbUnitExtension.class)
@interface MyDbUnit {
/**
* <pre>
* content = {
* your_table_name(id: 1, name: 'xxx', age: 21)
* your_table_name(id: 2, name: 'xxx', age: 22)
* })
</pre>
* @return
*/
Class<? extends Closure> content() default Closure.class;
/**
* xml存放路徑(相對於測試類)
* @return
*/
String xmlLocation() default "";
/**
* csv存放路徑(相對於測試類)
* @return
*/
String csvLocation() default "";
}

考慮以下代碼的測試:

@Repository("personInfoMapper")
public interface PersonInfoMapper {
@Delete("delete from person_info where id=#{id}")
int deleteById(Long id);
@Select("select count(*) from person_info")
int count();
@Select("select * from user_info")
List<PersonInfoDO> selectAll();
}

Demo1 (使用@MyDbUnitcontent指定導入數據內容,格式Closure)。

class Demo1Spec extends MyBaseSpec {
/**
* 直接獲取待測試的mapper
*/
def personInfoMapper = MapperUtil.getMapper(PersonInfoMapper.class)
/**
* 測試數據准備,通常為sql錶結構創建用的ddl,支持多個文件以逗號分隔。
*/
def setup() {
executeSqlScriptFile("com/xxx/xxx/xxx/......../schema.sql")
}
/**
* 數據錶清除,通常待drop的數據錶
*/
def cleanup() {
dropTables("person_info")
}
/**
* 直接構造數據庫中的數據錶,此方法適用於數據量較小的mapper sql測試
*/
@MyDbUnit(
content = {
person_info(id: 1, name: "abc", age: 21)
person_info(id: 2, name: "bcd", age: 22)
person_info(id: 3, name: "cde", age: 23)
}
)
def "demo1_01"() {
when:
int beforeCount = personInfoMapper.count()
// groovy sql用於快速執行sql,不僅能驗證數據結果,也可向數據中添加數據。
def result = new Sql(dataSource).firstRow("select * from `person_info`")
int deleteCount = personInfoMapper.deleteById(1L)
int afterCount = personInfoMapper.count()
then:
beforeCount == 3
result.name == "abc"
deleteCount == 1
afterCount == 2
}
/**
* 直接構造數據庫中的數據錶,此方法適用於數據量較小的mapper sql測試
*/
@MyDbUnit(content = {
person_info(id: 1, name: 'a', age: 21)
})
def "demo1_02"() {
when:
int beforeCount = personInfoMapper.count()
def result = new Sql(dataSource).firstRow("select * from `person_info`")
int deleteCount = personInfoMapper.deleteById(1L)
int afterCount = personInfoMapper.count()
then:
beforeCount == 1
result.name == "a"
deleteCount == 1
afterCount == 0
}
}

setup()階段,把數據庫錶中的Schema創建好,然後通過下面的@MyDbUnit注解的content屬性,把數據導入到數據庫中。person_info是錶名,idnameage是數據。

通過MapperUtil.getMapper()方法獲取mapper實例。

當測試數據量較大時,可以編寫相應的數據文件,通過@MyDbUnitxmlLocationcsvLocation加載文件(分別支持csv和xml格式)。

如通過csv加載文件,csvLocation指向csv文件所在文件夾。

 @MyDbUnit(csvLocation = "com/xxx/........./data01")
def "demo2_01"() {
when:
int beforeCount = personInfoMapper.count()
def result = new Sql(dataSource).firstRow("select * from `person_info`")
int deleteCount = personInfoMapper.deleteById(1L)
int afterCount = personInfoMapper.count()
then:
beforeCount == 3
result.name == "abc"
deleteCount == 1
afterCount == 2
}

通過xml加載文件,xmlLocation指向xml文件所在路徑。

@MyDbUnit(xmlLocation = "com/xxxx/........./demo3_02.xml")
def "demo3_02"() {
when:
int beforeCount = personInfoMapper.count()
def result = new Sql(dataSource).firstRow("select * from `person_info`")
int deleteCount = personInfoMapper.deleteById(1L)
int afterCount = personInfoMapper.count()
then:
beforeCount == 1
result.name == "a"
deleteCount == 1
afterCount == 0
}

還可以不通過@MyDbUnit而使用API直接加載測試數據文件。

class Demo4Spec extends MyBaseSpec {
def personInfoMapper = MapperUtil.getMapper(PersonInfoMapper.class)
/**
* 數據錶清除,通常待drop的數據錶
*/
def cleanup() {
dropTables("person_info")
}
def "demo4_01"() {
given:
executeSqlScriptFile("com/xxxx/.........../schema.sql")
IDataSet dataSet = MyDbUnitUtil.loadCsv("com/xxxx/.........../data01");
DatabaseOperation.CLEAN_INSERT.execute(MyIDatabaseConnection.getInstance().getConnection(), dataSet);
when:
int beforeCount = personInfoMapper.count()
def result = new Sql(dataSource).firstRow("select * from `person_info`")
int deleteCount = personInfoMapper.deleteById(1L)
int afterCount = personInfoMapper.count()
then:
beforeCount == 3
result.name == "abc"
deleteCount == 1
afterCount == 2
}
def "demo4_02"() {
given:
executeSqlScriptFile("com/xxxx/.........../schema.sq")
IDataSet dataSet = MyDbUnitUtil.loadXml("com/xxxx/.........../demo3_02.xml");
DatabaseOperation.CLEAN_INSERT.execute(MyIDatabaseConnection.getInstance().getConnection(), dataSet);
when:
int beforeCount = personInfoMapper.count()
def result = new Sql(dataSource).firstRow("select * from `person_info`")
int deleteCount = personInfoMapper.deleteById(1L)
int afterCount = personInfoMapper.count()
then:
beforeCount == 1
result.name == "a"
deleteCount == 1
afterCount == 0
}
}

最後為大家梳理了一些文檔,供大家參考。

作者簡介

建華,美團優選事業部工程師。

版权声明:本文为[美團技術團隊]所创,转载请带上原文链接,感谢。 https://gsmany.com/2021/08/20210815082207902p.html