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

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

目錄

寫在前面

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

**單元測試 **這個詞對於大多數android程序員來講應該是不陌生的,或者據說過,或者在某篇博客上見過,可是真正去實踐過的可謂少之又少。git

沒實踐的緣由多是:程序員

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

總有理由安慰本身。那爲何我將其做爲本系列的第六部分而非是提升篇裏的內容呢?github

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

能夠簡單的認爲經過了單元測試的代碼纔是高質量的代碼。架構

所以,我將其做爲本系列的第六部分,但願學習本系列的android開發人員都能擺脫碼農向工程師邁進,不求掌握,但求瞭解app

關於爲何要進行單元測試?還能夠查看小創的文章爲何要作單元測試異步

若是你想學習如何作單元測試,能夠查看關於安卓單元測試,你須要知道的一切async

在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鍵便可快速建立對應的測試文件

  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)
}
複製代碼

運行以上單元測試

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.

看說明,應該是異步的時候會有問題。對於這樣的狀況,咱們可使用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部分

再運行這一單元測試,結果以下:

意思是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))

}
複製代碼

再運行一次測試代碼:

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

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

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

說到方便單元測試,這是不少人在寫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

再一個好處就是方便你進行練習,好比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)
            })
    
}
複製代碼

結果:

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

結尾

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

因爲篇幅緣由,只進行了部分邏輯的覆蓋,Model層的驗證數據的輸入輸出正確與否並無進行測試,若是想了解如何進行這方面的單元測試能夠查看GoogleSamples/android-architecture-componentsGithubBrowserSample裏的單元測試代碼。

本文的重點不在於怎麼進行單元測試,關於這一點,徹底能夠查看關於安卓單元測試,你須要知道的一切這篇文章。只但願能讓跟隨本系列學習MVVM結構的開發者瞭解單元測試,而且能編寫出利於進行單元測試的代碼。

全部的代碼均可以在github.com/ditclear/MV… 中找到。

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

參考資料

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

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

android-architecture-components

相關文章
相關標籤/搜索