Spock 單元測試實踐
單測存在的痛點
- 執行一個 case 耗時過長(測試上下文中有太多依賴|設計中的耦合性太高)
- 一個方法中有太多 test case(資料物件情況複雜,被測試的方法做了太多事情)
- 太多的 setup/teardown(表示被測試類的耦合性太高)
- 資料在資料庫中,有操作許可權的成員太多極易被修改,極易造成混亂(H2 資料庫)
- 改變一個地方,多處測試受影響,也許是測試的設計問題,也許是實現程式碼中有過多依賴
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(但是可以用類例項來直接訪問)
- 使用 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實現。
保證測試粒度足夠小
對於單元測試,要保證測試粒度足夠小,有助於精確定位問題。單測粒度至多是類級別,一般是方法級別。
說明:只有測試粒度小才能在出錯時儘快定位到出錯位置。單測不負責檢查跨類或者跨系統的互動邏輯,那是整合測試的領域。
也有按照場景寫單元測試用例的,一個方法對應多個場景。
核心業務、核心應用、核心模組的增量程式碼確保單測通過
說明:新增程式碼及時補充單元測試,如果新增程式碼影響了原有單元測試,請及時修正。
單測的基本目標
語句覆蓋率達到 70%;核心模組的語句覆蓋率和分支覆蓋率都要達到 100%;
說明:在工程規約的應用分層中提到的DAO層,Manager層,可重用度高的Service,都應該進行單元測試。
單測程式碼遵守 BCDE 原則
以保證被測試模組的交付質量
- B:Border,邊界值測試,包括迴圈、 特殊取,邊界值測試包括迴圈、 特殊取特殊時間點、資料順序等。
- C:Correct,正確的輸入,並得到預期結果。 ,正確的輸入並得到預期結果。
- D:Design,與設計檔案相結合,來編寫單元測試。 ,與設計檔案相結合來編寫單元測試。
- E:Error,強制錯誤資訊輸入(如:非法資料、異常流程業務允許等),並得 ,強制錯誤資訊輸入(如:非法資料、異常流程業務允許等),並得到預期結果。
與資料庫互動規範
對於資料庫相關的查詢,更新,刪除等操作,不能假設資料庫裡的資料是存在的,或者直接操作資料庫把資料插入進去,請使用程式插入或者匯入資料的方式來準備資料。
反例:刪除某一行資料的單元測試,在資料庫中,先直接手動增加一行作為刪除目標,但是這一行新增資料並不符合業務插入規則,導致測試結果異常。
可以使用 h2 記憶體資料庫保證單測不汙染測試資料庫
不可測的程式碼建議做必要的重構
對於不可測的程式碼建議做必要的重構,使程式碼變得可測,避免為了達到測試要求而書寫不規範測試程式碼。
確定單測範圍
開發人員需要和測試人員一起確定單元測試範圍,單元測試最好覆蓋所有測試用例(UC)。
心態轉變
- 那是測試同學乾的事情。
- 單元測試程式碼是多餘的。 汽車的整體功能與各單元部件的測試正常否是強相關。
- 單元測試程式碼不需要維護。 一年半載後,那麼幾乎處於廢棄狀態。
- 單元測試與線上故障沒有辯證關係。好的單元測試能最大限度規避線上故障。
遇到的問題
如何測試私有方法?
程式碼中用了 new Object() 操作
此種方法暫時無法 Mock,需要將物件包裝一層,用於 Mock