---
title: 'Mockito & Mockk 框架'
disqus: kyleAlien
---
Mockito & Mockk 框架
===
## OverView of Content
可以使用 Mockito 這個套件可以輕鬆簡單的做到假物件的建立 (以往必須透過繼承來製作假物件),其中包括 Stub 狀態物件、Mock 互動物件
[TOC]
## 前情提要
手寫測試物件往往是在學測試中的一個必要流程,但在手寫測試時會發現對於大界面來說會寫過多個不必要參數,也會花時間在思考暫時成員的命名... 等等
而測試框架可以很好的幫我們解決這些問題
### 待測試程式
下面有一段代測程式,都可以使用 Mockito、Mockk 來測試
```kotlin=
interface ICost {
fun getCost() : Int
}
interface IWeather {
fun isSunny() : Boolean
}
interface IEmail {
fun sendEmail(type : Camping.Type)
}
class Camping {
enum class Type {
NON,
INNER_TENT,
BIG_TENT,
NORMAL_TENT,
}
fun getBookingTentType(cost : ICost, weather : IWeather) : Type {
val userCost = cost.getCost()
val isSunny = weather.isSunny()
if(!isSunny) {
return Type.INNER_TENT
}
return when(userCost) {
in 1000..2000 -> Type.NORMAL_TENT
in 2001 .. Int.MAX_VALUE -> Type.BIG_TENT
else -> Type.NON
}
}
fun getBookingTentTypeWithMail(cost : ICost,
weather : IWeather,
email : IEmail) : Type {
val userCost = cost.getCost()
val isSunny = weather.isSunny()
val result: Type = if(!isSunny) {
Type.INNER_TENT
} else {
when(userCost) {
in 1000..2000 -> Type.NORMAL_TENT
in 2001 .. Int.MAX_VALUE -> Type.BIG_TENT
else -> Type.NON
}
}
email.sendEmail(type = result)
return result
}
}
```
### 測試取名 - 變量
* **測試變量取名的重要性** (這裡說的不是測是函數)
由於我們在這裡會使用到 mock 測試框架,所以會常常看到 `mock` 關鍵字,但是請不要搞混 Stub & Mock 的責任
| 假物件 | 責任 | 驗證重點 |
| - | - | - |
| Stub | 假設情況驗證回傳 | 可以透過假設一系列的狀況,最終一次驗證結果 |
| Mock | 驗證互動 | 我們必須手動驗證互動後的結果 |
### 測試取名 - 函數
* 測試函數的取名也是幫助我們測試的一大重點;大部分時候我們不會記得我們之前撰寫的測試,必須透過重新看測試內容才能確定
:::success
這時候有一個 **好的函數名稱就可以加快對於這個測試初衷的了解** !
:::
```kotlin=
// 以下是個人的測試取名習慣
// 格式:test_<函數名、重點成員>_<情況>_[使用的測試方案]
@Test
fun test_getBookingTentType_NotSunny_Equals() {
// 函數:getBookingTentType
//
// 狀況:NotSunny
//
// 使用的測試方案:Equals
}
```
## Mockito 概述
Mockito 依賴 (`build.gradle.kts`)
```kotlin=
dependencies {
testImplementation(kotlin("test"))
testImplementation("org.mockito.kotlin:mockito-kotlin:4.1.0")
}
```
### Stub 物件 - 驗證回傳結果
```kotlin=
// 以往需手動繼承物件,並假設具體需要的回傳
// stub 物件
val stubWeather = object : IWeather {
override fun isSunny(): Boolean {
return true
}
}
```
* 使用 Mockito 可以輕鬆建立假 Stub 物件,去 **模擬一個 (多個) 情況**;以下模擬晴天、花費 1000,最後驗證回傳結果
:::info
寫測試時記得先寫失敗案例再寫成功案例
:::
```kotlin=
@Test
fun test_getBookingTentType_SunnyCost1000() {
val iCost = mock(ICost::class.java)
val iWeather = mock(IWeather::class.java)
// Stub 物件
Mockito.`when`(iCost.getCost()).thenReturn(1000)
Mockito.`when`(iWeather.isSunny()).thenReturn(true)
val sut = Camping()
val res = sut.getBookingTentType(iCost, iWeather)
assertEquals(Camping.Type.NORMAL_TENT, res)
}
```
> 
:::warning
由於 when 在 Kotlin 中是關鍵字,所以使用 \`when\` 來代替
:::
### Mock 物件 - 驗證互動結果
```kotlin=
// 以往需手動繼承並假設物件
val exceptType : Camping.Type = Camping.Type.INNER_TENT
var getType : Camping.Type? = null
// 匿名 Mock 物件
val mockEmail = object : IEmail {
override fun sendEmail(type: Camping.Type) {
getType = type
}
}
```
* 使用 Mockito 可以輕鬆建立假 Stub 物件,去 **建立一個物件來與待測物件 (SUT) 互動**;以下建立一個 IEmail 物件,驗證它的函數是否被呼叫
```kotlin=
@Test
fun test_getBookingTentTypeWithMail_Verify() {
val iCost = mock(ICost::class.java)
val iWeather = mock(IWeather::class.java)
val iEmail = mock(IEmail::class.java)
// Stub 物件
Mockito.`when`(iCost.getCost()).thenReturn(1000)
Mockito.`when`(iWeather.isSunny()).thenReturn(true)
val sut = Camping()
sut.getBookingTentTypeWithMail(iCost, iWeather, iEmail)
// Mock 物件
Mockito.verify(iEmail).sendEmail(Camping.Type.NORMAL_TENT)
}
```
> 
### Mockito 在 Kotlin 中的限制
* 雖然說 Mockito 方便使用,不過在 Kotlin 中有許多的限制:
1. 無法 Mock `final` class
```kotlin=
@Test
fun test_finalClz() {
val sut = mock<Camping>() // Error
}
```
> 
2. 原本可以使用的 `any`、`eq`、`argumentCaptor`、`capture`... 在 Kotlin 中無法使用
```kotlin=
@Test
fun test_useAny() {
val sut = Camping()
sut.getBookingTentType(any(), any()) // Error
}
```
> 
3. 最後是 `when` 關鍵字被重複 (改成使用必須使用 \`when\`)
## Mockk 概述
Mockk 是一個專門在為 Kotlin 創建 Mocking 的框架,所有 Mockito 可以做到的事情 Mockk 都可達成,並且克服了 Mockito 在 Kotlin 中的限制
* Mockito 依賴 (`build.gradle.kts`)
```kotlin=
dependencies {
testImplementation(kotlin("test"))
testImplementation("io.mockk:mockk:1.9.3")
}
```
### Stub 物件 - 驗證回傳結果
* Mockk 使用 `every` 函數取代 `when`,同樣可以製作物件來模擬狀況
```kotlin=
@Test
fun test_getBookingTentType_NotSunny() {
val stubCost = mockk<ICost>()
val stubWeather = mockk<IWeather>()
every {
stubCost.getCost()
}.returns(10000)
every {
stubWeather.isSunny()
}.returns(false)
val sutRes = Camping().getBookingTentType(stubCost, stubWeather)
assertEquals(Camping.Type.INNER_TENT, sutRes)
}
```
:::warning
* **怎麼多個一個無相關的 `ICost` 假物件的回傳**
```kotlin=
every {
stubCost.getCost() // 為何要這個 ?
}.returns(10000)
```
Mockk 框架對於函數的檢查相當嚴格,**所有的函數都必須做出假設才能讓測試正常運行**
:::
* Mockk 可以透過 `Relaxed mock` (relaxed 參數) 來假設物件的預設情況;最終可以達到更清晰的測試 ! (太多不相干的假設情況會讓測試混亂)
```kotlin=
@Test
fun test_getBookingTentType_NotSunnyWithRelaxed() {
val stubCost = mockk<ICost>(relaxed = true)
val stubWeather = mockk<IWeather>()
every {
stubWeather.isSunny()
}.returns(false)
val sutRes = Camping().getBookingTentType(stubCost, stubWeather)
assertEquals(Camping.Type.INNER_TENT, sutRes)
}
```
> 
:::warning
* 這種 `Relaxed mock` 預設回傳預設可以預設幾層呢? 基本上全部都會有預設值,如果要特定值則需要自己手動設定
```kotlin=
// 新增 Test 物件,查看 mock 後是否會有 msg(String) 物件
data class World(val msg: String)
data class Hello(val world: World)
data class Test(val hello : Hello)
interface IMock {
fun getTest() : Test
}
// 以下是測試 -------------------------------------------
@Test
fun test_mockRelaxed() {
val mock = mockk<IMock>(relaxed = true)
val sut = mock.getTest()
println("Test: $sut")
println("Test hello msg: ${sut.hello}")
println("Test hello world: ${sut.hello.world}")
println("Test hello world msg: ${sut.hello.world.msg}")
}
```
> 
:::
* 還有另外一個設定為 `relaxUnitFun`,它只假設回傳 Unit 的函數,如果有非 Unit 的函數就必須自己假設 (使用 every)
```kotlin=
interface IMock {
fun getTest() : Test
fun getUnit()
}
@Test
fun test_mockRelaxedUnitFun() {
val mock = mockk<IMock>(relaxUnitFun = true)
println("Test: ${mock.getTest()}")
println("Test hello msg: ${mock.getUnit()}")
}
```
如果 Mockk 發現沒有假設的函數會拋出 `no answer found...` 錯誤
> 
### Mock 物件 - 驗證互動結果
* Mock 物件的重點是在驗證呼叫 sut 函數後,與 Mock 物件的互動 (這個驗證要手動)
```kotlin=
@Test
fun test_getBookingTentType_NotSunnySendEmail_Verify() {
val stubCost = mockk<ICost>(relaxed = true)
val stubWeather = mockk<IWeather>(relaxed = true)
// 準備驗證互動的 mock 物件
val mockEmail = mockk<IEmail>(relaxed = true)
every {
stubWeather.isSunny()
}.returns(false)
// 呼叫 sut 物件
Camping().getBookingTentTypeWithMail(stubCost, stubWeather, mockEmail)
verify(exactly = 1) { // 手動驗證
mockEmail.sendEmail(any())
}
}
```
> 
## Mockk 其他使用
### Mock Enum & Object
* 我們知道 Enum 的內部成員都是一個類的實例化,而 Mockk 也可以 Mock
```kotlin=
enum class MockkEnum(val num : Int) {
ONE(1),
TWO(2),
THREE(3);
}
```
Mock One 物件,修改其回傳值
```kotlin=
@Test
fun test_MockkEnum() {
val expect = 666
mockkObject(MockkEnum.ONE)
every {
MockkEnum.ONE.num
}.returns(expect)
assertEquals(expect, MockkEnum.ONE.num)
}
```
> 
* 既然可以 Mock Enum 很自然的我們可以想到單例是否可以 Mock 呢 ? **當然可以**
```kotlin=
object MockObj {
val msg : String = "HelloWorld"
fun getMsgLen() : Int {
return msg.length
}
}
```
測試 Mock 單例物件,修改 `MockObj` 成員驗證其成員 & 函數
```kotlin=
@Test
fun test_MockkObject() {
val expect = "????"
mockkObject(MockObj)
every {
MockObj.msg
}.returns(expect)
assertEquals(expect, MockObj.msg)
}
@Test
fun test_MockkObject2() {
val expect = "????".length
mockkObject(MockObj)
every {
MockObj.getMsgLen()
}.returns(expect)
assertEquals(expect, MockObj.getMsgLen())
}
```
> 
### Capture 擷取參數
* 如果有需求需要驗證傳入方法的參數,就可以使用 `slot`、`capture` 來達成參數的捕捉 ! **`slot` 用來創建假物件,`capture` 用來捕捉假物件**,最後驗證 `slot`
> 這種再次手動驗證就是 Mock 的行為
* 以下我們來驗證傳入方法的參數是否正確
```kotlin=
interface IShow {
fun showNum(num : Int)
}
class MockCapture {
fun add10ToNum(num : Int, iShow: IShow) {
iShow.showNum(num + 10) // 驗證傳入值是否有被 + 10
}
}
```
捕捉傳入 `showNum` 函數前的參數是否正確如預期
```kotlin=
@Test
fun test_UseCapture() {
val expect = 110
val mockShow = mockk<IShow>()
val slot = slot<Int>() // 創建假物件
val sut = MockCapture()
every {
mockShow.showNum(capture(slot)) // 捕捉假物件
}.just(Runs)
sut.add10ToNum(100, mockShow)
assertEquals(expect, slot.captured)
}
```
> 
### Verify 驗證參數 - Matcher
* 除了使用 `Capture` 來捕捉驗證參數之外,也可以使用 `Verify` Matcher 驗證,達到相同效果
| Matcher 相關函數 | 功能 |
| -------- | -------- |
| range | 專門比較數字,在範圍某之間 |
| less | 專門比較數字,比小 |
| more | 專門比較數字,比大 |
| eq | 比較任何類型,包括物件也可以比較 |
| any | 任意類型 (其實就是不會比較,比較少用在 Verify) |
```kotlin=
@Test
fun test_Verify_params() {
val stubShow = mockk<IShow>(relaxed = true)
val sut = MockCapture()
sut.add10ToNum(10, stubShow)
verify {
stubShow.showNum(eq(20))
// 以下都通過
stubShow.showNum(range(20, 30))
stubShow.showNum(less(21))
stubShow.showNum(more(19))
stubShow.showNum(any()) // any 表示任何參數都可以
}
}
```
> 
### Verify 函數次數、時間
* 使用 `Verify` 關鍵字就可以驗證函數是否被呼叫,其更細節的設定在它的參數,參數代表意義如下表
| Verify 函數參數 | 主要測試 |
| - | - |
| exactly | 該函數 **確切被呼叫次數** |
| atLeast | 該函數 **最少** 會被呼叫多少次 |
| atMost | 該函數 **最多** 會被呼叫多少次 |
| timeout | 該函數 限制多少時間內必須被呼叫 |
以下示範跟參數次數相關的參數 `exactly`,其他的用法差異不大
```kotlin=
@Test
fun test_Verify_Exactly() {
val stubCost = mockk<ICost>(relaxed = true)
val stubWeather = mockk<IWeather>(relaxed = true)
val sut = Camping()
sut.getBookingTentType(stubCost, stubWeather)
verify(exactly = 1) {
stubWeather.isSunny()
}
}
```
:::success
* `Verify` 一次可以驗證多個函數 (**但不包含呼叫的順序**)
```kotlin=
@Test
fun test_Verify_Exactly2() {
val stubCost = mockk<ICost>(relaxed = true)
val stubWeather = mockk<IWeather>(relaxed = true)
val sut = Camping()
sut.getBookingTentType(stubCost, stubWeather)
verify(exactly = 1) {
// 順序相反
stubWeather.isSunny()
stubCost.getCost()
}
}
```
:::
* 在示範一個 timeout 驗證:驗證區塊在多少毫秒內沒有被呼叫,就會驗證失敗
```kotlin=
@Test
fun test_Verify_timeout() {
val stubCost = mockk<ICost>(relaxed = true)
val stubWeather = mockk<IWeather>(relaxed = true)
val stubEmail = mockk<IEmail>(relaxed = true)
val sut = Camping()
Thread {
Thread.sleep(100)
sut.getBookingTentTypeWithMail(stubCost, stubWeather, stubEmail)
}.start()
verify(timeout = 500) {
// 驗證區塊
stubEmail.sendEmail(any())
}
}
```
### Verify 順序
* 想要驗證函數的順序,可以使用以下函數
| Verify 順序相關函數 | 主要測試 |
| - | - |
| verifySequence | 關注函數的 **呼叫順序、次數** |
| verifyOrder | 關注函數的 **呼叫順序** (次數不重要,中間插入另一個函數也沒關係) |
```kotlin=
// 待測程式
interface ILogin {
fun checkAccount(account: String) : Boolean
fun checkPassword(password : String) : Boolean
fun login()
}
class OrderClz(private val login : ILogin) {
fun startLogin(account: String, password : String) {
if (!login.checkAccount(account)) {
return
}
if (!login.checkPassword(password)) {
return
}
login.login()
}
}
```
1. **`verifySequence` 函數**:嚴謹的驗證順序 & 次數
```kotlin=
@Test
fun test_verifySequence_functionOrder() {
val stubLogin = mockk<ILogin>(relaxed = true)
val sut = OrderClz(stubLogin)
every {
stubLogin.checkAccount(any())
}.returns(true)
every {
stubLogin.checkPassword(any())
}.returns(true)
sut.startLogin("", "")
verifySequence {
stubLogin.checkAccount(any())
stubLogin.checkPassword(any())
stubLogin.login()
// stubLogin.login() Error: 次數不對
}
}
```
> 
2. **`verifyOrder` 函數**:只驗證順序
```kotlin=
@Test
fun test_verifyOrder_functionOrder() {
val stubLogin = mockk<ILogin>(relaxed = true)
val sut = OrderClz(stubLogin)
every {
stubLogin.checkAccount(any())
}.returns(true)
every {
stubLogin.checkPassword(any())
}.returns(true)
sut.startLogin("", "")
verifyOrder {
stubLogin.checkAccount(any())
// stubLogin.checkPassword(any()) // 省略也沒關係
stubLogin.login()
}
}
```
> 
### excludeRecords 排除函數
* Mockk 有驗證函數呼叫順序,也同時提供了排除的驗證 (驗證某個函數不該被呼叫到);以下假設 `startLogin` 函數不呼叫 `checkPassword`
```kotlin=
interface ILogin {
fun checkAccount(account: String) : Boolean
fun checkPassword(password : String) : Boolean
fun login()
}
class ExcludeClz(private val login : ILogin) {
fun startLoginWithoutPasswordCheck(account: String) {
if (!login.checkAccount(account)) {
return
}
login.login()
}
}
```
使用 `excludeRecords` 排除某函數的驗證 (也就是該函數不該出現在 verify 中)
```kotlin=
@Test
fun test_verify_excludeRecords() {
val stubLogin = mockk<ILogin>(relaxed = true)
val sut = ExcludeClz(stubLogin)
every {
stubLogin.checkAccount(any())
}.returns(true)
excludeRecords {
stubLogin.checkPassword(any())
}
sut.startLoginWithoutPasswordCheck("")
verify {
stubLogin.checkAccount(any())
// stubLogin.checkPassword(any()) // 不該出現
stubLogin.login()
}
}
```
> 
:::success
* 使用 verify#exactly 不就可以了嗎,兩者差異在哪 ?
`excludeRecords` 用來提醒測試者,這個函數不應該出現在測試中 ! 而 `verify#exactly` 則是驗證實作程式的邏輯
**`excludeRecords` 可以讓測試更清晰**
:::
## 測試框架 - 其他
### 優點
* 容易驗證參數
* 容易驗證方法被呼叫的次數(Mock),不必定義多餘驗證參數
* 容易創建假物件 (Stub),不會產生多餘的類
### 注意
* 由於使用框架,所以測試程式會變得更加抽象,最終可能會導致不易閱讀
* 在寫測試時要清楚的知道驗證目標,這個目標要足夠清晰,驗證也要完整(盡可能)
* 一個測試中有多個 Mock 物件!這代表了你不清楚你要驗證啥,或是你一次驗證了過多東西,這樣就不是單元測試了!
* 過度指定:在測試時往往我們的類或界面會有不只一個方法,這會導致測試框架要指定很多非重點測試目標的假物件
> 在維護測試時也會很難維護,並且會混淆測試目標,不易閱讀
### 建議
* 盡量使用非嚴格物設定(relate = true),這樣可以避免過度指定
* 盡量使用 Stub 去驗測試;一個測試中可以有多個 Stub 但只能有一個 Mock
* 一個測試物件不要同時有 Stub & Mock 兩種身份!
## Appendix & FAQ
:::info
:::
###### tags: `Test`