使用Kotlin構建MVVM應用程式—第六部分:單元測試

ditclear發表於2018-11-22

簡書地址:www.jianshu.com/p/2ce583fc3…

使用Kotlin構建MVVM應用程式—第六部分:單元測試

目錄

寫在前面

這裡是使用Kotlin構建MVVM應用程式—第六部分:單元測試。

**單元測試 **這個詞對於大多數android程式設計師來說應該是不陌生的,或者聽說過,或者在某篇部落格上見過,但是真正去實踐過的可謂少之又少。

沒實踐的原因可能是:

  • 業務繁重,沒時間
  • 沒必要,測試的同事測過就可以了
  • 需求變化快,寫了也許又要改。。

總有理由安慰自己。那為什麼我將其作為本系列的第六部分而非是提高篇裡的內容呢?

在我看來,瞭解單元測試應該是每一名開發人員應該具備的素質,只有知道怎樣的程式碼是適合進行單元測試的,才能寫出高質量的程式碼。

可以簡單的認為通過了單元測試的程式碼才是高質量的程式碼。

因此,我將其作為本系列的第六部分,希望學習本系列的android開發人員都能擺脫碼農向工程師邁進,不求掌握,但求瞭解

關於為什麼要進行單元測試?還可以檢視小創的文章為什麼要做單元測試

如果你想學習如何做單元測試,可以檢視關於安卓單元測試,你需要知道的一切

在MVVM中如何進行單元測試?

首先,加入依賴

//幫助進行mock
testImplementation 'org.mockito:mockito-core:2.15.0'
//單元測試
testImplementation 'junit:junit:4.12'
複製程式碼

其次,知道要測試些什麼?

寫點有價值的測試用例這篇文章裡對這個問題進行了解答

對於測試用例的設計,不能離開架構層面和業務層面

  • Presenter(ViewModel) 層:這一層很清晰,我們為它的每個介面方法,以及每個方法裡涉及的多個邏輯路徑設計相應的測試用例,值得注意的是,這一層我們較少做輸入輸出的斷言,而是驗證是否正確覆蓋V層和M層的邏輯。
  • Model層: 同上,我們為它的每個方法設計測試用例,與P層不同,這一層要斷言輸入輸出資料是否準確。
  • View層:主要是進行ui測試是業務層面的測試。

那什麼是沒價值的測試用例,有以下幾種:

  1. 對成熟的工具類進行測試
  2. 對簡單的方法進行測試(比如get、set方法)
  3. MVP(VM)各層重複測試,比如P(VM)層去斷言輸入輸出的正確性

本文描述的單元測試主要是Model層和ViewModel層進行測試。

Model層的單元測試

  1. 快速建立測試檔案

PaoRepo.kt為例,在PaoRepo單詞上按住alt+enter鍵即可快速建立對應的測試檔案

使用Kotlin構建MVVM應用程式—第六部分:單元測試

使用Kotlin構建MVVM應用程式—第六部分:單元測試

  1. 寫些什麼

首先觀察PaoRepo.kt

class PaoRepo @Inject constructor(private val remote: PaoService, private val local: PaoDao) {
	//獲取文章詳情
    fun getArticleDetail(id: Int) = local.getArticleById(id)
            .onErrorResumeNext {
                if (it is EmptyResultSetException) {
                    remote.getArticleById(id)
                            .doOnSuccess { local.insertArticle(it) }
                } else throw it
            }

}
複製程式碼

構成一個PaoRepo物件需要通過構造方法傳入一個PaoService和一個PaoDao物件。

由於我們只是測試邏輯,所以並不需要真實的去構造PaoServicePaoDao物件。這裡我們就需要用到Mockito來進行mock。

class PaoRepoTest {

    private val local = Mockito.mock(PaoDao::class.java)
    private val remote = Mockito.mock(PaoService::class.java)
    private val repo = PaoRepo(remote, local)
    
}
複製程式碼

當有了PaoRepo物件之後,我們開始對getArticleDetail方法的邏輯進行覆蓋,而單元測試其實就是將這些測試用例翻譯為計算機所知道的語句。

舉幾個例子:

  • local.getArticleById(id)方法有資料返回的時候

    就不會丟擲EmptyResultSetException異常,remote.getArticleById(id)local.insertArticle(it) 都不會被呼叫

 	//mock返回資料
    private val article = mock(Article::class.java)
    //任意整數
    private val articleId = ArgumentMatchers.anyInt()

    @Test fun `local getArticleById`(){
        //當有資料返回的時候
        whenever(local.getArticleById(articleId)).thenReturn(Single.just(article))
        //進行方法模擬呼叫
        repo.getArticleDetail(articleId).test()
        //驗證local.getArticleById(articleId)被呼叫
        verify(local).getArticleById(articleId)
        //驗證remote.getArticleById(articleId)方法不被呼叫
        verify(remote, never()).getArticleById(articleId)
        //驗證local.insertArticle()方法不被呼叫
        verify(local, never()).insertArticle(article)
    }
複製程式碼
  • 當本地資料庫沒找到資料,local.getArticleById(1)方法則會返回EmptyResultSetException異常,

    就會進入onErrorResumeNext程式碼塊,由於是EmptyResultSetException異常,所以remote.getArticleById(id)local.insertArticle(it) 都會被呼叫

@Test
fun `remote getArticleById`() {
    //當本地不能查到資料會丟擲EmptyResultSetException
    whenever(local.getArticleById(articleId)).thenReturn(Single.error<Article>(EmptyResultSetException("本地沒有資料")))
    //當呼叫remote.getArticleById(articleId)時返回資料
    whenever(remote.getArticleById(articleId)).thenReturn(Single.just(article))
    //進行方法模擬呼叫
    repo.getArticleDetail(articleId).test()
    //驗證local.getArticleById(articleId)方法被呼叫
    verify(local).getArticleById(articleId)
    //驗證remote.getArticleById(articleId)方法被呼叫
    verify(remote).getArticleById(articleId)
    //驗證local.insertArticle(article)方法被呼叫
    verify(local).insertArticle(article)
}
複製程式碼

執行以上單元測試

使用Kotlin構建MVVM應用程式—第六部分:單元測試

pass則代表邏輯已經成功覆蓋,而且可以看到一共只需要315ms,如果要真機測試的話,光編譯的時間就可能幾分鐘甚至十幾分鍾。

ViewModel層的單元測試

首先看看PaoViewModel.kt

class PaoViewModel @Inject constructor(private val repo: PaoRepo) {

    //////////////////data//////////////
    val loading = ObservableBoolean(false)
    val content = ObservableField<String>()
    val title = ObservableField<String>()
    val error = ObservableField<Throwable>()

    //////////////////binding//////////////
    fun loadArticle(): Single<Article> =
            repo.getArticleDetail(8773)
                    .subscribeOn(Schedulers.io())
                    .delay(1000,TimeUnit.MILLISECONDS)
                    .observeOn(AndroidSchedulers.mainThread())
                    .doOnSuccess {
                        renderDetail(it)
                    }
                    .doOnSubscribe { startLoad() }
                    .doAfterTerminate { stopLoad() }


    fun renderDetail(detail: Article) {
            title.set(detail.title)
            detail.content?.let {
                val articleContent = Utils.processImgSrc(it)
                content.set(articleContent)
            }
    }


    private fun startLoad() = loading.set(true)
    private fun stopLoad() = loading.set(false)
}
複製程式碼

通過上文的方法建立出對應的測試檔案和資料mock之後,我們來覆蓋loadArticle()方法的邏輯。

ps:由於需要驗證viewModel的方法是否有呼叫,我們需要使用Mockito.spy方法讓viewModel物件可被偵察

class PaoViewModelTest {

    private val remote= mock(PaoService::class.java)

    private val local = mock(PaoDao::class.java)

    private val repo = PaoRepo(remote, local)

    private val viewModel = spy(PaoViewModel(repo))
}
複製程式碼
  • repo.getArticleDetail()方法請求成功之後,renderDetail()方法會被呼叫,當訂閱開始時,loading的值為true,當訂閱結束時,loading的值為false。

將上面?的邏輯翻譯為測試程式碼之後,如下所示:

 private val article = mock(Article::class.java)
@Before  //會在測試方法測試之前進行呼叫
fun setUp() {

    //讓local.getArticleById()方法返回可觀測的article
    whenever(local.getArticleById(anyInt())).thenReturn( Single.just(article))
}

@Test
fun `loadArticle success`() {
    
    //呼叫方法,進行驗證
    viewModel.loadArticle().test()
    //驗證載入中時loading為true
    Assert.assertThat(viewModel.loading.get(),`is`(true))
    //驗證renderDetail()方法有呼叫
    verify(viewModel).renderDetail(article)
    //驗證載入完成時loading為false
    Assert.assertThat(viewModel.loading.get(),`is`(false))

}
複製程式碼

執行以上測試程式碼,會報RuntimeException.

使用Kotlin構建MVVM應用程式—第六部分:單元測試

看說明,應該是非同步的時候會有問題。對於這樣的情況,我們可以使用RxJavaPluginsRxAndroidPlugins這些類來覆蓋預設的scheduler

為了便於複用到其它的測試類檔案裡,我們實現一個TestRule進行統一處理。

/**
 * 頁面描述:ImmediateSchedulerRule
 * 使用RxJavaPlugins和RxAndroidPlugins這些類用TestScheduler覆蓋預設的scheduler。
 * TestScheduler可以幫助我們控制時間來測試某些功能
 * Created by ditclear on 2018/11/19.
 */
class ImmediateSchedulerRule private constructor(): TestRule {

    private object Holder { val INSTANCE = ImmediateSchedulerRule () }

    companion object {
        val instance: ImmediateSchedulerRule by lazy { Holder.INSTANCE }
    }

    private val immediate = TestScheduler()

    override fun apply(base: Statement, d: Description): Statement {
        return object : Statement() {
            @Throws(Throwable::class)
            override fun evaluate() {
                RxJavaPlugins.setInitIoSchedulerHandler { immediate }
                RxJavaPlugins.setInitComputationSchedulerHandler { immediate }
                RxJavaPlugins.setInitNewThreadSchedulerHandler { immediate }
                RxJavaPlugins.setInitSingleSchedulerHandler { immediate }
                RxAndroidPlugins.setInitMainThreadSchedulerHandler { immediate }

                try {
                    base.evaluate()
                } finally {
                    RxJavaPlugins.reset()
                    RxAndroidPlugins.reset()
                }
            }
        }
    }
    //將時間提前xx ms
    fun advanceTimeBy(milliseconds:Long){
        immediate.advanceTimeBy(milliseconds,TimeUnit.MILLISECONDS)

    }
    //將時間提前到xx ms
    fun advanceTimeTo(milliseconds:Long){
        immediate.advanceTimeTo(milliseconds,TimeUnit.MILLISECONDS)

    }
}
複製程式碼

有一點需要注意的是 我們需要將其設定為單例模式,否則會出現只有第一次測試才能成功,其它測試都失敗的情況。

否則要解決這個問題,可能需要曲線救國,繞下彎路,通過注入TestScheduler的方法來解決。具體問題可以檢視筆者以前的譯文使用Kotlin和RxJava測試MVP架構的完整示例 - 第2部分

再執行這一單元測試,結果如下:

使用Kotlin構建MVVM應用程式—第六部分:單元測試

意思是renderDetail()方法未被呼叫。

這是正常的。仔細看程式碼就會發現這裡有一個1000ms的延遲,而測試程式碼會順序執行,不會像實際情況那樣等待1000ms的延遲再去驗證。

遇到這樣的情況,我們就需要使用TestScheduleradvanceTimeBy()advanceTimeTo()方法來控制時間。

更改後的測試程式碼如下所示:

@get:Rule
val testScheduler = ImmediateSchedulerRule.instance
@Before
fun setUp() {
    //讓local.getArticleById()方法正常返回資料
    whenever(local.getArticleById(anyInt())).thenReturn( Single.just(article))
}
@Test
fun `loadArticle success`() {

    //呼叫方法,進行驗證
    viewModel.loadArticle().test()
    //將時間提前500ms
    testScheduler.advanceTimeBy(500)
    //驗證載入中時loading為true
    Assert.assertThat(viewModel.loading.get(),`is`(true))
    //由於有async(1000).1000毫秒的延遲,這裡需要加快時間
    testScheduler.advanceTimeBy(500)
    //驗證renderDetail()方法有呼叫
    verify(viewModel).renderDetail(article)
    //驗證載入完成時loading為false
    Assert.assertThat(viewModel.loading.get(),`is`(false))

}
複製程式碼

再執行一次測試程式碼:

使用Kotlin構建MVVM應用程式—第六部分:單元測試

編寫方便進行單元測試的程式碼

通過以上的例子,我們瞭解了基礎的單元測試該這麼去寫。

那怎麼去方便寫出這樣的測試程式碼呢?

說到方便單元測試,這是很多人在寫MVP和MVVM程式碼和貶低MVC時,基本都會說到的事情。

因為MVC的程式碼邏輯基本都糅合在Activity中,Activty就是MVC的Controller,如果將Activity中邏輯控制的程式碼提出到一個Controller之中,那也會出現和MVP/MVVM一樣的三層結構。

但為什麼MVC就不方便進行單元測試呢?

最大的原因就是Controller中最好都要是純Java或者純Kotlin程式碼,不要匯入有任何包含android包下的類,比如Context,View等

這些都不方便進行mock,所以MVP結構就通過各種介面將邏輯程式碼和View層程式碼進行隔離,而在MVP的基礎上通過資料繫結便成了MVVM。

第二個要點就是儘量遵從物件導向六大原則中的單一職責原則,通過依賴注入來構造物件。

相信許多android開發者在開始編寫android程式的初期,或多或少都寫出過以下的程式碼。

class PaoViewModel  {

    //////////////////data//////////////
    val loading = ObservableBoolean(false)
    val content = ObservableField<String>()
    val title = ObservableField<String>()
    val error = ObservableField<Throwable>()

    //////////////////binding//////////////
    fun loadArticle(): Single<Article> =
            Repo().getArticleDetail(8773)//不通過注入直接new
                    .subscribeOn(Schedulers.io())
                    .delay(1000,TimeUnit.MILLISECONDS)
                    .observeOn(AndroidSchedulers.mainThread())
                    .doOnSuccess {
                        renderDetail(it)
                    }
                    .doOnSubscribe { startLoad() }
                    .doAfterTerminate { stopLoad() }
    
    fun otherAction() = Repo().otherAction()//不通過注入直接,再new一個
    
}
複製程式碼

如果程式碼寫成這樣,試問如何通過Mockito來mock相應的行為呢?

而且這樣的程式碼假如需要向Repo的構造方法中新增引數,那麼修改量將是巨大的。

因此,儘量通過注入的方式進行引數注入而且也更符合開閉原則。

單元測試的旁門左道

在日常開發android的過程中,我們要驗證自己的邏輯對不對,總是需要改動程式碼,然後執行程式,中間要build幾分鐘,然後如果結果不對,則又要反覆這個過程。反反覆覆,一天就浪費過去了。

也許你只是想驗證一下一個方法對不對?加一個0或者移動一下小數點?但是都會無謂的浪費時間。

這時候如果你知道單元測試的話,只需要在測試方法中驗證一下輸出就好了。

比如:BigDecimal(0.00)和BigDecimal(0.000)比較,是大?小?還是等於?

就可以編寫一個單元測試,看看輸出結果

class ExampleUnitTest{

    // if {@code this > val}, {@code -1} if {@code this < val},
    //         {@code 0} if {@code this == val}.
    @Test fun `test which is bigger `(){
        print(BigDecimal(0.00).compareTo(BigDecimal(0.000)))
    }
}
複製程式碼

執行test which is bigger

使用Kotlin構建MVVM應用程式—第六部分:單元測試

再一個好處就是方便你進行練習,比如Rxjava的操作符

@Test fun `practice rxJava operator`(){
    Single.just(2)
            .doOnSuccess {
                println("----------doOnSuccess--------")
            }
            .map { 3 }
            .doOnSubscribe {
                println("----------doOnSubscribe--------")
            }
            .doAfterTerminate {
                println("----------doAfterTerminate--------")
            }
            .subscribe({
                print("----------onSuccess --- $it-----")
            },{
                println(it.message)
            })
    
}
複製程式碼

結果:

使用Kotlin構建MVVM應用程式—第六部分:單元測試

是不是想起了剛開始學習Java的時光。。

結尾

到此,我們對Model層和ViewModel層的單元測試就已經結束了。

由於篇幅原因,只進行了部分邏輯的覆蓋,Model層的驗證資料的輸入輸出正確與否並沒有進行測試,如果想了解如何進行這方面的單元測試可以檢視GoogleSamples/android-architecture-componentsGithubBrowserSample裡的單元測試程式碼。

本文的重點不在於怎麼進行單元測試,關於這一點,完全可以檢視關於安卓單元測試,你需要知道的一切這篇文章。只希望能讓跟隨本系列學習MVVM結構的開發者瞭解單元測試,並且能編寫出利於進行單元測試的程式碼。

所有的程式碼都可以在github.com/ditclear/MV… 中找到。

更多示例程式碼github.com/ditclear/Pa…

參考資料

關於安卓單元測試,你需要知道的一切

【譯】使用Kotlin和RxJava測試MVP架構的完整示例 - 第2部分

android-architecture-components

相關文章