簡書地址:www.jianshu.com/p/2ce583fc3…java
這裏是使用Kotlin構建MVVM應用程序—第六部分:單元測試。android
**單元測試 **這個詞對於大多數android程序員來講應該是不陌生的,或者據說過,或者在某篇博客上見過,可是真正去實踐過的可謂少之又少。git
沒實踐的緣由多是:程序員
總有理由安慰本身。那爲何我將其做爲本系列的第六部分而非是提升篇裏的內容呢?github
在我看來,瞭解單元測試應該是每一名開發人員應該具有的素質,只有知道怎樣的代碼是適合進行單元測試的,才能寫出高質量的代碼。數據庫
能夠簡單的認爲經過了單元測試的代碼纔是高質量的代碼。架構
所以,我將其做爲本系列的第六部分,但願學習本系列的android開發人員都能擺脫碼農向工程師邁進,不求掌握,但求瞭解。app
關於爲何要進行單元測試?還能夠查看小創的文章爲何要作單元測試異步
若是你想學習如何作單元測試,能夠查看關於安卓單元測試,你須要知道的一切async
首先,加入依賴
//幫助進行mock
testImplementation 'org.mockito:mockito-core:2.15.0'
//單元測試
testImplementation 'junit:junit:4.12'
複製代碼
其次,知道要測試些什麼?
寫點有價值的測試用例這篇文章裏對這個問題進行了解答
對於測試用例的設計,不能離開架構層面和業務層面
那什麼是沒價值的測試用例,有如下幾種:
本文描述的單元測試主要是Model層和ViewModel層進行測試。
以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,若是要真機測試的話,光編譯的時間就可能幾分鐘甚至十幾分鍾。
首先看看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…