阅读 1162

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

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

什么是单元测试

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

为什么要做单元测试

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

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

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

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

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

  • 加深对业务理解

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

  • 帮你写出更好的代码

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

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

Android 单元测试

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

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

一个好的单元测试的一个重要特性就是运行速度要快,通常是毫秒级的,而依赖 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!

参考

@monkeyM

关注下面的标签,发现更多相似文章
评论