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

目錄
- ofollow,noindex">使用Kotlin構建MVVM應用程式—總覽篇
- 使用Kotlin構建MVVM應用程式—第一部分:入門篇
- 使用Kotlin構建MVVM應用程式—第二部分:Retrofit及RxJava
- 使用Kotlin構建MVVM應用程式—第三部分:Room
- 使用Kotlin構建MVVM應用程式—第四部分:依賴注入Dagger2
- 使用Kotlin構建MVVM應用程式—第五部分:LiveData
- 使用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測試是業務層面的測試。
那什麼是 沒價值的測試用例 ,有以下幾種:
- 對成熟的工具類進行測試
- 對簡單的方法進行測試(比如get、set方法)
- MVP(VM)各層重複測試,比如P(VM)層去斷言輸入輸出的正確性
本文描述的單元測試主要是Model層和ViewModel層進行測試。
Model層的單元測試
- 快速建立測試檔案
以 PaoRepo.kt
為例,在 PaoRepo
單詞上按住 alt+enter
鍵即可快速建立對應的測試檔案


- 寫些什麼
首先觀察 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
物件。
由於我們只是測試邏輯,所以並不需要真實的去構造 PaoService
和 PaoDao
物件。這裡我們就需要用到 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。
將上面:point_up_2:的邏輯翻譯為測試程式碼之後,如下所示:
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
.

看說明,應該是非同步的時候會有問題。對於這樣的情況,我們可以使用 RxJavaPlugins
和 RxAndroidPlugins
這些類來覆蓋預設的 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的延遲再去驗證。
遇到這樣的情況,我們就需要使用 TestScheduler
的 advanceTimeBy()
和 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-components 的 GithubBrowserSample" rel="nofollow,noindex"> GithubBrowserSample 裡的單元測試程式碼。
本文的重點不在於怎麼進行單元測試,關於這一點,完全可以檢視 關於安卓單元測試,你需要知道的一切 這篇文章。只希望能讓跟隨本系列學習MVVM結構的開發者瞭解單元測試,並且能編寫出利於進行單元測試的程式碼。
所有的程式碼都可以在 github.com/ditclear/MV… 中找到。
更多示例程式碼 github.com/ditclear/Pa…