Spock in Java 慢慢愛上寫單元測試

undefined

前言

最近小組裏面引進了Spock這個測試框架,本人在實際使用了以後,體驗很是不錯,本篇文章一是爲了鞏固輸入的知識,二是爲了向你們推廣一下。html

在瞭解學習Spock測試框架以前,咱們應該先關注單元測試自己,瞭解咱們常見的單測痛點,這樣才能更好地去了解Spock這個測試框架是什麼,咱們爲何要使用它,能解決咱們什麼痛點。java

如今讓咱們開始吧。git

關於單元測試

咱們寫代碼免不了要測試,測試有不少種,對於Javaer們來講,最初級的測試是寫個main函數運行一個函數結果,或者說把系統啓起來本身模擬一下請求,看輸入輸出是否符合預期,更高級地,會用各類測試套件,測試系統。每一個測試都有它的關注點,好比測試功能是否正確,系統性能瓶頸等等。github

那咱們常說的單元測試呢?數據庫

單元測試(英語:Unit Testing)又稱爲模塊測試,是針對程序模塊軟件設計的最小單位)來進行正確性檢驗的測試工做。程序單元是應用的最小可測試部件。在過程化編程中,一個單元就是單個程序、函數、過程等;對於面向對象編程,最小單元就是方法,包括基類(超類)、抽象類、或者派生類(子類)中的方法。express

-- 摘自維基百科編程

以上是維基百科的說明。緩存

單元測試固然不是必須之物,沒了單測你的程序通過QA團隊的端到端測試和集成測試以後,也能保證正確性。可是從另外的角度來看,單元測試也是必須之物。好比持續部署的前提之一就是有單元測試的保障,還有在重構代碼的時候,沒有單元測試你會步履維艱。bash

1.1 單元測試的好處

單元測試的好處包括但不限於:數據結構

  • 提高軟件質量

    優質的單元測試能夠保障開發質量和程序的魯棒性。越早發現的缺陷,其修復的成本越低。

  • 促進代碼優化

    單元測試的編寫者和維護者都是開發工程師,在這個過程中開發人員會不斷去審視本身的代碼,從而(潛意識)去優化本身的代碼。

  • 提高研發效率

    編寫單元測試,表面上是佔用了項目研發時間,可是在後續的聯調、集成、迴歸測試階段,單測覆蓋率高的代碼缺陷少、問題已修復,有助於提高總體的研發效率。

  • 增長重構自信

    代碼的重構通常會涉及較爲底層的改動,好比修改底層的數據結構等,上層服務常常會受到影響;在有單元測試的保障下,咱們對重構出來的代碼會多一份底氣。

1.2 單元測試的基本原則

宏觀上,單元測試要符合 AIR 原則:

  • A: Automatic(自動化)
  • I: Independent(獨立性)
  • R: Repeatable(可重複)

微觀上,單元測試代碼層面要符合 BCDE 原則:

  • B: Border,邊界性測試,包括循環邊界、特殊取值、特殊時間點、數據順序等
  • C: Correct,正確的輸入,而且獲得預期的結果**
  • D: Design,與設計文檔相符合,來編寫單元測試
  • **E: Error,單元測試的目的是爲了證實程序有錯,而不是證實程序無錯。**爲了發現代碼中潛藏的錯誤,咱們須要在編寫測試用例時有一些強制的錯誤輸入(如非法數據、異常流程、非業務容許輸入等)來獲得預期的錯誤結果。

1.3 單元測試的常見場景

  1. 開發前寫單元測試,經過測試描述需求,由測試驅動開發。(若是不熟悉TDD的同窗能夠去google一下)
  2. 在開發過程當中及時獲得反饋,提早發現問題。
  3. 應用於自動化構建或持續集成流程,對每次代碼修改作迴歸測試。(CI/CD 質量保障)
  4. 做爲重構的基礎,驗證重構是否可靠。

1.4 單元測試的常見痛點

下列痛點是平常開發中可能會遇到的,

  1. 測試上下文依賴外部服務(如數據庫服務)
  2. 測試上下文存在代碼依賴(如框架等)
  3. 單元測試難以維護和理解(語義不清)
  4. 對於多場景不一樣輸入輸出的函數,單元測試代碼量會不少
  5. ...

對上面幾點稍微作下解釋。

首先,測試代碼的代碼量絕對不會比業務代碼少(假設有覆蓋率指標,且不做弊),有時候一個函數,輸入和輸出會有多種狀況,想要徹底覆蓋,代碼量只會更多。較多的代碼量,加上單測代碼並不想業務代碼那樣直觀(靠寫註釋的方式,看的亂,寫的累),還有一部分編碼人員對代碼可讀性不重視,最終就會致使單元測試的代碼難以閱讀,更難以維護。同時,大部分單元測試的框架都對代碼有很強的侵入性,要想理解單元測試,首先得學習一下那個單元測試框架。從這個角度來看,維護的難度又增長了。

再說說,單元測試存在外部依賴的狀況,也就是第1、二點,想要寫一個純粹的無依賴的單元測試每每很困難,好比依賴了數據庫,依賴了其餘模塊,因此不少人在寫單元測試時選擇依賴一部分資源,好比在本機啓動一個數據庫。這類所謂的「單元測試」每每很流行,可是對於多人合做的項目,這類測試卻常常容易形成混亂。 好比說要在本地讀個文件,或者鏈接某個數據庫,其餘修改代碼的人(或者持續集成系統中)並無這些東西,因此測試也都無法經過。最後大部分這類測試代碼的下場都是用不了、也捨不得刪,只好被註釋掉,扔在那裏。隨着開源項目逐漸發展,對外部資源的依賴問題開始能夠經過一些測試輔助工具解決,好比使用內存型數據庫H2代替鏈接實際的測試數據庫,不過能替代的資源類型始終有限。

而實際工做過程當中,還有一類難以處理的依賴問題:代碼依賴。好比一個對象的方法中調用了其它對象的方法,其它對象又調用了更多對象,最後造成了一個無比巨大的調用樹。後來出現了一些mock框架,好比java的JMockit、EasyMock,或者Mockito。利用這類框架能夠相對比較輕鬆的經過mock方式去作假設和驗證,相對於以前的方式有了質的飛躍。

可是,在這裏須要強調一個觀點,寫單元測試的難易程度跟代碼的質量關係最大,而且是決定性的。項目裏不管用了哪一個測試框架都不能解決代碼自己難以測試的問題。

簡單來講,有時候你以爲你的代碼很難寫單元測試,說明代碼寫的不是很好,須要去關注代碼的邏輯抽象設計是否合理,一步步去重構你的代碼,讓你的代碼變得容易測試。但這些又屬於代碼重構方面的知識了,涉及到不少的設計原則。推薦閱讀《重構-改善既有代碼的設計》《修改代碼的藝術》 《敏捷軟件開發:原則、模式與實踐》這幾本著做。

1.5 心態的轉變

不少開發人員對待單元測試,存在心態上的障礙,

  • 那是測試同窗乾的事情。(開發人員要作好單元測試

  • 單元測試代碼是多餘的。 (汽車的總體功能與各單元部件的測試正常與否是強相關

  • 單元測試代碼不須要維護。 一年半載後,那麼幾乎處於廢棄狀態(單元測試代碼是須要隨着項目開發一直維護的

  • 單元測試與線上故障沒有辯證關係。(好的單元測試能最大限度規避線上故障

關於Spock

Spock能給你提供整個測試生命週期中可能須要的全部測試工具。它帶有內置的模擬打樁,以及專門爲集成測試建立的一些額外的測試註釋。同時,因爲Spock是較新的測試框架,所以它有時間觀察現有框架的常見缺陷,並加以解決或提供更優雅的解決方法。

image.png

  • Spock是Java和Groovy應用程序的測試和規範框架
  • 測試代碼使用基於groovy語言擴展而成的規範說明語言(specification language)
  • 經過junit runner調用測試,兼容絕大部分junit的運行場景(ide,構建工具,持續集成等)

Groovy

  • 以「擴展JAVA」爲目的而設計的JVM語言
  • JAVA開發者友好
  • 可使用java語法與API
  • 語法精簡,表達性強
  • 典型應用:jenkins, elasticsearch, gradle

specification language

specification 來源於近期流行起來寫的BDD(Behavior-driven development 行爲驅動測試)。在TDD的基礎上,經過測試來表達代碼的行爲。經過某種規範說明語言去描述程序「應該」作什麼,再經過一個測試框架讀取這些描述、並驗證應用程序是否符合預期。把需求轉化成Given/When/Then的三段式,因此你看到測試框架有這種Given/When/Then三段式語法的,通常來講背後都是BDD思想,好比上圖中的Cucumber和JBehave。

Spock快速使用

如今讓咱們以最快速的方式,來使用一次Spock

3.0 建立一個空白項目

建立一個空白項目:spock-example,選擇maven工程。

3.1 依賴

<dependencies>
    <!-- Mandatory dependencies for using Spock -->
    <!-- 使用Spock必須的依賴 -->
    <dependency>
      <groupId>org.spockframework</groupId>
      <artifactId>spock-core</artifactId>
      <version>1.3-groovy-2.5</version>
      <scope>test</scope>
    </dependency>
    <!-- Optional dependencies for using Spock -->
    <!-- 選擇性使用的Spock相關依賴 -->
    <dependency> <!-- use a specific Groovy version rather than the one specified by spock-core -->
    <!-- 不使用Spock-core中定義的Groovy版本,而是本身定義 -->
      <groupId>org.codehaus.groovy</groupId>
      <artifactId>groovy-all</artifactId>
      <version>2.5.7</version>
      <type>pom</type>
    </dependency>
    <dependency> <!-- enables mocking of classes (in addition to interfaces) -->
      <!-- mock 接口和類時要用 -->
      <groupId>net.bytebuddy</groupId>
      <artifactId>byte-buddy</artifactId>
      <version>1.9.3</version>
      <scope>test</scope>
    </dependency>
    <dependency> <!-- enables mocking of classes without default constructor (together with CGLIB) -->
      <!-- mock 類要用 -->
      <groupId>org.objenesis</groupId>
      <artifactId>objenesis</artifactId>
      <version>2.6</version>
      <scope>test</scope>
    </dependency>
    <dependency> <!-- only required if Hamcrest matchers are used -->
      <!-- Hamcrest 是一個用於編寫匹配對象的框架,若是用到了Hamcrest matchers,須要加這個依賴 -->
      <groupId>org.hamcrest</groupId>
      <artifactId>hamcrest-core</artifactId>
      <version>1.3</version>
      <scope>test</scope>
    </dependency>
    <!-- Dependencies used by examples in this project (not required for using Spock) -->
    <!-- 使用h2base作測試數據庫-->
    <dependency>
      <groupId>com.h2database</groupId>
      <artifactId>h2</artifactId>
      <version>1.4.197</version>
      <scope>test</scope>
    </dependency>
  </dependencies>
複製代碼

3.2 插件

<plugins>
      <!-- Mandatory plugins for using Spock -->
      <!--使用Spock的強制性插件 -->
      <plugin>
        <!-- The gmavenplus plugin is used to compile Groovy code. To learn more about this plugin,visit https://github.com/groovy/GMavenPlus/wiki -->
        <!-- 這個 gmavenplus 插件是用於編譯Groovy代碼的 . 想獲取更多此插件相關信息,visit https://github.com/groovy/GMavenPlus/wiki -->
        <groupId>org.codehaus.gmavenplus</groupId>
        <artifactId>gmavenplus-plugin</artifactId>
        <version>1.6</version>
        <executions>
          <execution>
            <goals>
              <goal>compile</goal>
              <goal>compileTests</goal>
            </goals>
          </execution>
        </executions>
      </plugin>
      <!-- Optional plugins for using Spock -->
      <!-- 選擇性使用的Spock相關插件-->
      <!-- Only required if names of spec classes don't match default Surefire patterns (`*Test` etc.) -->
      <!--只有當測試類不匹配默認的 Surefire patterns (`*Test` 等等.)-->
      <plugin>
        <artifactId>maven-surefire-plugin</artifactId>
        <version>2.20.1</version>
        <configuration>
          <useFile>false</useFile>
          <includes>
            <include>**/*Test.java</include>
            <include>**/*Spec.java</include>
          </includes>
        </configuration>
      </plugin>
	  ...
    </plugins>
複製代碼

3.3 設計測試源碼目錄

因爲spock是基於groovy語言的,因此須要建立groovy的測試源碼目錄:首先在test目錄下建立名爲groovy的目錄,以後將它設爲測試源碼目錄。

image.png

3.4 編寫待測試類

/** * @author Richard_yyf * @version 1.0 2019/10/1 */
public class Calculator {

    public int size(String str){
        return str.length();
    }
    
    public int sum(int a, int b) {
        return a + b;
    }

}
複製代碼

3.5 建立測試類

Ctrl + Shift + T

image.png

import spock.lang.Specification
import spock.lang.Subject
import spock.lang.Title
import spock.lang.Unroll

/** * * @author Richard_yyf * @version 1.0 2019/10/1 */
@Title("測試計算器類")
@Subject(Calculator)
class CalculatorSpec extends Specification {

    def calculator = new Calculator()

    void setup() {
    }

    void cleanup() {
    }

    def "should return the real size of the input string"() {
 expect:
        str.size() == length
 where:
        str     | length
        "Spock"  | 5
        "Kirk"   | 4
        "Scotty" | 6
    }

    // 測試不經過
    def "should return a+b value"() {
 expect:
        calculator.sum(1,1) == 1
    }

    // 不建議用中文哦
    @Unroll
    def "返回值爲輸入值之和"() {
 expect:
        c == calculator.sum(a, b)
 where:
        a | b | c
        1 | 2 | 3
        2 | 3 | 5
        10 | 2 | 12
    }
}

複製代碼

3.6 運行測試

image.png

3.7 模擬依賴

這裏模擬一個緩存服務做爲例子

/** * @author Richard_yyf * @version 1.0 2019/10/2 */
public interface CacheService {

    String getUserName();
}
複製代碼
public class Calculator {

    private CacheService cacheService;

    public Calculator(CacheService cacheService) {
        this.cacheService = cacheService;
    }

    public boolean isLoggedInUser(String userName) {
        return Objects.equals(userName, cacheService.getUserName());
    }
    ...
}
複製代碼

測試類

class CalculatorSpec extends Specification {
    
    // mock對象
// CacheService cacheService = Mock()
    def cacheService = Mock(CacheService)

    def calculator void setup() {
       calculator = new Calculator(cacheService)
    }


    def  "is username equal to logged in username"() {
        // stub 打樁
        cacheService.getUserName(*_) >> "Richard"

        when:
        def result = calculator.isLoggedInUser("Richard")

        then:
        result
    }

    ...
}
複製代碼

運行測試

image.png

Spock 深刻

在Spock中,待測系統(system under test; SUT) 的行爲是由規格(specification) 所定義的。在使用Spock框架編寫測試時,測試類須要繼承自Specification類。命名遵循Java規範。

Spock 基礎結構

每一個測試方法能夠直接用文本做爲方法名,方法內部由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: 清理每一個測試類的環境
  }

複製代碼

Feature methods

是Spock規格(Specification)的核心,其描述了SUT應具有的各項行爲。每一個Specification都會包含一組相關的Feature methods:

def "should return a+b value"() {
 expect:
        calculator.sum(1,1) == 1
    }
複製代碼

blocks

每一個feature method又被劃分爲不一樣的block,不一樣的block處於測試執行的不一樣階段,在測試運行時,各個block按照不一樣的順序和規則被執行,以下圖:

undefined

  • Setup Blocks

    setup也能夠寫成given,在這個block中會放置與這個測試函數相關的初始化程序,如:

    def  "is username equal to logged in username"() {
            setup:
            def str = "Richard"
            // stub 打樁
            cacheService.getUserName(*_) >> str
    
            when:
            def result = calculator.isLoggedInUser("Richard")
    
            then:
            result
        }
    複製代碼
  • When and Then Blocks

    whenthen須要搭配使用,在when中執行待測試的函數,在then中判斷是否符合預期

  • Expect Blocks

    expect能夠看作精簡版的when+then,如

    when:
    def x = Math.max(1, 2)  
    then:
    x == 2
    複製代碼

    簡化成

    expect:
    Math.max(1, 2) == 2   
    複製代碼

斷言

條件相似junit中的assert,就像上面的例子,在then或expect中會默認assert全部返回值是boolean型的頂級語句若是要在其它地方增長斷言,須要顯式增長assert關鍵字

異常斷言

若是要驗證有沒有拋出異常,能夠用thrown()

def "peek"() {
 when: stack.peek()
 then: thrown(EmptyStackException)
  }
複製代碼

若是要驗證沒有拋出某種異常,能夠用notThrown()

Mock

Mock 是描述規範下的對象與其協做者之間(強制)交互的行爲。

1 * subscriber.receive("hello")
|   |          |       |
|   |          |       argument constraint
|   |          method constraint
|   target constraint
cardinality
複製代碼

建立 Mock 對象

def subscriber = Mock(Subscriber)
def subscriber2 = Mock(Subscriber)
    
Subscriber subscriber = Mock()
Subscriber subscriber2 = Mock()    
複製代碼

注入 Mock 對象

class PublisherSpec extends Specification {
  Publisher publisher = new Publisher()
  Subscriber subscriber = Mock()
  Subscriber subscriber2 = Mock()

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

調用頻率約束(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
                                     // (rarely needed; see 'Strict Mocking')
複製代碼

目標約束(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 constraint)

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)
複製代碼

Stub 打樁

Stubbing 是讓協做者以某種方式響應方法調用的行爲。在對方法進行存根化時,不關心該方法的調用次數,只是但願它在被調用時返回一些值,或者執行一些反作用。

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

如:subscriber.receive(_) >> "ok" 意味,無論什麼實例,什麼參數,調用 receive 方法皆返回字符串 ok

返回固定值

使用 >> 操做符,返回固定值

subscriber.receive(_) >> "ok"
複製代碼

返回值序列

返回一個序列,迭代且依次返回指定值。以下所示,第一次調用返回 ok,第二次調用返回 error,以此類推

subscriber.receive(_) >>> ["ok", "error", "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"
複製代碼

結語

本文介紹了單元測試的基礎知識,和Spock的一些用法。使用Spock,能夠享受到groovy腳本語言的方便、一站式的測試套件,寫出來的測試代碼也更加優雅、可讀。

可是這只是第一步,學會了如何使用一個測試框架,只是初步學會了「術」而已,要如何利用好Spock,須要不少軟性方面的改變,好比如何寫好一個測試用例,如何漸進式地去重構代碼和寫出更易測試的代碼,如何讓團隊實行TDD等等。

但願能在之後分享更多相關的知識。

參考

  1. spock官網

  2. groovy官網

  3. 測試金字塔

  4. blog.2baxb.me/archives/13…

  5. Testing Your Code With Spock

  6. Spock Example in github

  7. 《碼出高效》

相關文章
相關標籤/搜索