BDD測試框架Spock概要


前言

為了找到一個適合自己的、更具操作性的、以DDD為核心的開發方法,我最近一直在摸索如何揉合BDD與DDD。圍繞這個目標,我找到了Impact MappingCucumberSpock → 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 ReportsDamage 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)組成。除此以外,還有andwhereexpect等幾種不同的塊。

@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的方式,都是需要通過學習和實踐來確認的。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM