天天看點

MockK:Kotlin Mocking 架構

MockK:Kotlin Mocking 架構

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'
           

最後

如果你看到了這裡,覺得文章寫得不錯就給個贊呗!歡迎大家評論讨論!如果你覺得那裡值得改進的,請給我留言。一定會認真查詢,修正不足,定期免費分享技術幹貨。謝謝!

MockK:Kotlin Mocking 架構

繼續閱讀