--- 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) } ``` > ![](https://i.imgur.com/ahhfoSU.png) :::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) } ``` > ![](https://i.imgur.com/UMKsEnJ.png) ### Mockito 在 Kotlin 中的限制 * 雖然說 Mockito 方便使用,不過在 Kotlin 中有許多的限制: 1. 無法 Mock `final` class ```kotlin= @Test fun test_finalClz() { val sut = mock<Camping>() // Error } ``` > ![](https://i.imgur.com/zOBij1L.png) 2. 原本可以使用的 `any`、`eq`、`argumentCaptor`、`capture`... 在 Kotlin 中無法使用 ```kotlin= @Test fun test_useAny() { val sut = Camping() sut.getBookingTentType(any(), any()) // Error } ``` > ![](https://i.imgur.com/pNqZlz5.png) 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) } ``` > ![](https://i.imgur.com/L8dTXfy.png) :::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}") } ``` > ![](https://i.imgur.com/leCEafl.png) ::: * 還有另外一個設定為 `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...` 錯誤 > ![](https://i.imgur.com/8TCYeur.png) ### 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()) } } ``` > ![](https://i.imgur.com/hp2YLwo.png) ## 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) } ``` > ![](https://i.imgur.com/D12YKEY.png) * 既然可以 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()) } ``` > ![](https://i.imgur.com/Tv2KT8A.png) ### 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) } ``` > ![](https://i.imgur.com/LbnCGhO.png) ### 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 表示任何參數都可以 } } ``` > ![](https://i.imgur.com/AHiCrY1.png) ### 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: 次數不對 } } ``` > ![](https://i.imgur.com/9to9IT0.png) 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() } } ``` > ![](https://i.imgur.com/y4m52PR.png) ### 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() } } ``` > ![](https://i.imgur.com/mDsKNJx.png) :::success * 使用 verify#exactly 不就可以了嗎,兩者差異在哪 ? `excludeRecords` 用來提醒測試者,這個函數不應該出現在測試中 ! 而 `verify#exactly` 則是驗證實作程式的邏輯 **`excludeRecords` 可以讓測試更清晰** ::: ## 測試框架 - 其他 ### 優點 * 容易驗證參數 * 容易驗證方法被呼叫的次數(Mock),不必定義多餘驗證參數 * 容易創建假物件 (Stub),不會產生多餘的類 ### 注意 * 由於使用框架,所以測試程式會變得更加抽象,最終可能會導致不易閱讀 * 在寫測試時要清楚的知道驗證目標,這個目標要足夠清晰,驗證也要完整(盡可能) * 一個測試中有多個 Mock 物件!這代表了你不清楚你要驗證啥,或是你一次驗證了過多東西,這樣就不是單元測試了! * 過度指定:在測試時往往我們的類或界面會有不只一個方法,這會導致測試框架要指定很多非重點測試目標的假物件 > 在維護測試時也會很難維護,並且會混淆測試目標,不易閱讀 ### 建議 * 盡量使用非嚴格物設定(relate = true),這樣可以避免過度指定 * 盡量使用 Stub 去驗測試;一個測試中可以有多個 Stub 但只能有一個 Mock * 一個測試物件不要同時有 Stub & Mock 兩種身份! ## Appendix & FAQ :::info ::: ###### tags: `Test`