【spock】單測竟然可以如此絲滑
0. 為什麼人人都討厭寫單測
在之前的關於swagger文章裡提到過,程式設計師最討厭的兩件事,一件是別人不寫文件,另一件就是自己寫文件。這裡如果把文件換成單元測試也同樣成立。
每個開發人員都明白單元測試的作用,也都知道程式碼覆蓋率越高越好。高覆蓋率的程式碼,相對來說出現 BUG 的概率就越低,在線上執行就越穩定,接的鍋也就越少,就也不會害怕測試同事突然的關心。
既然這麼多好處,為什麼還會討厭他呢?至少在我看來,單測有如下幾點讓我喜歡不起來的理由。
第一,要額外寫很多很多的程式碼,一個高覆蓋率的單測程式碼,往往比你要測試的,真正開發的業務程式碼要多,甚至是業務程式碼的好幾倍。這讓人覺得難以接受,你想想開發 5 分鐘,單測 2 小時是什麼樣的心情。而且並不是單測寫完就沒事了,後面業務要是變更了,你所寫的單測程式碼也要同步維護。
第三,寫單測通常是一件很無趣的事,因為他比較死,主要目的就是為了驗證,相比之下他更像是個體力活,沒有真正寫業務程式碼那種創造的成就感。寫出來,驗證不出bug很失落,白寫了,驗證出bug又感到自己是在打自己臉。
1. 為什麼人人又必須寫單測
所以得到的結論就是不寫單測?那麼問題又來了,出來混遲早是要還的,上線出了問題,最終責任人是誰?不是提需求的產品、不是沒發現問題的測試同學,他們頂多就是連帶責任。最該負責的肯定是寫這段程式碼的你。特別是對於那些從事金融、交易、電商等息息相關業務的開發人員,跟每行程式碼打交通的都是真金白銀。每次明星搞事,微博就掛,已經被傳為笑談,畢竟只是娛樂相關,如果掛的是支付寶、微信,那使用者就沒有那麼大的包容度了。這些業務如果出現嚴重問題,輕則掃地出門,然後整個職業生涯揹負這個汙點,重則直接從面向物件開發變成面向監獄開發。所以單元測試保護的不僅僅是程式,更保護的是寫程式的你。
2. SPOCK 可以幫你改善單測體驗
當然,本文不是教你用旁門左道的方法提高程式碼覆蓋率。而是通過一個神奇的框架 spock 去提高你編寫單元測試的效率。spock 這名稱來源,個人猜測是因為《星際迷航》的同名人物(封面圖)。那麼spock 是如何提高編寫單測的效率呢?我覺得有以下幾點:
第一,他可以用更少的程式碼去實現單元測試,讓你可以更加專注於去驗證結果而不是寫單測程式碼的過程。那麼他又是如何做到少寫程式碼這件事呢?原來他使用一種叫做 groovy 的魔法。
另外不要誤以為我學這門框架,還要多學一門語言,成本太大。其實大可不必擔心,你如果會 groovy 當然更好,如果不會也沒有關係。因為 groovy 是基於 java 的,所以完全可以放心大膽的使用 java 的語法,某些要用到的 groovy 獨有的語法很少,而且後面都會告訴你。
第二,他有更好的語義化,讓你的單測程式碼可讀性更高。
語義化這個詞可能不太好理解。舉兩個例子來說吧,第一個是語義化比較好的語言 -- HTML。他的語法特點就是標籤,不同的型別放在不同的標籤裡。比如 head 就是頭部的資訊,body 是主體內容的資訊,table 就是表格的資訊,對於沒有程式設計經驗的人來說,也可以很容易理解。第二個是語義化比較差的語言 -- 正則。他可以說基本上沒有語義這種東西,由此導致的直接問題就是,即使是你自己的寫的正則,幾天之後你都不知道當時寫的是什麼。比如下面這個正則,你能猜出他是什麼意思嗎?(可以留言回覆)
((?:(?:25[0-5]|2[0-4]\d|[01]?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d?\d))
3. 領略 SPOCK 的魔法
3.1 引入依賴
<!--如果沒有使得 spring boot,以下包可以省略-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!--引入spock 核心包-->
<dependency>
<groupId>org.spockframework</groupId>
<artifactId>spock-core</artifactId>
<version>1.3-groovy-2.5</version>
<scope>test</scope>
</dependency>
<!--引入spock 與 spring 整合包-->
<dependency>
<groupId>org.spockframework</groupId>
<artifactId>spock-spring</artifactId>
<version>1.3-groovy-2.5</version>
<scope>test</scope>
</dependency>
<!--引入 groovy 依賴-->
<dependency>
<groupId>org.codehaus.groovy</groupId>
<artifactId>groovy-all</artifactId>
<version>2.5.7</version>
<scope>test</scope>
</dependency>
說明
註釋已經標明,第一個包是 spring boot 專案需要使用的,如果你只是想使用 spock,只要最下面 3 個即可。其中第一個包 spock-core 提供了 spock 的核心功能,第二個包 spock-spring 提供了與 spring 的整合(不用 spring 的情況下也可以不引入)。 注意這兩個包的版本號 -> 1.3-groovy-2.5。第一個版本號 1.3 其實代表是 spock 的版本,第二個版本號代表的是 spock 所要依賴的 groovy 環境的版本。最後一個包就是我們要依賴的 groovy 。
3.2 準備基礎測試類
3.2.1 Calculator.java
/*
* *
* * blog.coder4j.cn
* * Copyright (C) 2016-2019 All Rights Reserved.
*
*/
package cn.coder4j.study.example.spock;
/**
* @author buhao
* @version Calculator.java, v 0.1 2019-10-30 10:34 buhao
*/
public class Calculator {
/**
* 加操作
*
* @param num1
* @param num2
* @return
*/
public static int add(int num1, int num2) {
return num1 + num2;
}
/**
* 整型除操作
*
* @param num1
* @param num2
* @return
*/
public static int divideInt(int num1, int num2) {
return num1 / num2;
}
/**
* 浮點型操作
* @param num1
* @param num2
* @return
*/
public static double divideDouble(double num1, double num2){
return num1 / num2;
}
}
說明
這是一個很簡單的計算器類。只寫了三個方法,一個是加法的操作、一個整型的除法操作、一個浮點型別的除法操作。
3.3 開始單測 Calculator.java
3.3.1 建立單測類 CalculatorTest.groovy
class CalculatorTest extends Specification {
}
說明
這裡一定要注意,之前我們已經說了 spock 是基於 groovy 。所以單測類的字尾不是 .java 而** .groovy。千萬不要建立成普通 java 類了。否則建立沒有問題,但是寫一些 groovy 語法會報錯。如果你用的是 IDEA 可以通過如下方式建立,以前建立 Java 類我們都是選擇第一個選項,現在我們選擇第三個 Groovy Class** 就可以了。
另外就是 spock 的測試類需要繼承 spock.lang.Specification 類。
3.3.2 驗證加操作 - expect
def "test add"(){
expect:
Calculator.add(1, 1) == 2
}
說明
def 是 groovy 的關鍵字,可以用來定義變數跟方法名。後面 "test add" 是你單元測試的名稱,也可以用中文。最後重點說明的是 expect 這個關鍵字。
expect 字面上的意思是期望,我們期望什麼樣的事情發生。在使用其它單測框架時,與之類似的是 assert 。比如 _Assert.assertEquals(_Calculator.add(_1 + 1), 2) _這樣,表示我們斷言加操作傳入1 與 1 相加結果為 2。如果結果是這樣則用例通過,如果不是則用例失敗。這與我們上面的程式碼功能上完成一致。
expect 的語法意義就是在 expect 的塊內,所有表示式成立則驗證通過,反之有任一個不成立則驗證失敗。這裡引入了一個塊的概念。怎麼理解 spock 的塊呢?我們上面說 spock 有良好的語義化及更好的閱讀性就是因為這個塊的作用。可以類比成 html 中的標籤。html 的標籤的範圍是兩個標籤之間,而 spock 更簡潔一點,從這個標籤開始到下一個標籤開始或程式碼結束的地方,就是他的範圍。我們只要看到 expect 這個標籤就明白,他的範圍內都是我們預期要得到的結果。
3.3.3 驗證加操作 - given - and
這裡程式碼比較簡單,引數我只使用了一次,所以直接寫死。如果想複用,我就得把這些引數抽成變數。這個時候可以使用 spock 的 given 塊。given 的語法意義相當於是一個初始化的程式碼塊。
def "test add with given"(){
given:
def num1 = 1
def num2 = 1
def result = 2
expect:
Calculator.add(num1, num2) == result
}
當然你也可以像下面這樣寫,但是嚴重不推薦,因為雖然可以達到同樣的效果,但是不符合 spock 的語義。就像我們一般是在 head 裡面引入 js、css,但是你在 body 或者任何標籤裡都可以引入,語法沒有問題但是破壞了語義,不便理解與維護。
// 反倒
def "test add with given"(){
expect:
def num1 = 1
def num2 = 1
def result = 2
Calculator.add(num1, num2) == result
}
如果你還想讓語義更好一點,我們可以把引數與結果分開定義,這個時候可以使用 and 塊。它的語法功能可以理解成同他上面最近的一個標籤。
def "test add with given and"(){
given:
def num1 = 1
def num2 = 1
and:
def result = 2
expect:
Calculator.add(num1, num2) == result
}
3.3.4 驗證加操作 - expect - where
看了上面例子,可能覺得 spock 只是語義比較好,但是沒有少寫幾行程式碼呀。別急,下面我們就來看 spock 的一大殺器 where。
def "test add with expect where"(){
expect:
Calculator.add(num1, num2) == result
where:
num1 | num2 | result
1 | 1 | 2
1 | 2 | 3
1 | 3 | 4
}
where 塊可以理解成準備測試資料的地方,他可以跟 expect 組合使用。上面程式碼裡 expect 塊裡面定義了三個變數 num1、num2、result。這些資料我們可以在 where 塊裡定義。where 塊使用了一種很像 markdown 中表格的定義方法。第一行或者說表頭,列出了我們要傳資料的變數名稱,這裡要與 expect 中對應,不能少但是可以多。其它行都是資料行,與表頭一樣都是通過 『 | 』 號分隔。通過這樣,spock 就會跑 3 次用例,分別是 1 + 2 = 2、1 + 2 = 3、1 + 3 = 4 這些用例。怎麼樣?是不是很方便,後面再擴充用例只要再加一行資料就可以了。
3.3.5 驗證加操作 - expect - where - @Unroll
上面這些用例都是正常可以跑通的,如果是 IDEA 跑完之後會如下所示:
那麼現在我們看看如果有用例不通過會怎麼樣,把上面程式碼的最後一個 4 改成 5
def "test add with expect where"(){
expect:
Calculator.add(num1, num2) == result
where:
num1 | num2 | result
1 | 1 | 2
1 | 2 | 3
1 | 3 | 5
}
再跑一次,IDEA 會出現如下顯示
左邊標註出來的是用例執行結果,可以看出來雖然有 3 條資料,其中 2 條資料是成功,但是隻會顯示整體的成功與否,所以顯示未通過。但是 3 條資料,我怎麼知道哪條沒通過呢?
右邊標註出來的是 spock 列印的的錯誤日誌。可以很清楚的看到,在 num1 為 1,num2 為 3,result 為 5 並且 他們之間的判斷關係為 == 的結果是 false 才是正確的。 spock 的這個日誌列印的是相當歷害,如果是比較字串,還會計算異常字串與正確字串之間的匹配度,有興趣的同學,可以自行測試。
嗯,雖然可以通過日誌知道哪個用例沒通過,但是還是覺得有點麻煩。spock 也知道這一點。所以他還同時提供了一個** @Unroll **註解。我們在上面的程式碼上再加上這個註解:
@Unroll
def "test add with expect where unroll"(){
expect:
Calculator.add(num1, num2) == result
where:
num1 | num2 | result
1 | 1 | 2
1 | 2 | 3
1 | 3 | 5
}
執行結果如下:
通過新增** @Unroll** 註解,spock 自動把上面的程式碼拆分成了 3 個獨立的單測測試,分別執行,執行結果更清晰了。
那麼還能更清晰嗎?當然可以,我們發現 spock 拆分後,每個用例的名稱其實都是你寫的單測方法的名稱,然後後面加一個數組下標,不是很直觀。我們可以通過 groovy 的字串語法,把變數放入用例名稱中,程式碼如下:
@Unroll
def "test add with expect where unroll by #num1 + #num2 = #result"(){
expect:
Calculator.add(num1, num2) == result
where:
num1 | num2 | result
1 | 1 | 2
1 | 2 | 3
1 | 3 | 5
}
如上,我們在方法名後加了一句 #num1 + #num2 = #result。這裡有點類似我們在 mybatis 或者一些模板引擎中使用的方法。# 號拼接宣告的變數就可以了,執行後結果如下。
這下更清晰了。
另外一點,就是 where 預設使用的是表格的這種形式:
where:
num1 | num2 | result
1 | 1 | 2
1 | 2 | 3
1 | 3 | 5
很直觀,但是這種形式有一個弊端。上面 『 | 』 號對的這麼整齊。都是我一個空格一個 TAG 按出來的。雖然語法不要求對齊,但是逼死強迫症。不過,好在還可以有另一種形式:
@Unroll
def "test add with expect where unroll arr by #num1 + #num2 = #result"(){
expect:
Calculator.add(num1, num2) == result
where:
num1 << [1, 1, 2]
num2 << [1, 2, 3]
result << [1, 3, 4]
}
可以通過 『<<』 符(注意方向),把一個數組賦給變數,等同於上面的資料表格,沒有表格直觀,但是比較簡潔也不用考慮對齊問題,這兩種形式看個人喜好了。
3.3.6 驗證整數除操作 - when - then
我們都知道一個整數除以0 會有丟擲一個『/ by zero』異常,那麼如果斷言這個異常呢。用上面 expect 不太好操作,我們可以使用另一個類似的塊** when ... then**。
@Unroll
def "test int divide zero exception"(){
when:
Calculator.divideInt(1, 0)
then:
def ex = thrown(ArithmeticException)
ex.message == "/ by zero"
}
when ... then 通常是成對出現的,它代表著當執行了 when 塊中的操作,會出現 then 塊中的期望。比如上面的程式碼說明了,當執行了 Calculator.divideInt(1, 0) 的操作,就一定會丟擲 ArithmeticException 異常,並且異常資訊是 / by zero。
3.4 準備Spring測試類
上面我們已經學會了 spock 的基礎用法,下面我們將學習與 spring 整合的知識,首先建立幾個用於測試的demo 類
3.4.1 User.java
/*
* *
* * blog.coder4j.cn
* * Copyright (C) 2016-2019 All Rights Reserved.
*
*/
package cn.coder4j.study.example.spock.model;
import java.util.Objects;
/**
* @author buhao
* @version User.java, v 0.1 2019-10-30 16:23 buhao
*/
public class User {
private String name;
private Integer age;
private String passwd;
public User(String name, Integer age, String passwd) {
this.name = name;
this.age = age;
this.passwd = passwd;
}
/**
* Getter method for property <tt>passwd</tt>.
*
* @return property value of passwd
*/
public String getPasswd() {
return passwd;
}
/**
* Setter method for property <tt>passwd</tt>.
*
* @param passwd value to be assigned to property passwd
*/
public void setPasswd(String passwd) {
this.passwd = passwd;
}
/**
* Getter method for property <tt>name</tt>.
*
* @return property value of name
*/
public String getName() {
return name;
}
/**
* Setter method for property <tt>name</tt>.
*
* @param name value to be assigned to property name
*/
public void setName(String name) {
this.name = name;
}
/**
* Getter method for property <tt>age</tt>.
*
* @return property value of age
*/
public Integer getAge() {
return age;
}
/**
* Setter method for property <tt>age</tt>.
*
* @param age value to be assigned to property age
*/
public void setAge(Integer age) {
this.age = age;
}
public User() {
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
User user = (User) o;
return Objects.equals(name, user.name) &&
Objects.equals(age, user.age) &&
Objects.equals(passwd, user.passwd);
}
@Override
public int hashCode() {
return Objects.hash(name, age, passwd);
}
}
3.4.2 UserDao.java
/*
* *
* * blog.coder4j.cn
* * Copyright (C) 2016-2019 All Rights Reserved.
*
*/
package cn.coder4j.study.example.spock.dao;
import cn.coder4j.study.example.spock.model.User;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
/**
* @author buhao
* @version UserDao.java, v 0.1 2019-10-30 16:24 buhao
*/
@Component
public class UserDao {
/**
* 模擬資料庫
*/
private static Map<String, User> userMap = new HashMap<>();
static {
userMap.put("k",new User("k", 1, "123"));
userMap.put("i",new User("i", 2, "456"));
userMap.put("w",new User("w", 3, "789"));
}
/**
* 通過使用者名稱查詢使用者
* @param name
* @return
*/
public User findByName(String name){
return userMap.get(name);
}
}
3.4.3 UserService.java
/*
* *
* * blog.coder4j.cn
* * Copyright (C) 2016-2019 All Rights Reserved.
*
*/
package cn.coder4j.study.example.spock.service;
import cn.coder4j.study.example.spock.dao.UserDao;
import cn.coder4j.study.example.spock.model.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
/**
* @author buhao
* @version UserService.java, v 0.1 2019-10-30 16:29 buhao
*/
@Service
public class UserService {
@Autowired
private UserDao userDao;
public User findByName(String name){
return userDao.findByName(name);
}
public void loginAfter(){
System.out.println("登入成功");
}
public void login(String name, String passwd){
User user = findByName(name);
if (user == null){
throw new RuntimeException(name + "不存在");
}
if (!user.getPasswd().equals(passwd)){
throw new RuntimeException(name + "密碼輸入錯誤");
}
loginAfter();
}
}
3.4.3 Application.java
/*
* *
* * blog.coder4j.cn
* * Copyright (C) 2016-2019 All Rights Reserved.
*
*/
package cn.coder4j.study.example.spock;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
3.5 與 spring 整合測試
/*
* *
* * blog.coder4j.cn
* * Copyright (C) 2016-2019 All Rights Reserved.
*
*/
package cn.coder4j.study.example.spock.service
import cn.coder4j.study.example.spock.model.User
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import spock.lang.Specification
import spock.lang.Unroll
@SpringBootTest
class UserServiceFunctionTest extends Specification {
@Autowired
UserService userService
@Unroll
def "test findByName with input #name return #result"() {
expect:
userService.findByName(name) == result
where:
name << ["k", "i", "kk"]
result << [new User("k", 1, "123"), new User("i", 2, "456"), null]
}
@Unroll
def "test login with input #name and #passwd throw #errMsg"() {
when:
userService.login(name, passwd)
then:
def e = thrown(Exception)
e.message == errMsg
where:
name | passwd | errMsg
"kd" | "1" | "${name}不存在"
"k" | "1" | "${name}密碼輸入錯誤"
}
}
spock 與 spring 整合特別的簡單,只要你加入了開頭所說的 spock-spring 和 spring-boot-starter-test。再於測試程式碼的類上加上 @SpringBootTest 註解就可以了。想用的類直接注入進來就可以了,但是要注意的是這裡只能算功能測試或整合測試,因為在跑用例時是會啟動 spring 容器的,外部依賴也必須有。很耗時,而且有時候外部依賴本地也跑不了,所以我們通常都是通過 mock 來完成單元測試。
3.6 與 spring mock 測試
/*
* *
* * blog.coder4j.cn
* * Copyright (C) 2016-2019 All Rights Reserved.
*
*/
package cn.coder4j.study.example.spock.service
import cn.coder4j.study.example.spock.dao.UserDao
import cn.coder4j.study.example.spock.model.User
import spock.lang.Specification
import spock.lang.Unroll
class UserServiceUnitTest extends Specification {
UserService userService = new UserService()
UserDao userDao = Mock(UserDao)
def setup(){
userService.userDao = userDao
}
def "test login with success"(){
when:
userService.login("k", "p")
then:
1 * userDao.findByName("k") >> new User("k", 12,"p")
}
def "test login with error"(){
given:
def name = "k"
def passwd = "p"
when:
userService.login(name, passwd)
then:
1 * userDao.findByName(name) >> null
then:
def e = thrown(RuntimeException)
e.message == "${name}不存在"
}
@Unroll
def "test login with "(){
when:
userService.login(name, passwd)
then:
userDao.findByName("k") >> null
userDao.findByName("k1") >> new User("k1", 12, "p")
then:
def e = thrown(RuntimeException)
e.message == errMsg
where:
name | passwd | errMsg
"k" | "k" | "${name}不存在"
"k1" | "p1" | "${name}密碼輸入錯誤"
}
}
spock 使用 mock 也很簡單,直接使用 Mock(類) 就可以了。如上程式碼 _UserDao userDao = Mock(UserDao) 。_上面寫的例子中有幾點要說明一下,以如下這個方法為例:
def "test login with error"(){
given:
def name = "k"
def passwd = "p"
when:
userService.login(name, passwd)
then:
1 * userDao.findByName(name) >> null
then:
def e = thrown(RuntimeException)
e.message == "${name}不存在"
}
given、when、then 不用說了,大家已經很熟悉了,但是第一個 then 裡面的 1 * userDao.findByName(name) >> null 是什麼鬼?
首先,我們可以知道的是,一個用例中可以有多個 then 塊,對於多個期望可以分別放在多個 then 中。
第二, 1 * xx 表示 期望 xx 操作執行了 1 次。1 * userDao.findByName(name)** 就表現當執行 userService.login(name, passwd) 時我期望執行 1 次 userDao.findByName(name) 方法。如果期望不執行這個方法就是_0 * xx,這在條件程式碼的驗證中很有用,然後 >> null_ 又是什麼意思?他代表當執行了 userDao.findByName(name) 方法後,我讓他結果返回 null。因為 userDao 這個物件是我們 mock 出來的,他就是一個假物件,為了讓後續流程按我們的想法進行,我可以通過『 >>』 讓 spock 模擬返回指定資料。
第三,要注意第二個 then 程式碼塊使用 ${name} 引用變數,跟標題的 #name** 是不同的。
3.7 其它內容
3.7.1 公共方法
方法名 | 作用 |
---|---|
setup() | 每個方法執行前呼叫 |
cleanup() | 每個方法執行後呼叫 |
setupSpec() | 每個方法類載入前呼叫一次 |
cleanupSpec() | 每個方法類執行完呼叫一次 |
這些方法通常用於測試開始前的一些初始化操作,和測試完成後的清理操作,如下:
def setup() {
println "方法開始前初始化"
}
def cleanup() {
println "方法執行完清理"
}
def setupSpec() {
println "類載入前開始前初始化"
}
def cleanupSpec() {
println "所以方法執行完清理"
}
3.7.2 @Timeout
對於某些方法,需要規定他的時間,如果執行時間超過了指定時間就算失敗,這時可以使用 timeout 註解
@Timeout(value = 900, unit = TimeUnit.MILLISECONDS)
def "test timeout"(){
expect:
Thread.sleep(1000)
1 == 1
}
註解有兩個值,一個是 value 我們設定的數值,unit 是數值的單位。
3.7.3 with
def "test findByName by verity"() {
given:
def userDao = Mock(UserDao)
when:
userDao.findByName("kk") >> new User("kk", 12, "33")
then:
def user = userDao.findByName("kk")
with(user) {
name == "kk"
age == 12
passwd == "33"
}
}
with 算是一個語法糖,沒有他之前我們要判斷物件的值只能,user.getXxx() == xx。如果屬性過多也是挺麻煩的,用 with 包裹之後,只要在花括號內直接寫屬性名稱即可,如上程式碼所示。
4. 其它
4.1 完整程式碼
因為篇幅有限,無法貼完所有程式碼,完整程式碼已上傳 github。
4.2 參考文件
本文在瞻仰瞭如下博主的精彩博文後,再加上自身的學習總結加工而來,如果本文在看的時候有不明白的地方可以看一下下方連結。
- Spock in Java 慢慢愛上寫單元測試
- 使用Groovy+Spock輕鬆寫出更簡潔的單測
- Spock 測試框架的介紹和使用詳解
- Spock 基於BDD測試
- Spock 官方文件
- Spock測試框架
- spock-testing-exceptions-with-data-tables