Spock測試套件入門

西北偏北UP發表於2020-09-18

Spock測試套件

Spock套件基於一個單元測試框架,它有比junit更為簡潔高效的測試語法。

核心概念

整體認識

Spock中一個單元測試類名叫Specification。所有的單元測試類,都需要繼承Specification

class MyFirstSpecification extends Specification {
  // fields
  // fixture methods
  // feature methods
  // helper methods
}

對於spock來說,Specification代表了一個軟體、應用、類的使用規範,其中的所有單元測試方法,被稱為feature,即功能。

一個feature method的執行邏輯大概是如下幾步:

  1. setup 設定該功能的前置配置
  2. stimulus 提供一個輸入,觸發該功能
  3. response 描述你期望該功能的返回值
  4. cleanup 清理功能的前置配置

所以,對spock來說,一個單元測試,其實是這個軟體應用提供的功能使用規範,這個規範中提供了每個功能的使用說明書,輸入什麼,會得到什麼,大體是按這個看法,去寫單元測試的。

前置、後置

就像junit一樣,我們可以對整個單元測試類做一些前置,並清理。也可以對每個單元測試的方法做一些前置後清理。

其跟Junit的類比關係為

setupSpec 對應 @BeforeClass
setup 對應 @Before
cleanup 對應 @After
cleanupSpec 對應 @AfterClass

同時由於Spock的單元測試本身是會整合Specification 父類的,所以父類中的前置、後置方法也會被呼叫,不過不用顯示呼叫,會自動呼叫。

一個測試功能方法執行時,其整體的執行順序為:

super.setupSpec

sub.setupSpec

super.setup

sub.setup

**feature method

sub.cleanup

super.cleanup

sub.cleanupSpec

super.cleanupSpec

同junit的類比

Feature 方法

blocks

feature的具體寫法有很多的block組成,這些block對應的feature方法本身的四個階段(setup, stimulus, reponse, cleanup) 。每個block對應階段示意圖

典型的用法

    def '測試++'(){
        given:
            def x = 5
        when: def result = calculateService.plusPlus(x)
        then: result == 6
    }
  • given也可以寫成setup,feature方法裡的given其實跟外面的setup方法功能一樣,都是做測試功能的前置設定。只是單獨的setup方法,是用來寫對每個測試feature都有用的測試。只跟當前feature相關的設定,請放在feature方法內的given標籤
  • when 標籤用來實際呼叫想要測試的feature
  • then 中對when的呼叫返回進行結果驗證,這裡不需要寫斷言,直接寫表示式就是斷言

異常condition

then中的斷言在spock中叫condition。比如Java中的Stack在沒有元素時,進行Popup,則會EmptyStackException異常。我們期望它確實會丟擲這個異常,那麼寫法如下

def '異常2'() {
        given:
        def stack = new Stack()
        when:
        def result = stack.pop()
        then:
        EmptyStackException e = thrown()
    }

它並不會丟擲EmptyStackException,我們要測試這個預期的話,程式碼如下:

    def '異常2'() {
        given:
        def stack = new Stack()
        stack.push("hello world")
        when:
        stack.pop()
        then:
        EmptyStackException e = notThrown()
    }

then和expect的區別

前面說了when block用來呼叫,then用來判斷預期結果。但有的時候,我們的呼叫和預期判斷並不複雜,那麼可以用expect將兩者合在一起,比如以下兩段程式碼等價

when:
def x = Math.max(1, 2)

then:
x == 2
expect:
Math.max(1, 2) == 2

cleanup block的用法

def 'cleanup'() {
        given:
        def file = new File("/some/path")
        file.createNewFile()

        // ...

        cleanup:
        file.delete()
    }

用於清理feature測試執行後的一些設定,比如開啟的檔案連結。該操作即便測試的feature出異常,依然會被呼叫

同樣,如果多個測試feature都需要這個cleanup.那麼建議將cleanup的資源提到setup方法中,並在cleanup方法中去清理

測試用例中的文字描述

為了讓單元測試可讀性更高,可以將測試方法中每一部分用文字進行描述,多個描述可以用and來串聯

    def '異常2'() {
        given:'設定stack物件'
        def stack = new Stack()

        and:'其它變數設施'
        stack.push('hello world')


        when:'從stack中彈出元素'
        def result = stack.pop()

        then:'預期會出現的異常'
        EmptyStackException e = thrown()
    }

Extension

spock通過標註來擴充單元測試的功能

@Timeout指定一個測試方法,或一個設定方法最長可以執行的時間,用於對效能有要求的測試
@Ignore用於忽略當前的測試方法
@IgnoreRest忽略除當前方法外的所有方法,用於想快速的測一個方法
@FailsWith 跟exception condition類似

資料驅動測試

資料表

對於有些功能邏輯,其程式碼是一樣的,只是需要測試不同輸入值。按照先前的介紹,最簡潔的寫法為:

    def "maximum of two numbers1"() {
        expect:
        // exercise math method for a few different inputs
        Math.max(1, 3) == 3
        Math.max(7, 4) == 4
        Math.max(0, 0) == 1
    }

缺點:

  1. Math.max程式碼需要手動呼叫三次
  2. 第二行出錯後,第三行不會被執行
  3. 資料和程式碼耦合在一起,不方便資料從其它地方獨立準備

所以spock引入了資料表的概念,將測試資料和程式碼分開。典型例項如下:

class MathSpec extends Specification {
  def "maximum of two numbers"() {
    expect:
    Math.max(a, b) == c

    where:
    a | b || c
    1 | 3 || 3
    7 | 4 || 7
    0 | 0 || 0
  }
}
  • where語句中,定義資料表。第一行是表頭,定義這一列所屬的變數。
  • 實際程式碼呼叫,只需要呼叫一次。程式碼中的變數跟資料表中的變數必須一一對應
  • 看似一個方法,實際上執行時,spock會根據資料表中的行數,迴圈迭代執行程式碼。每一行都是獨立於其餘行執行,所以有setup和cleanup塊,對每一個行的都會重複執行一次
  • 並且某一行的資料出錯,並不影響其餘行的執行

另外的寫法

def "maximum of two numbers"(int a, int b ,int c) {
        expect:
        Math.max(a, b) == c

        where:
        a | b | c
        1 | 3 | 3
        7 | 4 | 4
        0 | 0 | 1
    }
  • 變數可以在方法引數中宣告,但沒必要
  • 資料表可以全部用一個豎線來分割,但無法像兩個豎線一樣清晰的分割輸入和輸出

更清晰的測試結果展示

class MathSpec extends Specification {
  def "maximum of two numbers"() {
    expect:
    Math.max(a, b) == c

    where:
    a | b || c
    1 | 3 || 3
    7 | 4 || 4
    0 | 0 || 1
  }
}

以上測試程式碼,資料表中的後兩行會執行失敗。但從測試結果皮膚中,不能很好的看到詳細結果

使用@Unroll可以將每個迭代的執行結果輸出

可以看到皮膚中實際輸出的文字為測試方法的名稱。如果像在輸出中加上輸入輸出的變數,來詳細展示每個迭代,可以在方法名中使用佔位符#variable來引用變數的值。舉例如下:

@Unroll
    def "maximum of #a and #b is #c"() {
        expect:
        Math.max(a, b) == c

        where:
        a | b || c
        1 | 3 || 3
        7 | 4 || 4
        0 | 0 || 1
    }

更豐富的資料準備方式

前面的資料表顯示的將資料以表格的形式寫出來。實際上,資料在where block中的準備還有其它多種方式。

where:
a << [1, 7, 0]
b << [3, 4, 0]
c << [3, 7, 0]

從資料庫中查詢

@Shared sql = Sql.newInstance("jdbc:h2:mem:", "org.h2.Driver")

def "maximum of two numbers"() {
  expect:
  Math.max(a, b) == c

  where:
  [a, b, c] << sql.rows("select a, b, c from maxdata")
}

使用groovy程式碼賦值

where:
a = 3
b = Math.random() * 100
c = a > b ? a : b

以上幾種方式可以混搭。

其中方法名也可以以豐富的表示式引用where block中的變數

def "person is #person.age years old"() { 
  ...
  where:
  person << [new Person(age: 14, name: 'Phil Cole')]
  lastName = person.name.split(' ')[1]
}

基於互動的測試(Interaction Based Testing)

有的時候,我們測試的功能,需要依賴另外的collaborators來測試。這種涉及到多個執行單元之間的互動,叫做互動測試

比如:

class Publisher {
  List<Subscriber> subscribers = []
  int messageCount = 0
  void send(String message){
    subscribers*.receive(message)
    messageCount++
  }
}

interface Subscriber {
  void receive(String message)
}

我們想測Publisher,但Publisher有個功能是是發訊息給所有的Subscriber。要想測試Publisher的傳送功能確實ok,那麼需要測試Subscriber的確能收到訊息。

使用一個實際的Subscriber實現固然能實現這個測試。但對具體的Subscriber實現造成了依賴,這裡需要Mock。使用spock的測試用例如下:

class PublisherTest extends Specification{
    Publisher publisher = new Publisher()
    Subscriber subscriber = Mock()
    Subscriber subscriber2 = Mock() //建立依賴的Subscriber Mock

    def setup() {
        publisher.subscribers << subscriber // << is a Groovy shorthand for List.add()
        publisher.subscribers << subscriber2
    }


    def "should send messages to all subscribers"() {
        when:
        publisher.send("hello") //呼叫publisher的方法
        then:
        1*subscriber.receive("hello") //期望subscriber的receive方法能被呼叫一次
        1*subscriber2.receive("hello")//期望subscriber1的receive方法能被呼叫一次
    }
}

以上程式碼的目的是通過mock來測試當Publisher的send的方法被執行時,且執行引數是'hello'時,subscriber的receive方法一定能被呼叫,且入參也為‘hello’

對依賴Mock的呼叫期望,其結構如下

1 * subscriber.receive("hello")
|   |          |       |
|   |          |       argument constraint
|   |          method constraint
|   target constraint
cardinality

cardinality
定義右邊期望方法執行的次數,這裡是期望執行一次,可能的寫法有如下:

1 * subscriber.receive("hello")      // exactly one call
0 * subscriber.receive("hello")      // zero calls
(1..3) * subscriber.receive("hello") // between one and three calls (inclusive)
(1.._) * subscriber.receive("hello") // at least one call
(_..3) * subscriber.receive("hello") // at most three calls
_ * subscriber.receive("hello")      // any number of calls, including zero

target constraint
定義被依賴的物件。可能的寫法如下

1 * subscriber.receive("hello") // a call to 'subscriber'
1 * _.receive("hello")          // a call to any mock object

Method Constraint
定義在上述物件上期望被呼叫的方法,可能的寫法如下:

1 * subscriber.receive("hello") // a method named 'receive'
1 * subscriber./r.*e/("hello")  // a method whose name matches the given regular expression
                                // (here: method name starts with 'r' and ends in 'e')

Argument Constraints
對被呼叫方法,期望的入參進行定義。可能寫法如下:

1 * subscriber.receive("hello")        // an argument that is equal to the String "hello"
1 * subscriber.receive(!"hello")       // an argument that is unequal to the String "hello"
1 * subscriber.receive()               // the empty argument list (would never match in our example)
1 * subscriber.receive(_)              // any single argument (including null)
1 * subscriber.receive(*_)             // any argument list (including the empty argument list)
1 * subscriber.receive(!null)          // any non-null argument
1 * subscriber.receive(_ as String)    // any non-null argument that is-a String
1 * subscriber.receive(endsWith("lo")) // any non-null argument that is-a String
1 * subscriber.receive({ it.size() > 3 && it.contains('a') })
// an argument that satisfies the given predicate, meaning that
// code argument constraints need to return true of false
// depending on whether they match or not
// (here: message length is greater than 3 and contains the character a)

一些萬用字元

1 * subscriber._(*_)     // any method on subscriber, with any argument list
1 * subscriber._         // shortcut for and preferred over the above

1 * _._                  // any method call on any mock object
1 * _                    // shortcut for and preferred over the above

嚴格模式(Strict Mocking)

when:
publisher.publish("hello")

then:
1 * subscriber.receive("hello") // demand one 'receive' call on 'subscriber'
_ * auditing._                  // allow any interaction with 'auditing'
0 * _                           // don't allow any other interaction

預設情況下,你對Mock例項的方法的呼叫,會返回該方法返回值的預設值,比如該方法返回的是布林型,那麼你你呼叫mock例項中的該方法時,將返回布林型的預設值false.

如果我們希望嚴格的限定Mock例項的各方法行為,可以通過上述程式碼,對需要測試的方法顯示定義期望呼叫行為,對其它方法設定期望一次都不呼叫。以上then block中的0 * _ 即是定義這種期望。當除subscriber中的receive和auditing中的所有方法被呼叫時,該單元測試會失敗,因為這不符合我們對其它方法呼叫0次的期望

呼叫順序

then:
2 * subscriber.receive("hello")
1 * subscriber.receive("goodbye")

以上兩個期望被呼叫的順序是隨機的。如果要保證呼叫順序,使用兩個then

then:
2 * subscriber.receive("hello")

then:
1 * subscriber.receive("goodbye")

Stubbing 定義方法返回

前面的interaction mock是用來測試被mock的物件,期望方法的呼叫行為。比如入參,呼叫次數。

而stubbing則用來定義被mock的例項,在呼叫時返回的行為

總結,前者定義呼叫行為期望,後者定義返回行為期望。且Interaction test 測試的是執行期望或斷言。的stubbing則是用來定義mock的模擬的行為。

所以stubbing 對mock方法返回值的定義應該放在given block. 而對mock方法本身的呼叫Interaction test 應該放在then block中。所以stubbing對返回值的定義相當於在定義測試的測試資料。

Stubbing的使用場景也很明確。假設Publisher需要依賴Subscriber方法的返回值,再做下一步操作。那我們就需要對Subscriber的返回值進行mock,來測試不同返回值對目標測試程式碼(feature)的行為。

我們將上述Subscriber介面對應的方法新增一個返回值

class Publisher {
        Subscriber subscriber
        int messageCount = 0

        int send(String message){
            if(subscriber.receive(message) == 'ok') {
                this.messageCount++
            }
            return messageCount
        }
    }

interface Subscriber {
    String receive(String message)
}

測試程式碼舉例

Publisher publisher = new Publisher()
Subscriber subscriber = Mock()

def setup() {
    publisher.subscriber = subscriber
}

def "should send msg to subscriber"() {
     given:
     subscriber.receive("message1") >> "ok"

     when:
     def result = publisher.send("message1")

     then:
     result == 1
 }

以上程式碼表示,模擬subscriber.receive被呼叫時,且呼叫引數為message1,方法返回ok. 而此時期望(斷言)Publisher的send方法,返回的是1

stubbing 返回值結構

subscriber.receive(_) >> "ok"
|          |       |     |
|          |       |     response generator
|          |       argument constraint
|          method constraint
target constraint

注意這裡多了response generator,並且沒有interaction test中的Cardinality

各種返回值定義

返回固定值

subscriber.receive("message1") >> "ok"
subscriber.receive("message2") >> "fail"

順序呼叫返回不同的值

subscriber.receive(_) >>> ["ok", "error", "error", "ok"]

第一次呼叫返回ok,第二次、三次呼叫返回error。剩下的呼叫返回ok

根據入參計算返回值

subscriber.receive(_) >> { args -> args[0].size() > 3 ? "ok" : "fail" }
subscriber.receive(_) >> { String message -> message.size() > 3 ? "ok" : "fail" }

上述兩者效果都一樣,都是對第一個入參的長度進行判斷,然後確定返回值

返回異常

subscriber.receive(_) >> { throw new InternalError("ouch") }

鏈式返回值設定

subscriber.receive(_) >>> ["ok", "fail", "ok"] >> { throw new InternalError() } >> "ok"

前三次呼叫依次返回ok,fail,ok。第四次呼叫返回異常,之後的呼叫返回ok

將Interaction Mock和stubbing組合

1 * subscriber.receive("message1") >> "ok"
1 * subscriber.receive("message2") >> "fail"

這裡即定義了被mock 的subscriber其方法返回值,也定義了該方法期望被呼叫多少次。舉例:

Publisher publisher = new Publisher()
Subscriber subscriber = Mock()

def setup() {
    publisher.subscriber = subscriber
}

def "should send msg to subscriber"() {
    given:
    1*subscriber.receive("message1") >> "ok"

    when:
    def result = publisher.send("message1")

    then:
    result == 1
}

以上寫法,即測試了subscriber.receive被呼叫了一次,也測試了publisher.send執行結果為1.如果將Interaction Mock和stubbing組合拆開,像下面這種寫法是不行的:

Publisher publisher = new Publisher()
Subscriber subscriber = Mock()

def setup() {
    publisher.subscriber = subscriber
}

def "should send msg to subscriber"() {
        given:
        subscriber.receive("message1") >> "ok"

        when:
        def result = publisher.send("message1")

        then:
        result == 1
        1*subscriber.receive("message1")
    }

如何建立單元測試類

方式一

像Junit一樣,在需要測試的類上,使用Idea的幫助快捷鍵,然後彈出

選擇指定的測試框架spock和路徑即可

方式二

直接在指定的測試目錄下,新建對應的測試類,注意是新建groovy class
在Idea中,groovy class的圖示是方塊,java class是圓形,注意區分

有可能建完後,對應的圖示是

,說明Ide沒有識別到這是個groovy 類,一般是由於其程式碼有問題,可以開啟該檔案,把具體的錯誤修復,比如把註釋去掉之類的

參考資料

http://spockframework.org/spock/docs/1.1/all_in_one.html#_introduction

相關文章