簡書地址:www.jianshu.com/p/2ce583fc3…
目錄
- 使用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。
將上面?的邏輯翻譯為測試程式碼之後,如下所示:
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裡的單元測試程式碼。
本文的重點不在於怎麼進行單元測試,關於這一點,完全可以檢視關於安卓單元測試,你需要知道的一切這篇文章。只希望能讓跟隨本系列學習MVVM結構的開發者瞭解單元測試,並且能編寫出利於進行單元測試的程式碼。
所有的程式碼都可以在github.com/ditclear/MV… 中找到。
更多示例程式碼github.com/ditclear/Pa…