目录
- 使用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结构的开发者了解单元测试,并且能编写出利于进行单元测试的代码。
所有的代码都可以在https://github.com/ditclear/MVVM-Android 中找到。
更多示例代码https://github.com/ditclear/PaoNet