前言
為了找到一個適合自己的、更具操作性的、以DDD為核心的開發方法,我最近一直在摸索如何揉合BDD與DDD。圍繞這個目標,我找到了Impact Mapping → Cucumber → Spock → Scala這樣的一條路線,並相應選擇了Scala → Spock → Cucumber這樣的一條學習路線。
Spock是Java生態圈中一個新生的測試框架,采用動態語言Groovy編寫。我是在閱讀《BDD in Action》過程中開始接觸Spock的。在該書中,作者將Spock的角色定位於取代JUnit等傳統測試框架的BDD工具——在用Cucumber將Feature轉譯為測試步驟Step后,再通過Step粘合業務代碼,同時用Spock編寫相應的單元測試,以保證Feature得以正確實現。(具體內容請參見該書第49頁關於驗收測試與單元測試關系的描述。)
由於用Spock編寫的測試也采用Gherkin語法形式的Given-When-Then三段式進行描述,可以很自然地對用例場景進行描述。《Java Testing with Spock》一書的作者Konstantinos Kapelonis提到Spock可以完全勝任BDD的需要,甚至取代Cucumber,因此我選擇在學習Cucumber之前先學習Spock,並使用該書作為參考的學習資料,留下這篇書摘。
使用Cucumber實施BDD
在了解Spock之前,最好先簡單熟悉一下Cucumber這個目前非常流行的BDD框架。這樣無論是打算用Spock取代Cucumber,或者是將二者結合使用,都能有一個自己的判斷。
注:C#配合SpecFlow實施BDD的簡要過程,請參見我的另一篇文章 行為驅動開發BDD概要
第一步:定義Feature文件
Feature: 向指定賬號存款
Scenario: 向A賬號存入100元
Given A賬號余額10元
When 向A賬號存入100元
Then A賬號余額為110元
第二步:將Scenario轉譯為若干個Step
Cucumber會將每個Scenario里的場景切分為若干個Step,然后用Java的Annotation標記
public class DepositStepDefinitions {
private Account account;
@Given("^an account has (\\d+)$")
public void an_account_has_balance(int balance) {
account = new Account();
account.balance = balance;
}
@When("^(\\d+) is deposited into account$")
public void amount_is_deposited(int amount) {
account.deposit(amount);
}
@Then("^the balance should be (\\d+)$")
public void balance_should_be_expected(int expect) {
assertTrue(" Yes or No? ", account.balance == expect);
}
}
第三步: 采用TDD編寫單元測試並實現業務代碼
由於這個示例非常簡單,在上面的Then Step里的斷言非常簡單,所以我沒有再編寫額外的單元測試。
public class Account {
public int balance;
public Account() {
this.balance = 0;
}
public void deposit(int amount) {
this.balance += amount;
}
}
使用Spock實施BDD
看過了Cucumber,再來看看Spock是怎么實現的。
@Title("向指定賬號存款")
class DepositSpec extends Specification {
def "向A賬號存入100元"() {
given: "A賬號余額10元"
Account account = new Account()
account.balance = 10
when: "向A賬號存入100元"
account.deposit(100)
then: "A賬號余額為110元""
account.balance == 110
}
}
Cucumber與Spock實現的簡單比較
從上面的示例代碼可以知,Cucumber與Spock都同樣以一個Feature作為開始。但不同於Cucumber自動將Feature轉譯為Step,再與相應的測試代碼或者業務代碼粘合,Spock直接通過手工編碼實現從Feature到測試代碼的映射,因此相應減少了這個轉譯的環節。
這種區別,正是我目前非常糾結的。在《BDD in Action》一書中,作者在使用Cucumber定義了Then Step斷言的情況下,又提到要使用JUnit等框架編寫更細粒度的單元測試。於是,我就產生了這樣的疑惑:『Step斷言的粒度與單元測試斷言的粒度,應該如何划分?這樣的重復有必要嗎?』特別是象上面的示例一樣,Spock也能『自描述』Specification時。
要回答這個問題,我認為僅僅依靠《BDD in Action》中那些不完整的示例,還是比較困難的。所以我會在讀完下一本書——《The Cucumber for Java Book》之后,再回頭來看看是不是我對Cucumber有誤解。
4-7更新
文章發表后,我和朋友們當晚就圍繞我的困惑進行了討論。終於在清晨起床的時候,突然恍然大悟。Cucumber關注的更多的是驗收測試這個層面的東西,而JUnit關注的則是更細碎的單元測試層面的東西。二者關注的角度、粒度和要解決的問題都不同。所以《BDD in Action》作者提到的Cucumber+Spock的方法並沒有什么不妥。當然,Spock也能勝任從單元測試、集成測試到驗收測試的所有工作,所以僅僅使用Spock也未嘗不可。歸根結底,只是一個工具選擇的問題。
使用Spock的代價
因為Cucumber有着對應不同編程語言的版本,比如Cucumber for Java, SpecFlow for .NET,因此能與團隊選擇的開發語言無縫銜接。反觀Spock,盡管它支持Java混編,但其語法仍依賴於Groovy,因此增加了相應的學習成本,而且在工程實施過程中進行多語言的混合編程。
對此,需要提醒的是,Spock只用到了Groovy的極小部分語言特性,因此學習代價並不算很大。在業余時間照着《Java Testing with Spock》邊看邊做,我也只用了一周多點的時間。反倒是如何寫好Feature文件,並從中篩選出Key Example,成了我最大的困擾。
至於編寫好的測試,由於Cucumber與Spock都與Java兼容,因此可以直接在JUnit Runner上進行。測試報告的生成亦是如此,既可以使用JUnit輔助工具生成測試報告,也可以使用其專用的Spock Reports、Damage Control等等。
Spock入門
Groovy語法基礎
Groovy和Scala一樣,也是一種基於Java實現的編程語言。區別於Scala的,Groovy是動態語言。學會Spock的常見功能,只需掌握Groovy以下語法即可。
- 語句結束用的分號
;
,和Scala一樣是可選的。 - Groovy將一切視作對象,
==
將直接調用對象的equals()
方法,這與Scala同出一轍。 - 通常用一對雙引號表示一個字符串常量,
"這是一個字符串常量"
,使用\
作為轉義符。 - 用一對單引號表示不用轉義其中的任何字符,類似C#里的
@
。 - 用成對的3個單引號或雙引號表示一個保留換行格式的多行字符串,用
\
表示其中的換行符。這種方式通常用作大段的描述文本,比如'''這是保留了換行、縮進的RAW字符串常量'''
。 - int、float等表示數字類型,使用方法類似Java,比如1.002f。
- Groovy將0、
null
、空的數組或空字符串視為false
,非0值、有效的引用、非空的數組和非空的字符串則均視為true
。 - 使用關鍵字
def
表示動態類型,類似C#里的dynamic
。 - 類的訪問修飾與Scala一樣,默認都是
public
,而field默認是private
。 - 使用
$
作為字符串插入符,必要時使用一對大括號包圍插入值,類似C# 6.0,比如return "$name, ${age > 18}"
。 - 對象初始化方式類似JSON,整個表達式用圓括號包圍,field與值以冒號間隔、成對出現,數組或序列用中括號包圍,數組索引從0開始,用
[:]
表示一個空的Map - 閉包、lambda或者匿名函數使用大括號包圍,和Scala一樣,比如
{count -> count * 10}
。 - 使用
_
作為參數占位符,使用方法大致同Scala。它既可以用來指代參數、方法,也可以指代返回值或者where塊中的測試參數。
Spock測試的基本結構
Spock的測試類均派生自Specification
,命名遵循Java規范。每個測試方法可以直接用文本作為方法名,方法內部由given-when-then
的三段式塊(block)組成。除此以外,還有and
、where
、expect
等幾種不同的塊。
@Title("測試的標題")
@Narrative("""關於測試的大段文本描述""")
@Subject(Adder) //標明被測試的類是Adder
@Stepwise //當測試方法間存在依賴關系時,標明測試方法將嚴格按照其在源代碼中聲明的順序執行
class TestCaseClass extends Specification {
@Shared //在測試方法之間共享的數據
SomeClass sharedObj
def setupSpec() {
//TODO: 設置每個測試類的環境
}
def setup() {
//TODO: 設置每個測試方法的環境,每個測試方法執行一次
}
@Ignore("忽略這個測試方法")
@Issue(["問題#23","問題#34"])
def "測試方法1" () {
given: "給定一個前置條件"
//TODO: code here
and: "其他前置條件"
expect: "隨處可用的斷言"
//TODO: code here
when: "當發生一個特定的事件"
//TODO: code here
and: "其他的觸發條件"
then: "產生的后置結果"
//TODO: code here
and: "同時產生的其他結果"
where: "不是必需的測試數據"
input1 | input2 || output
... | ... || ...
}
@IgnoreRest //只測試這個方法,而忽略所有其他方法
@Timeout(value = 50, unit = TimeUnit.MILLISECONDS) // 設置測試方法的超時時間,默認單位為秒
def "測試方法2"() {
//TODO: code here
}
def cleanup() {
//TODO: 清理每個測試方法的環境,每個測試方法執行一次
}
def cleanupSepc() {
//TODO: 清理每個測試類的環境
}
}
斷言
- 在then塊里,不需要
assertEquals("斷言提示", left, right)
這樣的方式,直接寫left == right
這樣的邏輯表達式即可。 - 借助Groovy的語法,Spock使用
N * method()
來判定該方法是否被調用了N次。而N * method() >> true
則表示方法method被調用N次,且每次該方法的返回值均為true。 - 用
thrown(異常類型)
斷言會拋出指定類型的異常,notThrown(異常類型)
則反之。
參數化測試
Spock使用where塊,為測試方法提供表格化的測試數據。其中表頭為測試方法中要用在斷言中的變量名稱或者表達式,用|
分隔輸入參數,用||
分隔輸入與輸出。這些參數,可以用#參數名
的方式在@Unroll
描述或者測試方法名里定義,或者在測試方法的參數列表里定義,然后在where塊中使用。
@Unroll("test #para0, #para1") //where塊中的每行參數都轉換為一個獨立的測試用例
def "測試方法3 #para2, #para3"(int first, int second) {
... ...
where: "parameterized sample"
para0 | para1 | para2 || para3 | first | second
10 | 2 | 3 || 7 | 2 | 5
}
除以上這種表格式的數據,Groovy支持<<
和<<<
操作符作為輸入源,用>>
操作符指示輸出,還有類似Scala的1 .. 10
這樣生成序列的語法。
array << [0, 5, 7, 9]
list << (1 .. 10)
[name, condition] << [["Jack", true], ["Tom", false]]
isConfirmed() >> true
getAnswers() >>> [1, 2, 5, 7] //順次返回列表里的值
isAvailable(_) >>> true >> false //首次返回true,第2次返回false
Stub與Mock
Spock提供Stub與Mock功能,主要應用於單元測試。其使用很簡單,直接Stub(類或接口)
或Mock(類或接口)
即可。
given: "preparation"
Inventory inventory = Stub(Inventory) {
location >> "LOS"
manager >> "Tom"
}
DeliveryProcessor processor = Mock(DeliveryProcessor)
when: "one order is confirmed"
order.Confirm()
then: "only serviceA is called"
1 * processor.serviceA(_)
0 * processor._
原則上,任何的Stub都可以用Mock代替,反之而不盡然。因為Stub是輔助測試的工具,它模擬或者說偽裝成一個真正的對象,為測試提供一個完整的環境(輸入);而Mock才是驗證對象行為的工具,雖然它也是對象的一個假體,但其目的是確定對象是否按正常預期工作(輸出)。
Maven配置
加入Groovy支持
<plugin>
<groupId>org.codehaus.gmavenplus</groupId>
<artifactId>gmavenplus-plugin</artifactId>
<version>1.4</version>
<executions>
<execution>
<goals>
<goal>compile</goal>
<goal>testCompile</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.6</version>
<configuration>
<useFile>false</useFile>
<includes>
<include>**/*Spec.java</include>
<include>**/*Test.java</include>
</includes>
</configuration>
</plugin>
加入Spock支持
<dependency>
<groupId>org.spockframework</groupId>
<artifactId>spock-core</artifactId>
<version>1.0-groovy-2.4</version>
<scope>test</scope>
</dependency>
<dependency> <!-- enables mocking of classes
(in addition to interfaces) -->
<groupId>cglib</groupId>
<artifactId>cglib-nodep</artifactId>
<version>3.1</version>
<scope>test</scope>
</dependency>
<dependency> <!-- enables mocking of classes without default
constructor (together with CGLIB) -->
<groupId>org.objenesis</groupId>
<artifactId>objenesis</artifactId>
<version>2.1</version>
<scope>test</scope>
</dependency>
結語
在該書的最后一部分,作者就Spock在企業應用開發領域進行規模化應用進行了較為詳盡的闡述,包括如何組織Spock測試,如何與Maven集成,如何協調單元測試、集成測試和功能測試,如何使用Geb和REST工具輔助測試等等。在附錄中,也詳細介紹了Spock如何與Eclipse等常見IDE進行集成。有興趣的,可以讀一讀這本書。
基於Groovy動態語言的強大功能,Spock框架仍在不斷發展,因此這篇書摘意義的文章只算是管中窺豹。特別是在具體實踐BDD的過程中,究竟是僅靠Spock,亦或Cucumber+Spock的方式,都是需要通過學習和實踐來確認的。