MockK:Kotlin Mocking 框架

目錄

  1. 什么是單元測試?
  2. 為什么很多人不愿意做單元測試?
  3. 什么是測試驅動開發?
  4. 怎么進行測試驅動開發?
  5. 為什么要使用 Mock?
  6. Mockito 好用嗎?
  7. MockK 怎么用?
  8. 示例代碼倉庫地址
  9. 參考文獻

在介紹 MockK 前,我們先看看什么是單元測試和測試驅動開發,如果你對這一塊已經了解的話,你可以跳過,直接看主要的第 7 大節。

1. 什么是單元測試?

一個單元測試是一段自動化的代碼,這段代碼調用被測試的工作單元,然后對這個單元的單個最終結果的某些假設進行檢驗。單元測試容易編寫,能快速運、可靠、易讀且可維護,只要生產代碼不發生變化,單元測試的結果是穩定的。

從調用系統的一個公共方法到產生一個測試可見的最終結果,期間這個系統發生的行為總稱為一個工作單元。

說白了單元測試就像是你煮湯的時候喝一點試試味,看看會不會太淡或太咸。

2. 為什么很多人不愿意做單元測試?

不愿意做單元測試的理由通常有下面幾個。

2.1 我的項目不是新的,老項目的代碼寫得太爛

這個理由出現有兩種情況,一種是項目的代碼真的太爛了,另一種情況則是因為懶。

如果是項目代碼真的太爛,甚至爛到無法往上添加新功能了,重構起來的成本遠高于重新開發時,就應該考慮跟技術老大提議重寫這個項目,否則項目進度會不斷一次又一次地因為這些技術債的原因而延期。

如果是覺得本來只需要寫 5 行的代碼,加上單元測試,就變成了 10 行,再加上個邊界值的測試,可能要 20 行。

但是如果應用出了 bug,不僅公司會遭受損失,你的能力也會受到其他同事的質疑,那豈不是得不償失?

如果代碼只是部分寫得爛,那是不是可以考慮對這部分代碼進行重構?

在重構前建立一系列測試,這樣重構后的代碼才能正常工作。而且后續如果有需求變動,也能用這些測試確保修改后的代碼是正常且沒有影響到其他功能的。

2.2 開發的時間太短,沒時間做單元測試

開發時間短不應該成為不寫單元測試的理由,而應該是寫單元測試的原因。

因為哪怕開發時間再短,即時你按時實現了功能,但是如果有 bug,需要返工,那不是更浪費時間嗎?

2.3 有熱修復框架,出了 bug 也不怕

騰訊熱補丁框架 Tinker 的 GitHub 倉庫 2017 年前就有了,但在 18 年騰訊視頻還是出了一個 2 毛錢會員 bug,這個 bug 后續是修復了,但是損失也已經造成了。

如果騰訊視頻開發團隊在發布時建立了這一塊的單元測試,而且對邊界值也進行了測試,就不會出現這樣的問題了。

不過熱修復框架依舊是非常好的工具,即使代碼覆蓋率很高,也不能絕對保證應用就不會出現 bug 了,而出現 bug 的時候還是需要即時修復的,這時候就要用到熱修復框架了。

3. 什么是測試驅動開發?

3.1 測試驅動開發的定義

測試驅動開發(TDD,Test-Driven Development),用一句話說就是寫代碼只為了修復失敗的測試

測試驅動開發讓我們把處理問題的方式從被動修復問題轉變為主動暴露問題。

測試驅動開發有點像我們玩游戲,大多數游戲每一個關卡的設計都是有點難,但是又不會太難的。

3.2 測試驅動開發的好處

  • 不用再長時間調試代碼

    在不使用測試驅動的情況下,假如你修改了一個電商 App 中處理商品列表的函數,然后你想試試搜索出來時該函數是否正確處理了請求下來的列表,那你需要經歷八個步驟:安裝—閃屏頁—主頁—點擊搜索框—輸入關鍵字—點擊搜索—請求列表—處理列表。

    如果是在找出 bug 的地方,打開了 Debugger 走這個流程,而且斷點打得多的話,你還要一次又一次地繼續到下一個斷點,這個時間短則幾十秒,長則幾分鐘。

    幾分鐘又幾分鐘的積累下來,嚴重的話可能一天下來有三分之一的時間都是在調試代碼,而且可能最后發現是一個小小的錯誤導致的。

    如果使用測試驅動,你可以給這個函數模擬一個商品列表,在這個功能實現之前你就已經知道什么時候算是能做完了。

    如果后續需求有變動,需要重構代碼,你也不用再一步步點擊,測試運行時間不超過 5 秒,而且寫一個單元測試的時間一般就是幾秒鐘,長的話也就幾分鐘。

    如果單個單元測試的時間過長,那就說明這個測試是有問題的,不是測試中測試的點太多,就是測試的函數太長,需要進行重構。

  • 對自己的代碼有信心

    如果不使用測試驅動開發,當技術老大問你都搞定了吧,你只能心虛地說搞定了,然后交給測試人員去測試,找到問題了再修復。

    如果使用測試驅動開發,你把測試都跑一遍,知道大多數的功能都是正常運行的,你交付軟件給測試人員和技術老大的時候也就不用心虛了。

  • 優化代碼結構

    使用測試驅動開發,會倒逼你去優化代碼,因為難懂的、職責不明確的類和函數是難以測試的。

3.3 我是怎么接觸到測試驅動開發的?

在我開發 OkRefelct 以前,我也在公司的項目中也建立了單元測試,但是我當時的做法是在寫完代碼后再寫單元測試。

而在開發 OkRefelct 時,每一個功能我都提前寫好了測試,一般情況下連功能的方法都還沒聲明就先寫測試方法了,寫完代碼后點一下運行,綠了,感覺人生都充滿了希望。而且報編譯錯誤的代碼會不斷提醒我專注于當前需要實現的功能,幫我提高專注度。

3.4 測試驅動開發需要注意的問題

  • 遺留測試

    和生產代碼一樣,測試代碼會有遺留代碼,當項目被其他接收的時候,如果這些遺留測試的命名沒有清晰地說明這些測試的目的,而且也沒有注釋說明這些測試的意義,那當測試這些失敗的時候,新進的開發者就會很迷惑,不知道怎么做,最后的選擇可能是放棄測試驅動或刪除掉這部分的測試代碼。

  • 可維護性

    不僅是遺留代碼,即便是新寫的測試,命名也應該是清晰地表明當前測試的目的,否則可能第二天你就忘了自己當時為什么要寫這個測試了。

4. 怎么進行測試驅動開發?

傳統的軟件開發流程是設計—編碼—測試。
而測試驅動開發的流程是測試—編碼—重構

4.1 測試

在測試階段,我們要寫剛好失敗的測試

我們需要測試的代碼大多數都是公共(public)函數,這個函數可能是給我們自己或提供給其他開發者使用的。

先寫測試能讓我們站在用戶的角度去看待我們的函數,這個角度能讓我們能寫出具有高可用性的 API。

之所以測試要“剛好失敗”,是因為失敗的測試暗示著應用的部分功能缺失,如果你一口氣寫的測試太多,可能導致寫了幾個小時都還沒有一個測試能運行,弄得自己越寫越沒勁。

4.2 編碼

在編碼階段,我們要寫剛好能通過測試的代碼

上面已經說了不能一口氣寫太多測試,這樣我們就不用一口氣寫太多代碼了,我們可以讓失敗的測試來時刻提醒我們專注于實現當前缺失的功能。

每次通過測試,我們就能知道工作取得進展了,一般為一個功能寫一個測試到實現功能代碼的過程也就幾分鐘。如果超過這個時間,一般都是因為我們寫的函數沒有做到單一職責,而職責過多的函數是難以維護的。

之所以這個階段寫的代碼不需要太完善,只需要“剛好能通過測試”,是因為我們會在下一步來對代碼進行重構。

4.3 重構

在重構階段,我們要找出現有代碼的問題,優化代碼質量

重構是 TDD 的最后一步,重構能讓我們進行 TDD 的步伐更穩健。

使用 TDD 而不進行重構會帶來大量的爛代碼,不論我們的測試覆蓋率有多高,爛代碼還是爛代碼。

良好的代碼質量能提供我們后續的開發效率,是 TDD 中必不可少的一步。

5. 為什么要用 Mock?

5.1 Mock 的定義

Mock 也就是模擬單元測試中需要用到的對象和方法,這樣能避免創建對象帶來的麻煩。

5.2 使用 Mock 的理由

假如我們現在有一個用 MVP 架構實現的 Android 項目,如果我們想驗證 Presenter 中的邏輯是否正確,需要用到 Activity 時,有三個辦法是可以做到。

  • 設備式測試(Instrumented tests)

    通過把單元測試換成設備式測試,我們可以獲取到 Activity 的真實實例。但設備式測試的問題就在于運行時間太長,當你的電腦性能比較差,或者 APK 包很大時,運行速度更是慢得嚇人。

  • Robolectric

    通過 Robolectric 模擬點擊事件并檢查視圖上的文本,我們可以實現同時檢驗視圖以及 Presenter 的邏輯,但是這么做的問題就在于這個測試方法的職責不是單一的。

    如果我們真的想檢驗視圖的展示是否正確,正確的做法應該是通過 Mock 提供數據給 Activity。

    而且 Robolectric 的本質是建立了一個沙盒讓我們能夠在沙盒中進行測試,需要相對比較多的資源來完成一次測試,這樣就導致了用了 Robolectric 的單元測試運行速度也很慢,快的話幾十秒,慢的話甚至要幾分鐘。

  • Mock

    在 Presenter 的方法中會調用 View 接口提供的各種方法實現與 View 的一個通信,比如顯示和隱藏 Loading 動畫,所以 Presenter 的 getView() 方法的返回值不能為空。

    而 MVP 的實現方式的其中一種是通過 Presenter 的 attachView() 方法綁定 Presenter 和 View,這種情況下我們就可以 mock 一個 View 接口,并將 View 傳入 attachView() 方法實現綁定,這樣 Presenter 中的 getView() 就不為空了。

    通過這種方式,我們可以實現獨立地測試 Presenter 的邏輯,比如下面這樣的。

@RunWith(MockitoJUnitRunner.class)
public class GoodsPresenterTest {

    private GoodsPresenter presenter;

    @Mock
    GoodsContract.View view;

    @Before
    public void setUp() {
        MockitoAnnotations.initMocks(this);
        presenter = new GoodsPresenter();
        presenter.attachView(view);
    }

    @Test
    public void testGetGoods() {
        Goods goods = presenter.getGoods(1);
        assert goods.name.equals("紙巾");
    }

}

像上面這樣一個單元測試,在正常的情況下幾秒鐘就能完成,非常快。

6. Mockito 好用嗎?

6.1 Mockito 介紹

Mockito 是一個用 Java 寫的 Mocking(模擬)框架,5.2 小節的示例代碼中對 View 的 Mock 就是通過 Mockito 來進行的。

6.2 Mockito 存在的問題

  • 類型

    Mockito 不支持對 final class、匿名內部類以及基本類型(如 int)的 mock。

  • 方法

    Mockito 不支持對靜態方法、 final 方法、私有方法、equals() 和 hashCode() 方法進行 mock。

  • Kotlin

    在 Kotlin 寫的測試中用 Mockito 會用得很不順手,之所以不順手有兩點。

    第一點是上面說到的 Mockito 不支持對 final 類和 final 方法進行 mock,而在 Kotlin 中類和方法默認都是 final 的,也就是當你使用 Mockito 模擬 Kotlin 的類和方法時,你要為它們加上 open 關鍵字,如果你的項目中的類都是用 Kotlin 寫的,那這一點會讓你非常頭疼。

    第二點是 Mockito 的 when 方法與 Kotlin 的關鍵字沖突了,當 when 的數量比較多時,寫出來的代碼看上去會比較別扭,比如下面這樣的。

@Test
fun testAdd() {
    `when`(calculator!!.add(1, 1)).thenReturn(2)
    assertEquals(calculator!!.add(1, 1), 2)
}

7. MockK 怎么用?

7.1 MockK 介紹

MockK 是一個用 Kotlin 寫的 Mocking 框架,它解決了所有上述提到的 Mockito 中存在的問題。

7.2 使用 MockK 測試 Calculator

6.2 小節中的代碼,如果我們用 MockK 來做的話是這樣的。

@Test
fun testAdd() {
    // 每一次 add(1, 1) 被調用,都返回 2
    // 相當于是 Mockito 中的 when(…).thenReturns(…)
    every { calculator.add(1, 1) } returns 2
    assertEquals(calculator.add(1, 1), 2)
}

7.3 使用 MockK 測試 Presenter

5.2 小節的 PresenterTest 用 MockK 來實現的話,是下面這樣的。

class GoodsPresenterTest {

    private var presenter: GoodsPresenter? = null

    // @MockK(relaxed = true)
    @RelaxedMockK
    lateinit var view: GoodsContract.View

    @Before
    fun setUp() {
        MockKAnnotations.init(this)
        presenter = GoodsPresenter()
        presenter!!.attachView(view)
    }

    @Test
    fun testGetGoods() {
        val goods = presenter!!.getGoods(1)
        assertEquals(goods.name, "紙巾")
    }

}

在 MockK 中,如果你模擬的對象的方法是沒有返回值的,并且你也不想要指定該方法的行為,你可以指定 relaxed = true ,也可以使用 @RelaxedMockK 注解,這樣 MockK 就會為它指定一個默認行為,否則的話會報 MockKException 異常。

7.4 為無返回值的方法分配默認行為

把 every {…} 后面的 Returns 換成 just Runs ,就可以讓 MockK 為這個沒有返回值的方法分配一個默認行為。

@Test
fun testGetGoods() {
    val goods = presenter!!.getGoods(1)
    every { view.showLoading() } just Runs
    verify { view.showLoading() }
    assertEquals(goods.name, "紙巾")
}

7.5 為所有模擬對象的方法分配默認行為

如果測試中有多個模擬對象,且你想為它們的全部方法都分配默認行為,那你可以在初始化 MockK 的時候指定 relaxed 為 true,比如下面這樣。

@Before
fun setUp() {
    MockKAnnotations.init(this, relaxed = true)
}

使用這種方式我們就不需要使用 @RelaxedMockK 注解了,直接使用 @MockK 注解即可。

7.6 驗證多個方法被調用

在 GoodsPresenter 的 getGoods() 方法中調用了 View 的 showLoading() 和 hideLoading() 方法,如果我們想驗證這兩個方法執行了的話,我們可以把兩個方法都放在 verify {…} 中進行驗證。

@Test
fun testGetGoods() {
    val goods = presenter!!.getGoods(1)
    verify { 
        view.hideLoading()
        view.showLoading() 
    }
    assertEquals(goods.name, "紙巾")
}

7.7 驗證方法被調用的次數

如果你不僅想驗證方法被調用,而且想驗證該方法被調用的次數,你可以在 verify 中指定 exatcly、atLeast 和 atMost 屬性,比如下面這樣的。

@Test
fun testGetGoods() {
    val goods = presenter!!.getGoods(1)
    // 驗證調用了兩次
    verify(exactly = 2) { view.showToast("請耐心等待") }
  
    // 驗證調用了最少一次
    // verify(atLeast = 1) { view.showToast("請耐心等待") }
  
    // 驗證最多調用了兩次
    // verify(atMost = 1) { view.showToast("請耐心等待") }

    assertEquals(goods.name, "紙巾")
}

之所把 atLeast 和 atMost 注釋掉,是因為這種類型的驗證只能進行其中一種,而不能多種同時驗證。

7.8 驗證 Mock 方法都被調用了

Mock 方法指的是,我們當前調用的方法中,調用了的模擬對象的方法。

@Test
fun testGetGoods() {
    val goods = presenter!!.getGoods(1)
    verifyAll {
        view.showToast("請耐心等待")
        view.showToast("請耐心等待")
        view.showLoading()
        view.hideLoading()
    }
    assertEquals(goods.name, "紙巾")
}

7.9 驗證 Mock 方法的調用順序

@Test
fun testGetGoods() {
    val goods = presenter!!.getGoods(1)
    verifyOrder {
        view.showLoading()
        view.hideLoading()
    }
    assertEquals(goods.name, "紙巾")
}

7.10 驗證全部的 Mock 方法都按特定順序被調用了

如果你不僅想測試好幾個方法被調用了,而且想確保它們是按固定順序被調用的,你可以使用 verifySequence {…} ,比如下面這樣的。

@Test
fun testGetGoods() {
    val goods = presenter!!.getGoods(1)
    verifySequence {
        view.showLoading()
        view.showToast("請耐心等待")
        view.showToast("請耐心等待")
        view.hideLoading()
    }
    assertEquals(goods.name, "紙巾")
}

7.11 確認所有 Mock 方法都進行了驗證

把我們的模擬對象傳入 confirmVerified() 方法中,就可以確認是否驗證了模擬對象的每一個方法。

@Test
fun testGetGoods() {
    val goods = presenter!!.getGoods(1)
    verify {
        view.showLoading()
        view.showToast("請耐心等待")
        view.showToast("請耐心等待")
        view.hideLoading()
    }
    confirmVerified(view)
    assertEquals(goods.name, "紙巾")
}

7.12 驗證 Mock 方法接收到的單個參數

如果我們想驗證方法接收到的參數是預期的參數,那我們可以用 capture(slot) 進行驗證,比如下面這樣的。

@Test
fun testCaptureSlot() {
    val slot = slot<String>()
    every { view.showToast(capture(slot)) } returns Unit
    val goods = presenter!!.getGoods(1)
    assertEquals(slot.captured, "請耐心等待")
}

7.13 驗證 Mock 方法每一次被調用接收到參數

如果一個方法被調用了多次,可以使用 capture(mutableList) 將每一次被調用時獲取到的參數記錄下來, 并在后面進行驗證,比如下面這樣。

@Test
fun testCaptureList() {
    val list = mutableListOf<String>()
    every { view.showToast(capture(list)) } returns Unit
    val goods1 = presenter!!.getGoods(1)
    assertEquals(list[0], "請耐心等待")
    assertEquals(list[1], "請耐心等待")
}

7.14 驗證使用 Kotlin 協程進行耗時操作

使用 Mockito 測試異步代碼,只能通過 Thread.sleep() 阻塞當前線程,否則異步任務還沒完成,當前測試就完成了,當前測試所對應的線程也就結束了,沒有線程能處理回調中的結果。

當我們的協程涉及到線程切換時,我們需要在 setUp() 和 tearDown() 方法中設置和重置主線程的代理對象。

使用 verify(timeout) {…} 就可以實現延遲驗證,比如下面代碼中的 timeout = 2000 就表示在 2 秒后檢查該方法是否被調用。

class GoodsPresenterTest {

    private val mainThreadSurrogate = newSingleThreadContext("UI Thread")
    private var presenter: GoodsPresenter? = null

    @MockK
    lateinit var view: GoodsContract.View

    @Before
    fun setUp() {
        MockKAnnotations.init(this, relaxed = true)
        presenter = GoodsPresenter()
        presenter!!.attachView(view)
        Dispatchers.setMain(mainThreadSurrogate)
    }

    @After
    fun tearDown() {
        Dispatchers.resetMain()
        mainThreadSurrogate.close()
    }

    @Test
    fun testBlockingTask() {
        presenter!!.requestGoods(1)
        verify(timeout = 2000) { view.hideLoading() }
    }

}

7.15 添加依賴

// Unit tests
testImplementation "io.mockk:mockk:1.9.3"

// Instrumented tests
androidTestImplementation('io.mockk:mockk-android:1.9.3') { exclude module: 'objenesis' }
androidTestImplementation 'org.objenesis:objenesis:2.6'

// Coroutine tests
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.0-M2'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.3.0-M2'

示例代碼倉庫地址

GitHub 地址

參考文獻

《單元測試的藝術(第2版)》

《測試驅動開發的藝術》

《Google 軟件測試之道》

MockK GitHub

MockK 官方文檔

MockK: A Mocking Library for Kotlin | Baeldung

用 Kotlin + Mockito 寫單元測試會碰到什麼問題?

MockK 功能介紹:mockk, every, Annotation, verify

Coroutine tests

Mocking is not rocket science: MockK advanced features

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • 單元測試的目標和挑戰 單元測試的思路是在不涉及依賴關系的情況下測試代碼(隔離性),所以測試代碼與其他類或者系統的關...
    jiangmo閱讀 2,158評論 0 2
  • 在博客Android單元測試之JUnit4中,我們簡單地介紹了:什么是單元測試,為什么要用單元測試,并展示了一個簡...
    水木飛雪閱讀 9,581評論 4 18
  • 幾點說明:代碼中的 //<== 表示跟上面的相比,這是新增的,或者是修改的代碼,不知道怎么樣在代碼塊里面再強調幾行...
    鄒小創閱讀 14,060評論 15 41
  • 本文部分內容及示例來自Google Testing Blog,有興趣可以在文末點擊鏈接查看原文。 Preface ...
    lshilll閱讀 945評論 1 8
  • 1.Mockito是什么 Mockito是Mock框架,mock 測試就是在測試過程中,對于某些不容易構造或者不容...
    一笑小先生閱讀 3,421評論 0 2