编写你的第一个 Android 单元测试

TL;DR: 本文主要面向单元测试新手,首先简单介绍了什么是单元测试,为何要写单元测试,讨论了一下 Android 项目中哪些代码适合作单元测试,并以一个简单例子演示了如何编写属于你的第一个 Android 单元测试(kotlin 代码)。html

什么是单元测试

单元测试是对程序的最小单元进行正确性检验的测试工做。程序单元是应用的最小可测试部件。一个单元多是单个程序、类、对象、方法等。 —— Wikipediajava

为何要作单元测试

没有测试的代码都是不可靠的。— 鲁迅android

  • 验证代码正确性,加强对代码的信心

最直接的好处。在没有单元测试的时候,一般咱们自测的方法就是跑一跑程序,简单构造一下主要的分支场景,若是经过了,就认为 OK 能够提交给 QA 同窗了。但实际上有些时候有些分支本身是没法测到或者很难构造出来条件的,这只能依靠 QA 同窗手工测试来覆盖,若是他们也没有测到,那只能老天保佑了。而经过单元测试咱们能够方便构造各类测试场景,对于经过测试的代码,咱们会更有信心git

  • 在不须要 QA 参与的状况下保持或改进产品质量

说白了就是能够放心的重构。QA 同窗老是谈重构而色变,咱们在重构遗留代码的时候也是提心吊胆,生怕改错了旧的逻辑,或者意外影响到别的模块。有了单元测试,咱们就能够更加大胆的进行重构,重构完只要跑一下单测验证是否经过就能够了(适合小范围的重构,大的重构可能就须要重写单元测试了)github

  • 加深对业务理解

在设计测试用例的过程当中,须要考虑到业务上的各类场景,有助于咱们跳出代码加深对业务的理解web

  • 帮你写出更好的代码

单元测试要求被测试的代码高内聚,低耦合,因此你在写业务代码的时候就要考虑到如何写测试,或者反过来,先写测试用例的话会让你可以写出来结构性更好的代码shell

单元测试有什么代价吗?固然也是有的,编写和维护测试用例须要花费必定的时间和精力,当项目进度压力比较大的时候,不少人是不肯意再花时间去写测试的。这就须要进行权衡,要么不写而后丧失前面说的各类好处,要么后面有时间再补上来,但也错过了写测试的最好时间。数据库

Android 单元测试

Android 项目默认会建立两个测试目录,分别为 src/test 和 src/androidTest 前者是单元测试目录,后者是依赖 Android 框架的 instrumentation 测试目录。声明测试也有区别,前者是 testImplementation 后者是 androidTestImplementation,咱们今天讨论的是前者,也叫 Local Unit Test,意思也就是说不依赖 Android 真机或者模拟器,能够直接在本地 JVM 上运行的单元测试。网络

Android 的单元测试与普通的 java 项目并无太大差别,首先须要关注的是如何分辨那些类或者方法须要测试。app

一个好的单元测试的一个重要特性就是运行速度要快,一般是毫秒级的,而依赖 Android 框架的代码都须要在模拟器上或者真机上运行(也不是绝对的),速度不可避免的会慢不少,因此咱们在作 Android 单元测试的时候会避免让被测试代码对 Android 框架有任何依赖。在这个条件下,通常适合进行单元测试的代码就是:

  1. MVP 结构中的 Presenter 或者 MVVM 结构中的 ViewModel
  2. Helper 或者 Utils 工具类
  3. 公共基础模块,好比网络库、数据库等

若是你的项目中代码与 Android 框架耦合比较高,那么可能就不得不先对目标代码进行重构,而后再编写测试代码。如何重构不在本文讨论范围,请自行探索。

编写第一个 Android 单元测试

SETUP

Android 单元测试主要使用是 JUnit 测试框架 + Mockito Mock 类库 + Mockito-kotlin 的扩展库,须要在 build.gradle 中声明测试依赖。后面的示例代码对应的依赖以下。

testImplementation 'junit:junit:4.12'
testImplementation 'org.mockito:mockito-core:2.19.0'
testImplementation 'com.nhaarman.mockitokotlin2:mockito-kotlin:2.1.0'
复制代码

具体每一个库是用来作什么的,后面根据具体的代码来讲明。

目标代码

这里以一个简单的 MVP 中 Presenter 的例子来讲明如何写单元测试。 如下测试代码来自于这里,是一个食谱搜索结果展现页面。

class SearchResultsPresenter(private val repository: RecipeRepository) :
    BasePresenter<SearchResultsPresenter.View>() {
  private var recipes: List<Recipe>? = null

  fun search(query: String) {
    view?.showLoading()

    repository.getRecipes(query, object : RecipeRepository.RepositoryCallback<List<Recipe>> {
      override fun onSuccess(recipes: List<Recipe>?) {
        this@SearchResultsPresenter.recipes = recipes
        if (recipes != null && recipes.isNotEmpty()) {
          view?.showRecipes(recipes)
        } else {
          view?.showEmptyRecipes()
        }
      }

      override fun onError() {
        view?.showError()
      }
    })
  }

  fun addFavorite(recipe: Recipe) {
    recipe.isFavorited = true

    repository.addFavorite(recipe)

    val recipeIndex = recipes?.indexOf(recipe)
    if (recipeIndex != null) {
      view?.refreshFavoriteStatus(recipeIndex)
    }
  }


  fun removeFavorite(recipe: Recipe) {
    repository.removeFavorite(recipe)
    recipe.isFavorited = false
    val recipeIndex = recipes?.indexOf(recipe)
    if (recipeIndex != null) {
      view?.refreshFavoriteStatus(recipeIndex)
    }
  }

  interface View {
    fun showLoading()
    fun showRecipes(recipes: List<Recipe>)
    fun showEmptyRecipes()
    fun showError()
    fun refreshFavoriteStatus(recipeIndex: Int)
  }
}
复制代码

简单分析一下代码。

首先这个 Presenter 类包含了一个内部类 View ,定义了 MVP 中 View 应该实现的一些方法,包括显示加载状态,显示食谱列表,显示空页面,显示错误页面,刷新最爱等接口方法。

它的构造函数接受了一个 RecipeRepository 对象,咱们来看一下 RecipeRepository 的定义。

interface RecipeRepository {
  fun addFavorite(item: Recipe)
  fun removeFavorite(item: Recipe)
  fun getFavoriteRecipes(): List<Recipe>
  fun getRecipes(query: String, callback: RepositoryCallback<List<Recipe>>)
}

interface RepositoryCallback<in T> {
  fun onSuccess(t: T?)
  fun onError()
}
复制代码

能够看到它是也是一个接口类,顾名思义它是一个 recipe 的数据仓库,定义了一系列的数据获取和更新接口,至于从哪里获取并不须要咱们不关心,能够是本地文件、数据库、网络等等。这也正是依赖翻转原则的体现。

这个 Presenter 又继承了 BasePresenter,这个类是一个抽象类,定义了两个方法,分别是 attachView() 和 detachView(),还有一个字段 view。

abstract class BasePresenter<V> {
  protected var view: V? = null

  fun attachView(view: V) {
    this.view = view
  }

  fun detachView() {
    this.view = null
  }
}
复制代码

回到 SearchResultsPresenter 自身,这个类有三个主要方法,第一个 search() 接受一个字符串,调用了 repository 的方法获取搜索结果,根据结果分别调用 View 的不一样方法;第二个 addFavorite(),它接受一个 recipe 对象,将其设置为最爱,并调用 repository 更新到数据仓库中,最后调用 view 方法刷新 UI;第三个方法 removeFavorite() ,它与上一个方法恰好相反。基类的方法不在咱们测试范围内,不用考虑。

这三个方法无疑就是咱们单元测试的目标了,继续看如何写测试代码。

建立测试类

首先定位到咱们要测试的类,使用快捷键 CMD + N (Generate),选中 Test,就会出来一个弹窗,引导咱们建立一个对应的测试类,类名一般是咱们要测试的类 + Test 后缀。要记得位置要放到 src/test 目录下哟(也能够手动定位到相应目录,建立一个新的文件,但会慢不少)。

编写测试代码

行为验证

首先添加以下代码

class SearchResultsPresenterTests {

  private lateinit var repository: RecipeRepository
  private lateinit var presenter: SearchResultsPresenter
  private lateinit var view: SearchResultsPresenter.View

  @Before
  fun setup() {
    repository = mock()
    view = mock()
    presenter = SearchResultsPresenter(repository)
    presenter.attachView(view)
  }
复制代码

解释一下,这里可能比较陌生的代码有两处:

  1. @Before 注解

这个注解是 Junit 测试框架的一部分,当前测试类中的每个测试用例都会先调用 @Before 注解的方法,因此能够用来作一些公共的 setup 的操做。具体在这里,咱们要测试的是 Presenter,因此就是建立好了一个 Presenter 实例,并配置了须要与 Presenter 交互的 View / Repository 等外部对象。与 Before 对应,还有一个 @After 注解,能够标注一个方法,用来在每一个用例执行完毕后作一些清理操做,若是不须要的话 ,也能够省略不写。

  1. mock() 方法

这个方法是 mockito-kotlin 库提供的,它是一个包装类库,背后又调用了 Mockito 类库,这个库能够用来伪造一些稳定的依赖类,避免不稳定的依赖形成咱们的单元测试结果不可预期。具体在这里,由于咱们测试的目标是 Presenter 类,与 Presenter 有交互关系的 View 和 Repo 都有抽象的接口,咱们不想测试具体的 View 和 Repo 类(一 View 依赖了 Android 框架,运行太慢,二 Repo 可能依赖了网络或者数据库或者文件,不够稳定),就可使用 mock() 方法来建立一个模拟的类(这里 mock() 是一个泛型方法,使用了 kotlin 的类型推断特性)。 Mock 出来的类能够用来检测对应的方法是否被调用,调用了多少次,调用的次序等等。

接下来添加第一个测试用例,咱们要验证一下调用 presenter 的 search() 方法后,View 的 showLoading() 方法会被调用到。

@Test
fun search_callsShowLoading() {
    presenter.search("eggs")
    verify(view).showLoading()
}
复制代码

首先固然是先调用 presenter 的 search 方法,而后咱们 调用了一个 verify 方法,它会接受一个 Mock 的对象,而后咱们就能够验证这个 Mock 对象的 showLoading() 方法被调用过了! 很简单有没有。在这个方法声明的左边,有一个运行按钮,点击就能够执行这个测试用例了(快捷键 Ctrl + Shift + R)。

咱们再来写一个比较复杂的测试用例,此次咱们要验证一下 search() 调用后,repo 的 getRecipes() 方法会调用到,当回调返回后,view 的 showRecipes() 方法会调用到。

@Test
fun search_succeed_callShowRecipes() {
    val recipe = Recipe("id", "title", "imageUrl", "sourceUrl", false)
    val recipes = listOf(recipe)
    doAnswer {
        val callback: RepositoryCallback<List<Recipe>> = it.getArgument(1)
        callback.onSuccess(recipes)
    }.whenever(repository).getRecipes(eq("eggs"), any())

    presenter.search("eggs")

    verify(repository).getRecipes(eq("eggs"), any())
    verify(view).showRecipes(eq(recipes))
}
复制代码

喔,这个方法代码量一下多了好多,但不要被吓到,其实都很好理解,首先咱们建立了 recipes 对象来做为 repo 的搜索的返回结果,这里咱们使用了一个新的方法,doAnswer{}.whenever().getRecipes(),也很好理解,就是当调用的到 Mock 对象的 getRecipes() 方法的时候作一些事情,在 doAnswer{} 方法体中,咱们拿到了回调的对象,并执行了 onSuccess() 回调,将咱们构造的搜索结果返回回去(这个过程就叫作 Stubbing,翻译过来就是插桩)。好了,到这里位置咱们已经构造好了测试的前提条件,下一步就是调用 presenter 的 search() 方法了。最后就是验证步骤了,也很好理解,不废话了。

前面还漏了两个方法 eq("eggs")any(),这两个方法返回都是 Matcher 对象,顾名思义就是用来校验参数是否与预期的符合,any() 是一个特殊的 Matcher,意思就是咱们不在意究竟是什么。须要注意的是,若是在方法调用时有一个参数使用了 Matcher,全部其余参数都必须也是 Matcher,这个不须要你记住,若是你写错了,运行时就会报相应的错误提示。

根据前面的例子,很容易就能够联想到还能够增长 search 失败的时候调用 view.showError(),以及 search 结果为空时,调用 view.showEmpty() 的测试用例,小菜一叠是否是?

前面写的这些测试用例都是验证被测试对象依赖的模块的某些方法能够被正确调用,因此能够归为一类叫作行为验证,也就是 Mockito 一般被用来作的事情。

状态验证

还有一类测试,叫作状态验证,一般使用 JUnit 库中的 Assert 函数,咱们也举一个例子。presenter 中有一个方法 addFavorite() 是将一个食谱添加为最爱,咱们来看看应该怎么写测试用例。

@Test
fun addFavorite_shouldUpdateRecipeStatus() {
    val recipe = Recipe("id", "title", "imageUrl", "sourceUrl", false)
    presenter.addFavorite(recipe)
    assertThat(recipe.isFavorited, `is`(true))
}
复制代码

仍是很简单,咱们构造了一个默认 favorited 属性为 false 的 recipe,而后调用 addFavorite() 方法,而后去验证 recipe 对象的 isFavorited 属性应该是 True . 这里验证的时候使用了 JUnit 库中的 assertThat() 方法,这个方法接收两个参数 ,第一个参数是验证的目标,第二个参数是一个 Matcher,由于 kotlin 中 is 是保留关键字,因此须要用 ` 进行转义。

类似的,也能够给 presenter 的 removeFavorite() 方法添加测试用例。

完整的测试类

好了,如今咱们能够给 Presenter 编写出一个完整的测试类了,看一下完整的代码。

class SearchResultsPresenterTests {

    private lateinit var repository: RecipeRepository
    private lateinit var presenter: SearchResultsPresenter
    private lateinit var view: SearchResultsPresenter.View

    @Before
    fun setup() {
        repository = mock()
        view = mock()
        presenter = SearchResultsPresenter(repository)
        presenter.attachView(view)
    }

    @Test
    fun search_callsShowLoading() {
        presenter.search("eggs")
        verify(view).showLoading()
    }

    @Test
    fun search_succeed_callShowRecipes() {
        val recipe = Recipe("id", "title", "imageUrl", "sourceUrl", false)
        val recipes = listOf(recipe)

        doAnswer {
            val callback: RepositoryCallback<List<Recipe>> = it.getArgument(1)
            callback.onSuccess(recipes)
        }.whenever(repository).getRecipes(eq("eggs"), any())

        presenter.search("eggs")

        verify(repository).getRecipes(eq("eggs"), any())
        verify(view).showRecipes(eq(recipes))
    }

    @Test
    fun search_error_callShowError() {
        doAnswer {
            val callback: RepositoryCallback<List<Recipe>> = it.getArgument(1)
            callback.onError()
        }.whenever(repository).getRecipes(eq("eggs"), any())

        presenter.search("eggs")

        verify(repository).getRecipes(eq("eggs"), any())
        verify(view).showError()
    }

    @Test
    fun addFavorite_shouldUpdateRecipeStatus() {
        val recipe = Recipe("id", "title", "imageUrl", "sourceUrl", false)
        presenter.addFavorite(recipe)
        assertThat(recipe.isFavorited, `is`(true))
    }

    @Test
    fun removeFavorite_shouldUpdateRecipeStatus() {
        val recipe = Recipe("id", "title", "imageUrl", "sourceUrl", true)
        presenter.removeFavorite(recipe)
        assertThat(recipe.isFavorited, `is`(false))
    }
}
复制代码

这已是一个相对完整的测试类了,在类声明的第一行的左边,一样有一个按钮点击后能够运行整个类内定义的全部测试用例,一样也有快捷键 Ctrl + Shift + R,光标放到类上运行便可。执行结果以下图。

如何判断测试的有效性

测试代码很快写完了,你可能会想,怎么才能衡量测试的有效性呢?这里就要引入另一个概念,叫测试覆盖率 (Code Coverage)。

测试覆盖率有着不一样的维度,好比类数量、方法数量、行数、条件分支等等,具体什么意思不在本文讨论范围,你们能够自行探索。Android Studio 内置了工具能够帮咱们进行统计。

回顾前面运行测试用例的时候,Android Studio 会帮咱们建立一个 Task,而在运行按钮右边,还有一个按钮叫 “Run [test-task-name] with coverage”,这个就是 IDE 内置的统计测试覆盖率的工具啦。

运行以后会自动打开一个 Coverage 结果页面窗口,点进去就可看到当前测试 task 对相关的被测试代码的一个覆盖状况。结果显示咱们的测试用例覆盖了 100% 的类和方法和 88% 的行数。

点击打开具体类还能看到每一行代码有没有执行到,很是好用,为咱们对测试用例的调整和完善提供了很好的参考价值。好比,观察这个 addFavorite() 方法,咱们的测试用例没有覆盖到 view 的 refresh 方法调用状况。

陷阱注意!

看起来测试覆盖率是一个很好的衡量单元测试覆盖程度甚至是测试质量的指标,实际上确实有不少开发者也所以会追求 100% 的测试覆盖率,但这样真的好吗?

“单元测试并非越多越好,而是越有效越好。” 这句话不是我说的,而是 Kent Beck 说的,他是 TDD 和 XP 的发起者,也是敏捷开发的奠定人。说这些的意思是提醒你们不要陷入教条主义,测试的目的是为了提高对代码质量,只要本身和团队有信心,就爱怎么测试就怎么测,怎么合适怎么测,没有必要必定要写测试,必定要测试先行。

延伸阅读

OK,到此为止,你应该已经学会了编写 Android 单元测试的基本知识,若是想进一步了解 Android 测试,建议能够阅读如下资料:

Happy unit testing!

参考

相关文章
相关标签/搜索