1. 程式人生 > 程式設計 >Spock 單元測試實踐

Spock 單元測試實踐

單測存在的痛點

  1. 執行一個 case 耗時過長(測試上下文中有太多依賴|設計中的耦合性太高)
  2. 一個方法中有太多 test case(資料物件情況複雜,被測試的方法做了太多事情)
  3. 太多的 setup/teardown(表示被測試類的耦合性太高)
  4. 資料在資料庫中,有操作許可權的成員太多極易被修改,極易造成混亂(H2 資料庫)
  5. 改變一個地方,多處測試受影響,也許是測試的設計問題,也許是實現程式碼中有過多依賴

Spock 測試框架簡介

Spock

Spock 是一個測試框架,它的核心特性有以下幾個:

  • 可以應用於 java 或 groovy 應用的單元測試框架。
  • 測試程式碼使用基於 groovy 語言擴充套件而成的規範說明語言(specification language)。
  • 通過 junit runner 呼叫測試,相容絕大部分junit的執行場景(ide,構建工具,持續整合等)。
  • 框架的設計思路參考了 JUnit,jMock,RSpec,Groovy,Scala,Vulcans……

Spock in 5 minutes

所需依賴

<dependency>
    <groupId>junit</groupId>
    <artifactId>junit</artifactId>
    <version>4.12</version>
    <scope>test</scope
>
</dependency> <!-- Mandatory dependencies for using Spock --> <dependency> <groupId>org.spockframework</groupId> <artifactId>spock-core</artifactId> <version>1.1-groovy-2.4</version> <scope>test</scope> </dependency
>
<dependency> <groupId>org.spockframework</groupId> <artifactId>spock-spring</artifactId> <version>1.1-groovy-2.4</version> <scope>test</scope> </dependency> <!-- enables mocking of classes (in addition to interfaces) --> <dependency> <groupId>net.bytebuddy</groupId> <artifactId>byte-buddy</artifactId> <version>1.9.3</version> <scope>test</scope> </dependency> <!-- enables mocking of classes without default constructor --> <dependency> <groupId>org.objenesis</groupId> <artifactId>objenesis</artifactId> <version>2.6</version> <scope>test</scope> </dependency> 複製程式碼

基本結構

Spock 的測試類均派生自 Specification 類,命名遵循 Java 規範。每個測試方法可以直接用文字作為方法名,方法內部由 given-when-then 的三段式塊(block)組成。除此以外,還有 and、where、expect 等幾種不同的塊。

@Title("測試的標題")
@Narrative("""關於測試的大段文字描述""")
// 標明被測試的類是Adder
@Subject(TestedClassName)  
// 當測試方法間存在依賴關係時
// 標明測試方法將嚴格按照其在原始碼中宣告的順序執行
@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。

引數化測試

Spock 使用 where 塊,為測試方法提供表格化的測試資料。其中表頭為測試方法中要用在斷言中的變數名稱或者表示式,用|分隔輸入引數,用||分隔輸入與輸出。這些引數,可以用 #引數名 的方式在 @Unroll 描述或者測試方法名裡定義,或者在測試方法的引數列表裡定義,然後在 where 塊中使用。

// where 塊中的每行引數都轉換為一個獨立的測試用例
@Unroll("test #para0,#para1") 
def "測試方法3"(int first,int second) {
  // ... ...
  where: "parameterized sample"
  para0 | para1 | para2 || para3 | first | second
    10  |   2   |   3   ||   7   |   2   |   5
}
複製程式碼

簡單例項

被測試類(src/main/java/SumUtils.java)

public class SumUtils {
    public static int sum(int a,int b) {
        return a + b;
    }
}
複製程式碼

測試類(src/test/groovy/SumUtilsTest.groovy

@Title("測試加法工具類")
@Subject(SumUtils)
class SumUtilsTest extends Specification {
    @Unroll
    def "test Sum"() {
        expect:
        res == SumUtils.sum(a,b)

        where:
        a | b | res
        1 | 1 | 2
        0 | 0 | 0
        -1 | -1 | -2
        0  | -1 | 0
    }
}
複製程式碼

常用 Spock 語法

Mocking

Mocking 是描述規範下的物件與其協作者之間(強制)互動的行為。

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

Stubing

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"
複製程式碼

完整單測例項

@Title("招行業務類測試")
@Subject(CmbPayLocalServiceImpl)
class CmbPayLocalServiceImplTest extends Specification {

    CmbPayLocalServiceImpl cmbPayLocalService = new CmbPayLocalServiceImpl()
    ConfigBaseService configBaseService = Mock()
    WeChatPayHelper helper = Mock()
    CodeUrlMapLocalService codeUrlMapLocalService = Mock()

    void setup() {
        cmbPayLocalService.configBaseService = configBaseService
        cmbPayLocalService.weChatPayHelper = helper
        cmbPayLocalService.codeUrlMapLocalService = codeUrlMapLocalService
    }

    def "GetCodeUrl"() {
        given: "定義一些變數"
        def enterpriseNum = "AC310008"
        def mchId = "308999170120019"
        def fgTradeNo = System.currentTimeMillis().toString()
        def start = "20190813130000"
        def end = "20190815130000"

        def param = new WeChatQrCodePayParam(
                enterpriseNum: enterpriseNum,mchId: mchId,outTradeNo: System.currentTimeMillis().toString(),body: "Groovy 測試",totalFee: "1",notifyUrl: ""
        )

        def record = new TransRecord<>(
                enterpriseNum: enterpriseNum,fgTradeNo: fgTradeNo,state: "1"
        )

        configBaseService.getConfig(_) >> ""
        codeUrlMapLocalService.saveCodeUrlMapping(*_) >> true

        when:
        Map<String,Object> result = cmbPayLocalService.getCodeUrl(param,record)

        then:
        def url = result.get("url")
    }

    @Unroll
    def "GetCodeUrlWithWheretime"() {
        when:
        def enterpriseNum = "AC310008"
        def mchId = "308999170120019"
        def fgTradeNo = System.currentTimeMillis().toString()

        def param = new WeChatQrCodePayParam(
                enterpriseNum: enterpriseNum,state: "1"
        )

        configBaseService.getConfig(_) >> "308999170120019"
        codeUrlMapLocalService.saveCodeUrlMapping(*_) >> true

        then:
        StringUtils.isNotBlank((String)cmbPayLocalService.getCodeUrl(param,record).get("CodeUrl"))

        where:
        start | end | url
        "20190813130000"  | "20190815130000"  | true
        "20190813130000"  | "20191015130000"  | true
        "20190813130000"  | "20191015130000"  | true
        "20190813130000"  | "20191015130000"  | true
    }
}
複製程式碼

元件:Groovy

Groovy 是 Java 平臺上設計的面向物件程式語言。這門動態語言擁有類似 Python、Ruby 和 Smalltalk 中的一些特性,可以作為Java平臺的指令碼語言使用。

Groovy 的語法與 Java 非常相似,以至於多數的 Java 程式碼也是正確的 Groovy 程式碼。Groovy 程式碼動態的被編譯器轉換成 Java 位元組碼。由於其執行在 JVM 上的特性,Groovy 可以使用其他 Java 語言編寫的庫。

  • 語句結束用的分號; 是可選的
  • 通過 def 關鍵字定義變數和方法
  • 通常用一對雙引號表示一個字串常量,"這是一個字串常量",使用 \ 作為轉義符
  • Groovy 將 0、null、空的陣列或空字串視為 false,非 0 值、有效的引用、非空的陣列和非空的字串則均視為 true
  • 使用關鍵字 def 表示動態型別,類似 JavaScript 中的 var
  • 類的訪問修飾預設都是 public,而 field 預設是 private(但是可以用類例項來直接訪問)
  • 使用 作為字串插入符,必要時使用一對大括號包圍插入值,比如 `return "name,${age > 18}"`
  • 物件初始化方式類似 JSON,整個表示式用圓括號包圍,field 與值以冒號間隔成對出現,陣列或序列用中括號包圍,陣列索引從 0 開始,用[:]表示一個空的 Map
  • 使用 _作為引數佔位符。它既可以用來指代引數、方法,也可以指代返回值或者 where 塊中的測試引數
// 定義字串
String str = ""
// 定義列表
List list = [1,2,3,4,5]
// 定義 map
Map map = [key1:val1,key2:val2]
複製程式碼

融合付規範

必須遵守 AIR 原則

說明:單元測試線上上執行時,感覺像空氣(AIR)一樣並不存在,但在測試質量的保障上,卻是非常關鍵的。好的單元測試巨集觀上來說,具有自動化、獨立性、可重複執行的特點。

- A:Automatic(自動化)

單元測試應該是全自動執行的,並且非互動式的。測試用例通常是被定期執行的,執行過程必須完全自動化才有意義。輸出結果需要人工檢查的測試不是一個好的單元測試。單元測試中不準使用 System.out 來進行人肉驗證,必須使用 assert 來驗證。

- I:Independent(獨立性)

保持單元測試的獨立性。為了保證單元測試穩定可靠且便於維護,單元測試用例之間決不能互相呼叫,也不能依賴執行的先後次序。 反例:method2需要依賴method1的執行,將執行結果作為method2的輸入。

- R:Repeatable(可重複)

單元測試是可以重複執行的,不能受到外界環境的影響。 說明:單元測試通常會被放到持續整合中,每次有程式碼check in時單元測試都會被執行。如果單測對外部環境(網路、服務、中介軟體等)有依賴,容易導致持續整合機制的不可用。 正例:為了不受外界環境影響,要求設計程式碼時就把SUT的依賴改成注入,在測試時用spring 這樣的DI框架注入一個本地(記憶體)實現或者Mock實現。

保證測試粒度足夠小

對於單元測試,要保證測試粒度足夠小,有助於精確定位問題。單測粒度至多是類級別,一般是方法級別。

說明:只有測試粒度小才能在出錯時儘快定位到出錯位置。單測不負責檢查跨類或者跨系統的互動邏輯,那是整合測試的領域。

也有按照場景寫單元測試用例的,一個方法對應多個場景。

參照:blog.csdn.net/flysqrlboy/…

核心業務、核心應用、核心模組的增量程式碼確保單測通過

說明:新增程式碼及時補充單元測試,如果新增程式碼影響了原有單元測試,請及時修正。

單測的基本目標

語句覆蓋率達到 70%;核心模組的語句覆蓋率和分支覆蓋率都要達到 100%;

說明:在工程規約的應用分層中提到的DAO層,Manager層,可重用度高的Service,都應該進行單元測試。

單測程式碼遵守 BCDE 原則

以保證被測試模組的交付質量

- B:Border,邊界值測試,包括迴圈、 特殊取,邊界值測試包括迴圈、 特殊取特殊時間點、資料順序等。

- C:Correct,正確的輸入,並得到預期結果。 ,正確的輸入並得到預期結果。

- D:Design,與設計檔案相結合,來編寫單元測試。 ,與設計檔案相結合來編寫單元測試。

- E:Error,強制錯誤資訊輸入(如:非法資料、異常流程業務允許等),並得 ,強制錯誤資訊輸入(如:非法資料、異常流程業務允許等),並得到預期結果。

與資料庫互動規範

對於資料庫相關的查詢,更新,刪除等操作,不能假設資料庫裡的資料是存在的,或者直接操作資料庫把資料插入進去,請使用程式插入或者匯入資料的方式來準備資料。

反例:刪除某一行資料的單元測試,在資料庫中,先直接手動增加一行作為刪除目標,但是這一行新增資料並不符合業務插入規則,導致測試結果異常。

可以使用 h2 記憶體資料庫保證單測不汙染測試資料庫

不可測的程式碼建議做必要的重構

對於不可測的程式碼建議做必要的重構,使程式碼變得可測,避免為了達到測試要求而書寫不規範測試程式碼。

確定單測範圍

開發人員需要和測試人員一起確定單元測試範圍,單元測試最好覆蓋所有測試用例(UC)。

心態轉變

- 那是測試同學乾的事情。

- 單元測試程式碼是多餘的。 汽車的整體功能與各單元部件的測試正常否是強相關。

- 單元測試程式碼不需要維護。 一年半載後,那麼幾乎處於廢棄狀態。

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

遇到的問題

如何測試私有方法?

www.infoq.cn/article/200…

程式碼中用了 new Object() 操作

此種方法暫時無法 Mock,需要將物件包裝一層,用於 Mock

參考資料