1. 程式人生 > 程式設計 >Spock in Java 慢慢愛上寫單元測試

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

undefined

前言

最近小組裡面引進了Spock這個測試框架,本人在實際使用了之後,體驗非常不錯,本篇文章一是為了鞏固輸入的知識,二是為了向大家推廣一下。

在瞭解學習Spock測試框架之前,我們應該先關注單元測試本身,瞭解我們常見的單測痛點,這樣才能更好地去了解Spock這個測試框架是什麼,我們為什麼要使用它,能解決我們什麼痛點。

現在讓我們開始吧。

關於單元測試

我們寫程式碼免不了要測試,測試有很多種,對於Javaer們來說,最初級的測試是寫個main函式執行一個函式結果,或者說把系統啟起來自己模擬一下請求,看輸入輸出是否符合預期,更高階地,會用各種測試套件,測試系統。每個測試都有它的關注點,比如測試功能是否正確,系統效能瓶頸等等。

那我們常說的單元測試呢?

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

-- 摘自維基百科

以上是維基百科的說明。

單元測試當然不是必須之物,沒了單測你的程式經過QA團隊的端到端測試和整合測試之後,也能保證正確性。但是從另外的角度來看,單元測試也是必須之物。比如持續部署的前提之一就是有單元測試的保障,還有在重構程式碼的時候,沒有單元測試你會寸步難行。

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. ...

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

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

再說說,單元測試存在外部依賴的情況,也就是第一、二點,想要寫一個純粹的無依賴的單元測試往往很困難,比如依賴了資料庫,依賴了其他模組,所以很多人在寫單元測試時選擇依賴一部分資源,比如在本機啟動一個資料庫。這類所謂的“單元測試”往往很流行,但是對於多人合作的專案,這類測試卻經常容易造成混亂。 比如說要在本地讀個檔案,或者連線某個資料庫,其他修改程式碼的人(或者持續整合系統中)並沒有這些東西,所以測試也都沒法通過。最後大部分這類測試程式碼的下場都是用不了、也捨不得刪,只好被註釋掉,扔在那裡。隨著開源專案逐漸發展,對外部資源的依賴問題開始可以通過一些測試輔助工具解決,比如使用記憶體型資料庫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","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. 《碼出高效》