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

前言

最近小組裡面引進了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是較新的測試框架,因此它有時間觀察現有框架的常見缺陷,並加以解決或提供更優雅的解決方法。

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

  • 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 依賴

<code>  <dependencies>


<dependency>
<groupid>org.spockframework/<groupid>
<artifactid>spock-core/<artifactid>
<version>1.3-groovy-2.5/<version>
<scope>test/<scope>
/<dependency>


<dependency>

<groupid>org.codehaus.groovy/<groupid>
<artifactid>groovy-all/<artifactid>
<version>2.5.7/<version>
<type>pom/<type>
/<dependency>
<dependency>

<groupid>net.bytebuddy/<groupid>
<artifactid>byte-buddy/<artifactid>
<version>1.9.3/<version>

<scope>test/<scope>
/<dependency>
<dependency>

<groupid>org.objenesis/<groupid>
<artifactid>objenesis/<artifactid>
<version>2.6/<version>
<scope>test/<scope>
/<dependency>
<dependency>

<groupid>org.hamcrest/<groupid>
<artifactid>hamcrest-core/<artifactid>
<version>1.3/<version>
<scope>test/<scope>
/<dependency>


<dependency>
<groupid>com.h2database/<groupid>
<artifactid>h2/<artifactid>
<version>1.4.197/<version>
<scope>test/<scope>
/<dependency>
/<dependencies>/<code>

3.2 插件

<code>  <plugins>


<plugin>


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




<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>/<code>

3.3 設計測試源碼目錄

由於spock是基於groovy語言的,所以需要創建groovy的測試源碼目錄:首先在test目錄下創建名為groovy的目錄,之後將它設為測試源碼目錄。

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

3.4 編寫待測試類

<code>/**
* @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;
}

}/<code>

3.5 創建測試類

Ctrl + Shift + T

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

<code>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
}
}
/<code>

3.6 運行測試

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

3.7 模擬依賴

這裡模擬一個緩存服務作為例子

<code>/**
* @author Richard_yyf
* @version 1.0 2019/10/2

*/
public interface CacheService {

String getUserName();
}/<code>
<code>public class Calculator {

private CacheService cacheService;

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

public boolean isLoggedInUser(String userName) {
return Objects.equals(userName, cacheService.getUserName());
}
...
}/<code>

測試類

<code>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
}

...
}/<code>

運行測試

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

Spock 深入

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

Spock 基礎結構

每個測試方法可以直接用文本作為方法名,方法內部由given-when-then的三段式塊(block)組成。除此以外,還有and、where、expect等幾種不同的塊。

<code> 

@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: 清理每個測試類的環境
}
/<code>

Feature methods

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

<code>    def "should return a+b value"() {
expect:
calculator.sum(1,1) == 1
}/<code>

blocks

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

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

  • Setup Blockssetup也可以寫成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 Blockswhen與then需要搭配使用,在when中執行待測試的函數,在then中判斷是否符合預期
  • Expect Blocksexpect可以看做精簡版的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()

<code>  def "peek"() {
when: stack.peek()
then: thrown(EmptyStackException)
}/<code>

如果要驗證沒有拋出某種異常,可以用notThrown()

Mock

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

<code>1 * subscriber.receive("hello")
| | | |
| | | argument constraint
| | method constraint
| target constraint
cardinality/<code>

創建 Mock 對象

<code>def subscriber = Mock(Subscriber)
def subscriber2 = Mock(Subscriber)

Subscriber subscriber = Mock()
Subscriber subscriber2 = Mock() /<code>

注入 Mock 對象

<code>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
}/<code>

調用頻率約束(cardinality)

<code>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')/<code>

目標約束(target constraint)

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

方法約束(method constraint)

<code>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')/<code>

參數約束(argument constraint)

<code>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)/<code>

Stub 打樁

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

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

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

返回固定值

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

<code>subscriber.receive(_) >> "ok"/<code>

返回值序列

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

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

動態計算返回值

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

產生副作用

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

鏈式響應

<code>subscriber.receive(_) >>> ["ok", "fail", "ok"] >> { thrownew InternalError() } >> "ok"/<code>

結語

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

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

希望能在以後分享更多相關的知識。

鏈接:https://segmentfault.com/a/1190000021639286

喜歡對你有幫助的話記得加個關注不迷路哦

還有關注我私信回覆【資料】可以領取到一些個人收集的面試及電子書資料,或許對你有幫助!

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


分享到:


相關文章: