(已废弃) Kotlin 高级 Android 05.2:测试替身和依赖注入简介

1. 欢迎

简介

本测试系列 Codelab 的第二部分将重点介绍测试替身:何时在 Android 中使用它们,以及如何使用依赖注入、Service Locator 模式和库来实现它们。通过学习这些内容,您将了解如何编写

  • Repository(仓库)单元测试
  • Fragment 和 ViewModel 集成测试
  • Fragment 导航测试

前提条件知识

您应熟悉以下知识:

您将学到的内容

  • 如何规划测试策略
  • 如何创建和使用测试替身,特别是假对象(fakes)和模拟对象(mocks)
  • 如何在 Android 上使用手动依赖注入进行单元测试和集成测试
  • 如何应用 Service Locator 模式
  • 如何测试 Repository、Fragment、ViewModel 和 Navigation 组件

您将使用以下库和代码概念

您将完成的任务

  • 使用测试替身和依赖注入为 Repository 编写单元测试。
  • 使用测试替身和依赖注入为 ViewModel 编写单元测试。
  • 使用 Espresso UI 测试框架为 Fragment 及其 ViewModel 编写集成测试。
  • 使用 Mockito 和 Espresso 编写导航测试。

2. 应用概览

在本系列 Codelab 中,您将使用待办事项记事应用 (TO-DO Notes app)。此应用可让您记下要完成的任务,并将其显示在列表中。然后,您可以将其标记为已完成或未完成,对其进行过滤或删除。

e490df637e1bf10c.gif

此应用采用 Kotlin 编写,包含几个屏幕,使用 Jetpack 组件,并遵循 应用架构指南 中的架构。通过学习如何测试此应用,您将能够测试使用相同库和架构的应用。

下载代码

首先,请下载代码

或者,您可以克隆此代码的 Github 仓库

$ git clone https://github.com/google-developer-training/advanced-android-testing.git
$ cd android-testing
$ git checkout end_codelab_1

您可以在 android-testing Github 仓库 中浏览代码。

花一些时间熟悉代码,请按照以下说明操作。

第 1 步:运行示例应用

下载待办事项应用后,在 Android Studio 中打开并运行它。它应该可以编译。请按照以下说明探索应用:

  • 使用加号浮动操作按钮创建新任务。首先输入标题,然后输入有关该任务的其他信息。使用绿色对勾 FAB 保存。
  • 在任务列表中,点击刚刚完成的任务的标题,查看该任务的详细信息屏幕,以查看其余描述。
  • 在列表或详细信息屏幕中,选中该任务的复选框,将其状态设置为已完成
  • 返回任务屏幕,打开过滤菜单,按活动已完成状态过滤任务。
  • 打开抽屉式导航菜单,点击统计信息
  • 返回概览屏幕,然后从抽屉式导航菜单中选择清除已完成以删除所有状态为已完成的任务

483916536f10c42a.png

第 2 步:探索示例应用代码

此待办事项应用基于 Architecture Blueprints 测试和架构示例。此应用遵循 应用架构指南 中的架构。它使用带有 Fragment 的 ViewModel、一个 Repository 和 Room。如果您熟悉以下任何示例,则此应用的架构与这些示例类似

更重要的是您了解应用的总体架构,而不是对任何一层的逻辑有深入的理解。

f2e425a052f7caf7.png

以下是您将找到的软件包摘要

软件包:com.example.android.architecture.blueprints.todoapp

.addedittask

添加或编辑任务屏幕:用于添加或编辑任务的 UI 层代码。

.data

数据层:此层处理任务的数据。它包含数据库、网络和 Repository 代码。

.statistics

统计信息屏幕:统计信息屏幕的 UI 层代码。

.taskdetail

任务详情屏幕:单个任务的 UI 层代码。

.tasks

任务屏幕:所有任务列表的 UI 层代码。

.util

实用程序类:应用各部分使用的共享类,例如用于多个屏幕上的下拉刷新布局。

数据层 (.data)

此应用包含一个模拟网络层,位于 remote 软件包中;还有一个数据库层,位于 local 软件包中。为简单起见,在此项目中,网络层仅使用带有延迟的 HashMap 进行模拟,而不是发起真实的网络请求。

DefaultTasksRepository 协调或中介网络层和数据库层,并负责将数据返回给 UI 层。

UI 层 ( .addedittask, .statistics, .taskdetail, .tasks)

每个 UI 层软件包都包含一个 Fragment 和一个 ViewModel,以及 UI 所需的任何其他类(例如任务列表的适配器)。TaskActivity 是包含所有 Fragment 的 Activity。

导航

应用的导航由 Navigation 组件控制。它在 nav_graph.xml 文件中定义。导航在 ViewModel 中使用 Event 类触发;ViewModel 也决定要传递哪些参数。Fragment 观察 Event 并执行屏幕之间的实际导航。

3. 概念:测试策略

在本 Codelab 中,您将学习如何使用测试替身和依赖注入来测试 Repository、ViewModel 和 Fragment。在深入了解这些内容之前,了解指导您如何编写这些测试的推理非常重要。

本节涵盖了一些通用的测试最佳实践,以及它们如何应用于 Android。

测试金字塔

考虑测试策略时,有三个相关的测试方面

  • 范围—测试涉及多少代码?测试可以在单个方法上运行,也可以在整个应用上运行,或者在两者之间的某个范围运行。
  • 速度—测试运行速度有多快?测试速度可以从毫秒到几分钟不等。
  • 保真度—测试的“真实程度”如何?例如,如果您正在测试的代码的一部分需要发起网络请求,测试代码是实际发起这个网络请求,还是模拟结果?如果测试实际与网络交互,则意味着它具有更高的保真度。权衡在于,测试运行时间可能更长,如果网络中断可能会导致错误,或者使用成本较高。

这些方面之间存在固有的权衡。例如,速度和保真度是一个权衡——通常,测试越快,保真度越低,反之亦然。划分自动化测试的一种常用方法是将它们分为以下三类

  • 单元测试—这些是高度聚焦的测试,在单个类上运行,通常是该类中的单个方法。如果单元测试失败,您可以确切地知道代码中的问题在哪里。它们的保真度较低,因为在现实世界中,您的应用涉及的不仅仅是一个方法或类的执行。它们足够快,可以在您更改代码时每次都运行。它们通常是本地运行的测试(在 test 源集中)。示例:测试 ViewModel 和 Repository 中的单个方法。
  • 集成测试—这些测试测试多个类之间的交互,以确保它们协同使用时行为符合预期。构建集成测试的一种方法是让它们测试单个功能,例如保存任务的能力。它们测试的代码范围比单元测试更大,但仍经过优化以便快速运行,而不是具有完全保真度。根据具体情况,它们可以作为本地测试或仪器化测试运行。示例:测试单个 Fragment 和 ViewModel 对的所有功能。
  • 端到端测试 (E2e)—测试功能组合协同工作的情况。它们测试应用的大部分,紧密模拟真实使用情况,因此通常速度较慢。它们具有最高的保真度,并告诉您应用作为一个整体是否正常工作。总的来说,这些测试将是仪器化测试(在 androidTest 源集中)。示例:启动整个应用并一起测试几个功能。

这些测试的建议比例通常用金字塔表示,其中绝大多数测试是单元测试。

7017a2dd290e68aa.png

架构与测试

您在测试金字塔各个不同层级上测试应用的能力本质上与您的应用架构紧密相关。例如,一个架构“极其”糟糕的应用可能会将其所有逻辑放在一个方法中。您可能可以为此编写端到端测试,因为这些测试倾向于测试应用的大部分,但是编写单元测试或集成测试呢?所有代码都放在一个地方,很难只测试与单个单元或功能相关的代码。

更好的方法是将应用逻辑分解为多个方法和类,从而允许对每个部分进行隔离测试。架构是一种划分和组织代码的方式,它使单元测试和集成测试更加容易。您将要测试的待办事项应用遵循特定的架构

f2e425a052f7caf7.png

在本课中,您将看到如何以适当的隔离方式测试上述架构的各个部分

  1. 首先,您将对 repository 进行单元测试
  2. 然后,您将在 ViewModel 中使用测试替身,这对于对 ViewModel 进行单元测试集成测试是必需的。
  3. 接下来,您将学习如何为Fragment 及其 ViewModel 编写集成测试
  4. 最后,您将学习如何编写包含 Navigation 组件集成测试

端到端测试将在下一课中介绍。

4. 任务:创建假的 Data Source(数据源)

当您为一个类的某个部分(一个方法或一组少量方法)编写单元测试时,您的目标是仅测试该类中的代码

仅测试特定类或类中的代码可能很棘手。让我们来看一个示例。在 main 源集中打开 data.source.DefaultTasksRepository 类。这是应用的 Repository,您接下来将要为其编写单元测试。

您的目标是仅测试该类中的代码。然而,DefaultTasksRepository 依赖于其他类(例如 TasksLocalDataSourceTasksRemoteDataSource)来运行。换句话说,TasksLocalDataSourceTasksRemoteDataSourceDefaultTasksRepository依赖项

因此,DefaultTasksRepository 中的每个方法都会调用数据源类的方法,这些方法又会调用其他类的方法将信息保存到数据库或与网络通信。

518a4ea76fcb835a.png

例如,看看 DefaultTasksRepo 中的这个方法。

    suspend fun getTasks(forceUpdate: Boolean = false): Result<List<Task>> {
        if (forceUpdate) {
            try {
                updateTasksFromRemoteDataSource()
            } catch (ex: Exception) {
                return Result.Error(ex)
            }
        }
        return tasksLocalDataSource.getTasks()
    }

getTasks 是您可能对 Repository 发出的最“基本”的调用之一。此方法包括从 SQLite 数据库读取数据和进行网络调用(对 updateTasksFromRemoteDataSource 的调用)。这涉及的代码远不止 仅仅 Repository 代码。

以下是测试 Repository 困难的一些更具体的原因

  • 即使是针对此 Repository 的最简单的测试,您也需要考虑创建和管理数据库。这会引发“这应该是本地测试还是仪器化测试?”以及是否应该使用 AndroidX Test 来获取模拟的 Android 环境等问题。
  • 代码的某些部分,例如网络代码,可能需要很长时间才能运行,或者偶尔甚至会失败,从而产生长时间运行、不稳定的测试。
  • 您的测试可能会失去诊断导致测试失败的代码的能力。您的测试可能会开始测试非 Repository 代码,因此,例如,您假定的“Repository”单元测试可能会因为某些依赖代码中的问题而失败,例如数据库代码。

测试替身

解决方案是,当您测试 Repository 时,不要使用真实的网络或数据库代码,而是使用测试替身。测试替身是专门为测试而制作的类版本。它旨在在测试中替换类的真实版本。这类似于特技替身演员专门从事特技表演,并在危险动作中替换真实演员。

以下是一些类型的测试替身

Fake(假对象)

一种测试替身,它具有该类的“工作”实现,但其实现方式使其适合测试,但不适合生产环境。

Mock(模拟对象)

一种测试替身,它跟踪其哪些方法被调用。然后根据其方法是否被正确调用来决定测试通过或失败。

Stub(桩对象)

一种测试替身,不包含任何逻辑,仅返回您编程使其返回的内容。例如,StubTaskRepository 可以编程为从 getTasks 返回特定的任务组合。

Dummy(哑对象)

一种测试替身,它被传递但未被使用,例如当您只需要将其作为参数提供时。如果您有一个 NoOpTaskRepository,它将只实现 TaskRepository,并且在其任何方法中都没有代码。

Spy(间谍对象)

一种测试替身,它还跟踪一些额外信息;例如,如果您创建了一个 SpyTaskRepository,它可能会跟踪 addTask 方法被调用的次数。

有关测试替身的更多信息,请查阅 Testing on the Toilet: Know Your Test Doubles

Android 中最常用的测试替身是 Fake(假对象)Mock(模拟对象)

在此任务中,您将创建一个 FakeDataSource 测试替身,用于对 DefaultTasksRepository 进行单元测试,使其与实际数据源解耦。

第 1 步:创建 FakeDataSource 类

在此步骤中,您将创建一个名为 FakeDataSouce 的类,它将作为 LocalDataSourceRemoteDataSource 的测试替身。

  1. test 源集中,右键点击并选择 New -> Package

efdc92ba8079ed1.png

  1. 创建一个 data 软件包,并在其内部创建一个 source 软件包。
  2. data/source 软件包中创建一个名为 FakeDataSource 的新类。

46428a328ea88457.png

第 2 步:实现 TasksDataSource 接口

为了能够将新类 FakeDataSource 用作测试替身,它必须能够替换其他数据源。这些数据源是 TasksLocalDataSourceTasksRemoteDataSource

688533d909b2330b.png

  1. 请注意,这两个数据源都实现了 TasksDataSource 接口。
class TasksLocalDataSource internal constructor(
    private val tasksDao: TasksDao,
    private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) : TasksDataSource { ... }

object TasksRemoteDataSource : TasksDataSource { ... }
  1. 使 FakeDataSource 实现 TasksDataSource
class FakeDataSource : TasksDataSource {

}

Android Studio 会提示您尚未实现 TasksDataSource 所需的方法。

  1. 使用快速修复菜单并选择 Implement members

890b25398497ec8d.png

  1. 选择所有方法,然后按 OK

61433018aef0bb29.png

第 3 步:在 FakeDataSource 中实现 getTasks 方法

FakeDataSource 是一种特定类型的测试替身,称为 fake(假对象)。假对象是一种测试替身,它具有类的“工作”实现,但其实现方式使其适合测试,但不适合生产环境。“工作”实现意味着该类将根据输入产生逼真的输出。

例如,您的假数据源不会连接到网络或将任何内容保存到数据库中——相反,它只会使用内存中的列表。这会“按您期望的方式工作”,即获取或保存任务的方法将返回预期结果,但您永远无法在生产环境中使用此实现,因为它未保存到服务器或数据库中。

一个 FakeDataSource

  • 允许您测试 DefaultTasksRepository 中的代码,而无需依赖真实的数据库或网络。
  • 为测试提供一个“足够真实”的实现。
  1. 更改 FakeDataSource 构造函数,使其创建一个名为 tasksvar,类型为 MutableList<Task>?,默认值为一个空的 MutableList。
class FakeDataSource(var tasks: MutableList<Task>? = mutableListOf()) : TasksDataSource { // Rest of class }

这是模拟数据库或服务器响应的任务列表。目前,目标是测试 repository 的 getTasks 方法。此方法调用数据源的 getTasksdeleteAllTaskssaveTask 方法。

编写这些方法的假版本

  1. 编写 getTasks:如果 tasks 不是 null,则返回 Success 结果。如果 tasksnull,则返回 Error 结果。
  2. 编写 deleteAllTasks:清除可变任务列表。
  3. 编写 saveTask:将任务添加到列表中。

FakeDataSource 实现的这些方法如下所示。

override suspend fun getTasks(): Result<List<Task>> {
    tasks?.let { return Success(ArrayList(it)) }
    return Error(
        Exception("Tasks not found")
    )
}


override suspend fun deleteAllTasks() {
    tasks?.clear()
}

override suspend fun saveTask(task: Task) {
    tasks?.add(task)
}

如果需要,导入语句如下

import com.example.android.architecture.blueprints.todoapp.data.Result
import com.example.android.architecture.blueprints.todoapp.data.Result.Error
import com.example.android.architecture.blueprints.todoapp.data.Result.Success
import com.example.android.architecture.blueprints.todoapp.data.Task

这与实际的本地和远程数据源的工作方式类似。

5. 任务:使用依赖注入编写测试

在此步骤中,您将使用一种称为手动依赖注入的技术,以便可以使用刚刚创建的假测试替身。

主要问题是您有一个 FakeDataSource,但不清楚如何在测试中使用它。它需要在测试中替换 TasksRemoteDataSourceTasksLocalDataSourceTasksRemoteDataSourceTasksLocalDataSource 都是 DefaultTasksRepository 的依赖项,这意味着 DefaultTasksRepositories 需要或“依赖”这些类才能运行。

目前,依赖项是在 DefaultTasksRepositoryinit 方法内部构建的。

DefaultTasksRepository.kt

class DefaultTasksRepository private constructor(application: Application) {

    private val tasksRemoteDataSource: TasksDataSource
    private val tasksLocalDataSource: TasksDataSource

   // Some other code

    init {
        val database = Room.databaseBuilder(application.applicationContext,
            ToDoDatabase::class.java, "Tasks.db")
            .build()

        tasksRemoteDataSource = TasksRemoteDataSource
        tasksLocalDataSource = TasksLocalDataSource(database.taskDao())
    }
    // Rest of class
}

由于您在 DefaultTasksRepository 内部创建和分配 taskLocalDataSourcetasksRemoteDataSource,它们基本上是硬编码的。无法替换您的测试替身。

您要做的是向类提供这些数据源,而不是硬编码它们。提供依赖项称为依赖注入。提供依赖项有不同的方法,因此有不同类型的依赖注入。

构造函数依赖注入允许您通过将其传递到构造函数来替换测试替身。

无注入

注入

第 1 步:在 DefaultTasksRepository 中使用构造函数依赖注入

  1. DefaultTasksRepository 的构造函数从接受 Application 改为同时接受两个数据源和协程调度程序。

DefaultTasksRepository.kt

// REPLACE
class DefaultTasksRepository private constructor(application: Application) { // Rest of class }

// WITH

class DefaultTasksRepository(
    private val tasksRemoteDataSource: TasksDataSource,
    private val tasksLocalDataSource: TasksDataSource,
    private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO) { // Rest of class }
  1. 由于您传递了依赖项,因此请移除 init 方法。您不再需要创建依赖项。
  2. 同时删除旧的实例变量。您正在构造函数中定义它们

DefaultTasksRepository.kt

// Delete these old variables
private val tasksRemoteDataSource: TasksDataSource
private val tasksLocalDataSource: TasksDataSource
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
  1. 最后,更新 getRepository 方法以使用新的构造函数

DefaultTasksRepository.kt

    companion object {
        @Volatile
        private var INSTANCE: DefaultTasksRepository? = null

        fun getRepository(app: Application): DefaultTasksRepository {
            return INSTANCE ?: synchronized(this) {
                val database = Room.databaseBuilder(app,
                    ToDoDatabase::class.java, "Tasks.db")
                    .build()
                DefaultTasksRepository(TasksRemoteDataSource, TasksLocalDataSource(database.taskDao())).also {
                    INSTANCE = it
                }
            }
        }
    }

您现在正在使用构造函数依赖注入!

第 2 步:在测试中使用您的 FakeDataSource

现在您的代码正在使用构造函数依赖注入,您可以使用假数据源来测试您的 DefaultTasksRepository

  1. 右键点击 DefaultTasksRepository 类名,然后选择 Generate,再选择 Test。
  2. 按照提示在 test 源集中创建 DefaultTasksRepositoryTest
  3. 在新的 DefaultTasksRepositoryTest 类顶部,添加以下成员变量以表示假数据源中的数据。

DefaultTasksRepositoryTest.kt

    private val task1 = Task("Title1", "Description1")
    private val task2 = Task("Title2", "Description2")
    private val task3 = Task("Title3", "Description3")
    private val remoteTasks = listOf(task1, task2).sortedBy { it.id }
    private val localTasks = listOf(task3).sortedBy { it.id }
    private val newTasks = listOf(task3).sortedBy { it.id }
  1. 创建三个变量,两个 FakeDataSource 成员变量(Repository 的每个数据源各一个)和一个要测试的 DefaultTasksRepository 变量。

DefaultTasksRepositoryTest.kt

    private lateinit var tasksRemoteDataSource: FakeDataSource
    private lateinit var tasksLocalDataSource: FakeDataSource

    // Class under test
    private lateinit var tasksRepository: DefaultTasksRepository

创建一个方法来设置和初始化可测试的 DefaultTasksRepository。此 DefaultTasksRepository 将使用您的测试替身 FakeDataSource

  1. 创建一个名为 createRepository 的方法,并使用 @Before 对其进行注解。
  2. 实例化您的假数据源,使用 remoteTaskslocalTasks 列表。
  3. 实例化您的 tasksRepository,使用您刚刚创建的两个假数据源和 Dispatchers.Unconfined

最终的方法代码应如下所示。

DefaultTasksRepositoryTest.kt

    @Before
    fun createRepository() {
        tasksRemoteDataSource = FakeDataSource(remoteTasks.toMutableList())
        tasksLocalDataSource = FakeDataSource(localTasks.toMutableList())
        // Get a reference to the class under test
        tasksRepository = DefaultTasksRepository(
            // TODO Dispatchers.Unconfined should be replaced with Dispatchers.Main
            //  this requires understanding more about coroutines + testing
            //  so we will keep this as Unconfined for now.
            tasksRemoteDataSource, tasksLocalDataSource, Dispatchers.Unconfined
        )
    }

第 3 步:编写 DefaultTasksRepository getTasks() 测试

是时候编写 DefaultTasksRepository 测试了!

  1. 为 Repository 的 getTasks 方法编写一个测试。检查当您调用 getTasks 并传入 true(表示应从远程数据源重新加载)时,它是否返回远程数据源的数据(而不是本地数据源)。

DefaultTasksRepositoryTest.kt

@Test
    fun getTasks_requestsAllTasksFromRemoteDataSource(){
        // When tasks are requested from the tasks repository
        val tasks = tasksRepository.getTasks(true) as Success

        // Then tasks are loaded from the remote data source
        assertThat(tasks.data, IsEqual(remoteTasks))
    }

调用时会收到错误

getTasks

bb35045989edbd3f.png

第 4 步:添加 runBlockingTest

协程错误是预期结果,因为 getTasks 是一个 suspend 函数,您需要启动一个协程来调用它。为此,您需要一个协程作用域。要解决此错误,您需要在 gradle 中添加一些依赖项,用于处理在测试中启动协程。

  1. 使用 testImplementation 将测试协程所需的依赖项添加到测试源集中。

app/build.gradle

testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"

不要忘记同步!

kotlinx-coroutines-test 是协程测试库,专门用于测试协程。要运行测试,请使用函数 runBlockingTest。这是协程测试库提供的一个函数。它接受一个代码块,然后在特殊的协程上下文中运行此代码块,该上下文是同步且立即运行的,这意味着操作将按确定性顺序发生。这实质上使您的协程像非协程一样运行,因此它适用于测试代码。

在测试类中调用 suspend 函数时使用 runBlockingTest。您将在本系列的下一个 Codelab 中了解有关 runBlockingTest 如何工作以及如何测试协程的更多信息。

  1. 在类上方添加 @ExperimentalCoroutinesApi 注解。这表示您知道在该类中使用了实验性协程 API (runBlockingTest)。如果不添加此注解,您将收到警告。
  2. 回到 DefaultTasksRepositoryTest 中,添加 runBlockingTest,使其将您的整个测试作为一个代码“块”接收

最终的测试代码如下所示。

DefaultTasksRepositoryTest.kt

import com.example.android.architecture.blueprints.todoapp.data.Result.Success
import com.example.android.architecture.blueprints.todoapp.data.Task
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runBlockingTest
import org.hamcrest.core.IsEqual
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test


@ExperimentalCoroutinesApi
class DefaultTasksRepositoryTest {

    private val task1 = Task("Title1", "Description1")
    private val task2 = Task("Title2", "Description2")
    private val task3 = Task("Title3", "Description3")
    private val remoteTasks = listOf(task1, task2).sortedBy { it.id }
    private val localTasks = listOf(task3).sortedBy { it.id }
    private val newTasks = listOf(task3).sortedBy { it.id }

    private lateinit var tasksRemoteDataSource: FakeDataSource
    private lateinit var tasksLocalDataSource: FakeDataSource

    // Class under test
    private lateinit var tasksRepository: DefaultTasksRepository

    @Before
    fun createRepository() {
        tasksRemoteDataSource = FakeDataSource(remoteTasks.toMutableList())
        tasksLocalDataSource = FakeDataSource(localTasks.toMutableList())
        // Get a reference to the class under test
        tasksRepository = DefaultTasksRepository(
            // TODO Dispatchers.Unconfined should be replaced with Dispatchers.Main
            //  this requires understanding more about coroutines + testing
            //  so we will keep this as Unconfined for now.
            tasksRemoteDataSource, tasksLocalDataSource, Dispatchers.Unconfined
        )
    }

    @Test
    fun getTasks_requestsAllTasksFromRemoteDataSource() = runBlockingTest {
        // When tasks are requested from the tasks repository
        val tasks = tasksRepository.getTasks(true) as Success

        // Then tasks are loaded from the remote data source
        assertThat(tasks.data, IsEqual(remoteTasks))
    }

}
  1. 运行新的 getTasks_requestsAllTasksFromRemoteDataSource 测试,并确认它工作正常且错误已消失!

6. 任务:设置一个假的 Repository

您刚刚了解了如何对 Repository 进行单元测试。在接下来的步骤中,您将再次使用依赖注入并创建另一个测试替身—这一次将展示如何为 ViewModel 编写单元测试和集成测试。

单元测试应该测试您感兴趣的类或方法。这被称为在隔离中测试,即您清楚地隔离您的“单元”,并且只测试该单元中的代码。

因此 TasksViewModelTest 应该只测试 TasksViewModel 的代码—它不应该测试数据库、网络或 Repository 类中的代码。因此,对于您的 ViewModel,就像您刚刚对 Repository 所做的那样,您将创建一个假的 Repository 并应用依赖注入在测试中使用它。

在此任务中,您将对 ViewModel 应用依赖注入。

2ee5bcac127f3952.png

第 1 步:创建 TasksRepository 接口

使用构造函数依赖注入的第一步是创建假类和真实类之间共享的公共接口。

这在实践中是什么样的?看看 TasksRemoteDataSourceTasksLocalDataSourceFakeDataSource,请注意它们都共享同一个接口:TasksDataSource。这使得您可以在 DefaultTasksRepository 的构造函数中声明您接受一个 TasksDataSource

DefaultTasksRepository.kt

class DefaultTasksRepository(
   private val tasksRemoteDataSource: TasksDataSource,
   private val tasksLocalDataSource: TasksDataSource,
   private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO) {

这就是允许我们替换您的 FakeDataSource 的原因!

接下来,为 DefaultTasksRepository 创建一个接口,就像您为数据源所做的那样。它需要包含 DefaultTasksRepository 的所有公共方法(公共 API 接口)。

  1. 打开 DefaultTasksRepository右键点击类名。然后选择 Refactor -> Extract -> Interface

638b33aa1d3fe91d.png

  1. 选择 Extract to separate file

76daf401b9f0bb9c.png

  1. Extract Interface 窗口中,将接口名称更改为 TasksRepository
  2. Members to form interface 部分中,选中所有成员,除了两个 companion 成员和私有方法。

d97bdbdf2478fe24.png

  1. 点击 Refactor。新的 TasksRepository 接口应出现在 data/source 软件包中。

558f53d1462cf2d5.png

DefaultTasksRepository 现在实现了 TasksRepository

  1. 运行您的应用(而不是测试),确保一切正常工作。

第 2 步:创建 FakeTestRepository

现在您有了接口,就可以创建 DefaultTasksRepository 测试替身了。

  1. test 源集中的 data/source 目录下创建 Kotlin 文件和类 FakeTestRepository.kt,并继承自 TasksRepository 接口。

FakeTestRepository.kt

class FakeTestRepository : TasksRepository  {
}

系统将提示您需要实现接口方法。

  1. 将鼠标悬停在错误上,直到看到建议菜单,然后点击并选择 Implement members
  2. 选择所有方法,然后按 OK

7ce0211e1f7f91bb.png

第 3 步:实现 FakeTestRepository 方法

您现在有一个 FakeTestRepository 类,其中包含“未实现”的方法。类似于您实现 FakeDataSource 的方式,FakeTestRepository 将由数据结构支持,而不是处理本地和远程数据源之间复杂的协调。

请注意,您的 FakeTestRepository 不需要使用 FakeDataSource 或类似的东西;它只需要根据输入返回逼真的假输出。您将使用 LinkedHashMap 存储任务列表,并使用 MutableLiveData 存储可观察的任务。

  1. FakeTestRepository 中,添加一个表示当前任务列表的 LinkedHashMap 变量,以及一个用于可观察任务的 MutableLiveData

FakeTestRepository.kt

class FakeTestRepository : TasksRepository {

    var tasksServiceData: LinkedHashMap<String, Task> = LinkedHashMap()

    private val observableTasks = MutableLiveData<Result<List<Task>>>()


    // Rest of class
}

实现以下方法

  1. getTasks—此方法应获取 tasksServiceData,使用 tasksServiceData.values.toList() 将其转换为列表,然后将结果作为 Success 返回。
  2. refreshTasks—将 observableTasks 的值更新为 getTasks() 返回的值。
  3. observeTasks—使用 runBlocking 创建一个协程并运行 refreshTasks,然后返回 observableTasks

对于您的测试替身,请使用 runBlockingrunBlocking 更接近地模拟 Repository 的“真实”实现会做的事情,并且对于 Fake 更可取,这样它们的行为更接近真实实现。

当您在测试类中(即包含 @Test 函数的类)时,使用 runBlockingTest 以获得确定性行为。

以下是这些方法的代码。

FakeTestRepository.kt

class FakeTestRepository : TasksRepository {

    var tasksServiceData: LinkedHashMap<String, Task> = LinkedHashMap()

    private val observableTasks = MutableLiveData<Result<List<Task>>>()

    override suspend fun getTasks(forceUpdate: Boolean): Result<List<Task>> {
        return Result.Success(tasksServiceData.values.toList())
    }

    override suspend fun refreshTasks() {
        observableTasks.value = getTasks()
    }

    override fun observeTasks(): LiveData<Result<List<Task>>> {
        runBlocking { refreshTasks() }
        return observableTasks
    }

    override suspend fun completeTask(task: Task) {
       val completedTask = task.copy(isCompleted = true)
       tasksServiceData[task.id] = completedTask
       refreshTasks()
     }

    // Rest of class

}

第 4 步:添加一个用于测试的 addTasks 方法

测试时,Repository 中最好已经有一些 Tasks。您可以多次调用 saveTask,但为了更轻松地完成此操作,请添加一个专门用于测试的辅助方法,让您可以添加任务。

  1. 添加 addTasks 方法,该方法接受一个任务的 vararg,将每个任务添加到 HashMap 中,然后刷新任务。

FakeTestRepository.kt

    fun addTasks(vararg tasks: Task) {
        for (task in tasks) {
            tasksServiceData[task.id] = task
        }
        runBlocking { refreshTasks() }
    }

此时,您已经拥有了一个用于测试的假 Repository,并实现了一些关键方法。接下来,在您的测试中使用它!

7. 任务:在 ViewModel 中使用假 Repository

在此任务中,您将在 ViewModel 中使用一个假类。使用构造函数依赖注入,通过向 TasksViewModel 的构造函数添加 TasksRepository 变量,通过构造函数依赖注入接受两个数据源。

对于 ViewModel,此过程有点不同,因为您不直接构建它们。例如

class TasksFragment : Fragment() {

    private val viewModel by viewModels<TasksViewModel>()
    
    // Rest of class...

}

如上面的代码所示,您正在使用 viewModel属性委托来创建 ViewModel。要更改 ViewModel 的构建方式,您需要添加和使用一个 ViewModelProvider.Factory。如果您不熟悉 ViewModelProvider.Factory,可以在此处了解更多信息。

第 1 步:在 TasksViewModel 中创建并使用 ViewModelFactory

您首先更新与 Tasks 屏幕相关的类和测试。

  1. 打开 TasksViewModel
  2. TasksViewModel 的构造函数更改为接受 TasksRepository,而不是在类内部构建它。

TasksViewModel.kt

// REPLACE
class TasksViewModel(application: Application) : AndroidViewModel(application) {

    private val tasksRepository = DefaultTasksRepository.getRepository(application)

    // Rest of class
}

// WITH

class TasksViewModel( private val tasksRepository: TasksRepository ) : ViewModel() { 
    // Rest of class 
}

由于您更改了构造函数,现在需要使用工厂来构建 TasksViewModel。为方便起见,您可以将工厂类放在与 TasksViewModel 同一个文件中,但也可以将其放在自己的文件中。

  1. TasksViewModel 文件底部,在类外部,添加一个接受普通 TasksRepositoryTasksViewModelFactory

TasksViewModel.kt

@Suppress("UNCHECKED_CAST")
class TasksViewModelFactory (
    private val tasksRepository: TasksRepository
) : ViewModelProvider.NewInstanceFactory() {
    override fun <T : ViewModel> create(modelClass: Class<T>) =
        (TasksViewModel(tasksRepository) as T)
}

这是更改 ViewModel 构建方式的标准方法。现在您有了工厂,可以在构建 ViewModel 的任何地方使用它。

  1. 更新 TasksFragment 以使用该工厂。

TasksFragment.kt

// REPLACE
private val viewModel by viewModels<TasksViewModel>()

// WITH

private val viewModel by viewModels<TasksViewModel> {
    TasksViewModelFactory(DefaultTasksRepository.getRepository(requireActivity().application))
}
  1. 运行您的应用代码,确保一切仍然正常工作!

第 2 步:在 TasksViewModelTest 中使用 FakeTestRepository

现在,您可以在 ViewModel 测试中使用假的 Repository,而不是真实的 Repository。

  1. 打开 TasksViewModelTest。它位于 test 源集中的 tasks 文件夹下。
  2. TasksViewModelTest 中添加一个 FakeTestRepository 属性。

TaskViewModelTest.kt

@RunWith(AndroidJUnit4::class)
class TasksViewModelTest {

    // Use a fake repository to be injected into the viewmodel
    private lateinit var tasksRepository: FakeTestRepository
    
    // Rest of class
}
  1. 更新 setupViewModel 方法,使其创建一个包含三个任务的 FakeTestRepository,然后使用此 Repository 构建 tasksViewModel

TasksViewModelTest.kt

    @Before
    fun setupViewModel() {
        // We initialise the tasks to 3, with one active and two completed
        tasksRepository = FakeTestRepository()
        val task1 = Task("Title1", "Description1")
        val task2 = Task("Title2", "Description2", true)
        val task3 = Task("Title3", "Description3", true)
        tasksRepository.addTasks(task1, task2, task3)

        tasksViewModel = TasksViewModel(tasksRepository)
        
    }
  1. 由于您不再使用 AndroidX TestApplicationProvider.getApplicationContext 代码,您也可以移除 @RunWith(AndroidJUnit4::class) 注解。
  2. 运行您的测试,确保它们仍然工作正常!

通过使用构造函数依赖注入,您现在已将 DefaultTasksRepository 作为依赖项移除,并在测试中将其替换为您的 FakeTestRepository

第 3 步:同时更新 TaskDetailFragment 和 ViewModel

TaskDetailFragmentTaskDetailViewModel 进行相同的更改。这将为接下来编写 TaskDetail 测试做好代码准备。

  1. 打开 TaskDetailViewModel
  2. 更新构造函数

TaskDetailViewModel.kt

// REPLACE
class TaskDetailViewModel(application: Application) : AndroidViewModel(application) {

    private val tasksRepository = DefaultTasksRepository.getRepository(application)

    // Rest of class
}

// WITH

class TaskDetailViewModel(
    private val tasksRepository: TasksRepository
) : ViewModel() { // Rest of class }
  1. TaskDetailViewModel 文件底部,在类外部,添加一个 TaskDetailViewModelFactory

TaskDetailViewModel.kt

@Suppress("UNCHECKED_CAST")
class TaskDetailViewModelFactory (
    private val tasksRepository: TasksRepository
) : ViewModelProvider.NewInstanceFactory() {
    override fun <T : ViewModel> create(modelClass: Class<T>) =
        (TaskDetailViewModel(tasksRepository) as T)
}
  1. 更新 TaskDetailFragment 以使用该工厂。

TaskDetailFragment.kt

// REPLACE
private val viewModel by viewModels<TaskDetailViewModel>()

// WITH

private val viewModel by viewModels<TaskDetailViewModel> {
    TaskDetailViewModelFactory(DefaultTasksRepository.getRepository(requireActivity().application))
}
  1. 运行您的代码,确保一切工作正常。

您现在可以在 TasksFragmentTaskDetailFragment 中使用 FakeTestRepository 来代替真实的 Repository。

8. 任务:从测试中启动 Fragment

接下来,您将编写集成测试来测试您的 Fragment 和 ViewModel 交互。您将发现您的 ViewModel 代码是否适当地更新了 UI。为此,您将使用

  • ServiceLocator 模式
  • Espresso 和 Mockito 库

集成测试测试多个类之间的交互,以确保它们协同使用时行为符合预期。这些测试可以作为本地测试(test 源集)或仪器化测试(androidTest 源集)运行。

7017a2dd290e68aa.png

在您的案例中,您将选择每个 Fragment 并为其 Fragment 和 ViewModel 编写集成测试,以测试 Fragment 的主要功能。

第 1 步:添加 Gradle 依赖项

  1. 添加以下 gradle 依赖项。

app/build.gradle

    // Dependencies for Android instrumented unit tests
    androidTestImplementation "junit:junit:$junitVersion"
    androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"

    // Testing code should not be included in the main code.
    // Once https://issuetracker.google.com/128612536 is fixed this can be fixed.

    implementation "androidx.fragment:fragment-testing:$fragmentVersion"
    implementation "androidx.test:core:$androidXTestCoreVersion"

这些依赖项包括

  • junit:junit—JUnit,编写基本测试语句所必需。
  • androidx.test:core—核心 AndroidX test 库
  • kotlinx-coroutines-test—协程测试库
  • androidx.fragment:fragment-testing—用于在测试中创建 Fragment 并更改其状态的 AndroidX test 库。

由于您将在 androidTest 源集中使用这些库,请使用 androidTestImplementation 将它们添加为依赖项。

第 2 步:创建 TaskDetailFragmentTest 类

TaskDetailFragment 显示有关单个任务的信息。

dae7832a0afea061.png

您将从为 TaskDetailFragment 编写一个 Fragment 测试开始,因为它与其他 Fragment 相比功能相对基本。

  1. 打开 taskdetail.TaskDetailFragment
  2. TaskDetailFragment 生成一个测试,就像您之前做的那样。接受默认选项,并将其放在 androidTest 源集中(而不是 test 源集)。

d1f60b80b9a92218.png

  1. TaskDetailFragmentTest 类添加以下注解。

TaskDetailFragmentTest.kt

@MediumTest
@RunWith(AndroidJUnit4::class)
class TaskDetailFragmentTest {

}

这些注解的目的是

第 3 步:从测试中启动 Fragment

在此任务中,您将使用 AndroidX Testing 库启动 TaskDetailFragmentFragmentScenario 是 AndroidX Test 中的一个类,它封装了一个 Fragment,并允许您直接控制该 Fragment 的生命周期进行测试。要为 Fragment 编写测试,您需要为您要测试的 Fragment(TaskDetailFragment)创建一个 FragmentScenario

  1. 复制此测试到 TaskDetailFragmentTest 中。

TaskDetailFragmentTest.kt

    @Test
    fun activeTaskDetails_DisplayedInUi() {
        // GIVEN - Add active (incomplete) task to the DB
        val activeTask = Task("Active Task", "AndroidX Rocks", false)

        // WHEN - Details fragment launched to display task
        val bundle = TaskDetailFragmentArgs(activeTask.id).toBundle()
        launchFragmentInContainer<TaskDetailFragment>(bundle, R.style.AppTheme)

    }

上面的代码

  • 创建一个任务。
  • 创建一个 Bundle,它代表传递给 Fragment 的任务参数)。
  • launchFragmentInContainer 函数创建一个 FragmentScenario,并带上此 bundle 和主题。

这还不是一个完整的测试,因为它没有断言任何内容。现在,运行测试并观察发生的情况。

  1. 这是一个仪器化测试,因此请确保模拟器或您的设备可见。
  2. 运行测试。

会发生一些事情。

  • 首先,因为这是一个仪器化测试,所以测试将在您的物理设备(如果已连接)或模拟器上运行。
  • 它应该启动 Fragment。
  • 请注意,它不会通过任何其他 Fragment 进行导航,也没有与 Activity 关联的任何菜单——它只是 Fragment 本身。

最后,仔细观察,注意到 Fragment 显示“无数据”,因为它未能成功加载任务数据。

d14df7b104bdafe.png

您的测试既需要加载 TaskDetailFragment(您已经完成了),还需要断言数据已正确加载。为什么没有数据?这是因为您创建了一个任务,但没有将其保存到 Repository 中。

    @Test
    fun activeTaskDetails_DisplayedInUi() {
        // This DOES NOT save the task anywhere
        val activeTask = Task("Active Task", "AndroidX Rocks", false)

        val bundle = TaskDetailFragmentArgs(activeTask.id).toBundle()
        launchFragmentInContainer<TaskDetailFragment>(bundle, R.style.AppTheme)

    }

您拥有这个 FakeTestRepository,但您需要某种方式来用您的假 Repository 替换真实 Repository,以便用于您的 Fragment。接下来您将执行此操作!

9. 任务:创建 ServiceLocator

在此任务中,您将使用 ServiceLocator 向您的 Fragment 提供假的 Repository。这将允许您编写 Fragment 和 ViewModel 集成测试。

您不能像之前向 ViewModel 或 Repository 提供依赖项时那样使用构造函数依赖注入。构造函数依赖注入要求您构建该类。Fragment 和 Activity 是您不自行构建且通常无法访问其构造函数的类示例。

由于您不构建 Fragment,因此无法使用构造函数依赖注入将 Repository 测试替身(FakeTestRepository)替换到 Fragment 中。相反,请使用 Service Locator 模式。Service Locator 模式是依赖注入的替代方案。它涉及创建一个名为“Service Locator”的单例类,其目的是为常规代码和测试代码提供依赖项。在常规应用代码(main 源集)中,所有这些依赖项都是常规应用依赖项。对于测试,您修改 Service Locator 以提供依赖项的测试替身版本。

未使用 Service Locator

使用 Service Locator

对于本 Codelab 应用,请执行以下操作

  1. 创建一个能够构建和存储 Repository 的 Service Locator 类。默认情况下,它构建一个“正常”的 Repository。
  2. 重构您的代码,以便在需要 Repository 时使用 Service Locator。
  3. 在您的测试类中,调用 Service Locator 上的一个方法,用您的测试替身替换“正常”的 Repository。

第 1 步:创建 ServiceLocator

让我们创建一个 ServiceLocator 类。它将与应用的其余代码一起位于主源集中,因为它被主要应用代码使用。

注意:ServiceLocator 是一个单例,因此对该类使用 Kotlin object 关键字

  1. 在主源集的顶层创建文件 ServiceLocator.kt
  2. 定义一个名为 ServiceLocatorobject
  3. 创建 databaserepository 实例变量,并将两者都设置为 null
  4. @Volatile 注解 Repository,因为它可能被多个线程使用(@Volatile 的详细解释请参阅此处)。

您的代码应如下所示。

object ServiceLocator {

    private var database: ToDoDatabase? = null
    @Volatile
    var tasksRepository: TasksRepository? = null

}

现在,您的 ServiceLocator 唯一需要做的事情就是知道如何返回一个 TasksRepository。它会返回一个已存在的 DefaultTasksRepository,或者在需要时创建一个新的 DefaultTasksRepository 并返回。

定义以下函数

  1. provideTasksRepository—提供一个已存在的 Repository 或创建一个新的。此方法应在 this 上进行 synchronized,以避免在多线程运行时意外创建两个 Repository 实例。
  2. createTasksRepository—用于创建新 Repository 的代码。将调用 createTaskLocalDataSource 并创建一个新的 TasksRemoteDataSource
  3. createTaskLocalDataSource—用于创建新的本地数据源的代码。将调用 createDataBase
  4. createDataBase—用于创建新数据库的代码。

完成的代码如下所示。

ServiceLocator.kt

object ServiceLocator {

    private var database: ToDoDatabase? = null
    @Volatile
    var tasksRepository: TasksRepository? = null

    fun provideTasksRepository(context: Context): TasksRepository {
        synchronized(this) {
            return tasksRepository ?: createTasksRepository(context)
        }
    }

    private fun createTasksRepository(context: Context): TasksRepository {
        val newRepo = DefaultTasksRepository(TasksRemoteDataSource, createTaskLocalDataSource(context))
        tasksRepository = newRepo
        return newRepo
    }

    private fun createTaskLocalDataSource(context: Context): TasksDataSource {
        val database = database ?: createDataBase(context)
        return TasksLocalDataSource(database.taskDao())
    }

    private fun createDataBase(context: Context): ToDoDatabase {
        val result = Room.databaseBuilder(
            context.applicationContext,
            ToDoDatabase::class.java, "Tasks.db"
        ).build()
        database = result
        return result
    }
}

第 2 步:在 Application 中使用 ServiceLocator

您将对主要应用代码(不是您的测试)进行更改,以便在您的 ServiceLocator 中统一创建 Repository。

重要的是您只创建一个 Repository 类的实例。为了确保这一点,您将在 TodoApplication 类中使用 Service locator。

  1. 在软件包层次结构的顶层,打开 TodoApplication,并为您的 Repository 创建一个 val,将其分配为使用 ServiceLocator.provideTaskRepository 获取的 Repository。

TodoApplication.kt

class TodoApplication : Application() {

    val taskRepository: TasksRepository
        get() = ServiceLocator.provideTasksRepository(this)

    override fun onCreate() {
        super.onCreate()
        if (BuildConfig.DEBUG) Timber.plant(DebugTree())
    }
}

现在您已经在应用中创建了一个 Repository,您可以移除 DefaultTasksRepository 中旧的 getRepository 方法。

  1. 打开 DefaultTasksRepository 并删除 companion object。

DefaultTasksRepository.kt

// DELETE THIS COMPANION OBJECT
companion object {
    @Volatile
    private var INSTANCE: DefaultTasksRepository? = null

    fun getRepository(app: Application): DefaultTasksRepository {
        return INSTANCE ?: synchronized(this) {
            val database = Room.databaseBuilder(app,
                ToDoDatabase::class.java, "Tasks.db")
                .build()
            DefaultTasksRepository(TasksRemoteDataSource, TasksLocalDataSource(database.taskDao())).also {
                INSTANCE = it
            }
        }
    }
}

现在,您原来使用 getRepository 的所有地方都改用应用的 taskRepository。这确保您不再直接创建 Repository,而是获取由 ServiceLocator 提供的任何 Repository。

  1. 打开 TaskDetailFragement 并找到类顶部的 getRepository 调用。
  2. 将此调用替换为从 TodoApplication 获取 Repository 的调用。

TaskDetailFragment.kt

// REPLACE this code
private val viewModel by viewModels<TaskDetailViewModel> {
    TaskDetailViewModelFactory(DefaultTasksRepository.getRepository(requireActivity().application))
}

// WITH this code

private val viewModel by viewModels<TaskDetailViewModel> {
    TaskDetailViewModelFactory((requireContext().applicationContext as TodoApplication).taskRepository)
}
  1. TasksFragment 执行相同的操作。

TasksFragment.kt

// REPLACE this code
    private val viewModel by viewModels<TasksViewModel> {
        TasksViewModelFactory(DefaultTasksRepository.getRepository(requireActivity().application))
    }


// WITH this code

    private val viewModel by viewModels<TasksViewModel> {
        TasksViewModelFactory((requireContext().applicationContext as TodoApplication).taskRepository)
    }
  1. 对于 StatisticsViewModelAddEditTaskViewModel,更新获取 Repository 的代码以使用来自 TodoApplication 的 Repository。
// REPLACE this code
    private val tasksRepository = DefaultTasksRepository.getRepository(application)



// WITH this code

    private val tasksRepository = (application as TodoApplication).taskRepository

  1. 运行您的应用(不是测试)!

由于您只是进行了重构,应用应该可以正常运行而没有问题。

第 3 步:创建 FakeAndroidTestRepository

您已经在测试源集中有一个 FakeTestRepository。默认情况下,您不能在 testandroidTest 源集之间共享测试类。因此,您需要在 androidTest 源集中复制一个 FakeTestRepository 类,并将其命名为 FakeAndroidTestRepository

  1. 右键点击 androidTest 源集,并创建一个 data.source 软件包。
  2. 在此源软件包中创建一个名为 FakeAndroidTestRepository.kt 的新类。
  3. 将以下代码复制到该类中。

FakeAndroidTestRepository.kt

import androidx.annotation.VisibleForTesting
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.map
import com.example.android.architecture.blueprints.todoapp.data.Result
import com.example.android.architecture.blueprints.todoapp.data.Result.Error
import com.example.android.architecture.blueprints.todoapp.data.Result.Success
import com.example.android.architecture.blueprints.todoapp.data.Task
import kotlinx.coroutines.runBlocking
import java.util.LinkedHashMap


class FakeAndroidTestRepository : TasksRepository {

    var tasksServiceData: LinkedHashMap<String, Task> = LinkedHashMap()

    private var shouldReturnError = false

    private val observableTasks = MutableLiveData<Result<List<Task>>>()

    fun setReturnError(value: Boolean) {
        shouldReturnError = value
    }

    override suspend fun refreshTasks() {
        observableTasks.value = getTasks()
    }

    override suspend fun refreshTask(taskId: String) {
        refreshTasks()
    }

    override fun observeTasks(): LiveData<Result<List<Task>>> {
        runBlocking { refreshTasks() }
        return observableTasks
    }

    override fun observeTask(taskId: String): LiveData<Result<Task>> {
        runBlocking { refreshTasks() }
        return observableTasks.map { tasks ->
            when (tasks) {
                is Result.Loading -> Result.Loading
                is Error -> Error(tasks.exception)
                is Success -> {
                    val task = tasks.data.firstOrNull() { it.id == taskId }
                        ?: return@map Error(Exception("Not found"))
                    Success(task)
                }
            }
        }
    }

    override suspend fun getTask(taskId: String, forceUpdate: Boolean): Result<Task> {
        if (shouldReturnError) {
            return Error(Exception("Test exception"))
        }
        tasksServiceData[taskId]?.let {
            return Success(it)
        }
        return Error(Exception("Could not find task"))
    }

    override suspend fun getTasks(forceUpdate: Boolean): Result<List<Task>> {
        if (shouldReturnError) {
            return Error(Exception("Test exception"))
        }
        return Success(tasksServiceData.values.toList())
    }

    override suspend fun saveTask(task: Task) {
        tasksServiceData[task.id] = task
    }

    override suspend fun completeTask(task: Task) {
        val completedTask = Task(task.title, task.description, true, task.id)
        tasksServiceData[task.id] = completedTask
    }

    override suspend fun completeTask(taskId: String) {
        // Not required for the remote data source.
        throw NotImplementedError()
    }

    override suspend fun activateTask(task: Task) {
        val activeTask = Task(task.title, task.description, false, task.id)
        tasksServiceData[task.id] = activeTask
    }

    override suspend fun activateTask(taskId: String) {
        throw NotImplementedError()
    }

    override suspend fun clearCompletedTasks() {
        tasksServiceData = tasksServiceData.filterValues {
            !it.isCompleted
        } as LinkedHashMap<String, Task>
    }

    override suspend fun deleteTask(taskId: String) {
        tasksServiceData.remove(taskId)
        refreshTasks()
    }

    override suspend fun deleteAllTasks() {
        tasksServiceData.clear()
        refreshTasks()
    }

   
    fun addTasks(vararg tasks: Task) {
        for (task in tasks) {
            tasksServiceData[task.id] = task
        }
        runBlocking { refreshTasks() }
    }
}

第 4 步:为测试准备您的 ServiceLocator

好的,是时候在测试时使用 ServiceLocator 替换测试替身了。为此,您需要向您的 ServiceLocator 代码添加一些代码。

  1. 打开 ServiceLocator.kt
  2. tasksRepository 的 setter 标记为 @VisibleForTesting。此注解是一种表达 setter 为 public 的原因是出于测试目的的方式。

ServiceLocator.kt

    @Volatile
    var tasksRepository: TasksRepository? = null
        @VisibleForTesting set

无论您是单独运行测试还是在测试组中运行,您的测试都应该完全相同地运行。这意味着您的测试不应有相互依赖的行为(这意味着避免在测试之间共享对象)。

由于 ServiceLocator 是一个单例,它有可能在测试之间意外共享。为了帮助避免这种情况,请创建一个方法,用于在测试之间正确重置 ServiceLocator 的状态。

  1. 添加一个名为 lock 的实例变量,其值为 Any

ServiceLocator.kt

private val lock = Any()
  1. 添加一个测试特定方法 resetRepository,该方法清除数据库并将 Repository 和数据库都设置为 null。

ServiceLocator.kt

    @VisibleForTesting
    fun resetRepository() {
        synchronized(lock) {
            runBlocking {
                TasksRemoteDataSource.deleteAllTasks()
            }
            // Clear all data to avoid test pollution.
            database?.apply {
                clearAllTables()
                close()
            }
            database = null
            tasksRepository = null
        }
    }

第 5 步:使用您的 ServiceLocator

在此步骤中,您将使用 ServiceLocator

  1. 打开 TaskDetailFragmentTest
  2. 声明一个 lateinit TasksRepository 变量。
  3. 添加一个 setup 和一个 tear down 方法,用于在每个测试之前设置一个 FakeAndroidTestRepository,并在每个测试之后清理它。

TaskDetailFragmentTest.kt

    private lateinit var repository: TasksRepository

    @Before
    fun initRepository() {
        repository = FakeAndroidTestRepository()
        ServiceLocator.tasksRepository = repository
    }

    @After
    fun cleanupDb() = runBlockingTest {
        ServiceLocator.resetRepository()
    }
  1. 将函数主体 activeTaskDetails_DisplayedInUi() 封装在 runBlockingTest 中。
  2. 在启动 fragment 之前,将 activeTask 保存到仓库中。
repository.saveTask(activeTask)

最终的测试代码如下所示。

TaskDetailFragmentTest.kt

    @Test
    fun activeTaskDetails_DisplayedInUi()  = runBlockingTest{
        // GIVEN - Add active (incomplete) task to the DB
        val activeTask = Task("Active Task", "AndroidX Rocks", false)
        repository.saveTask(activeTask)

        // WHEN - Details fragment launched to display task
        val bundle = TaskDetailFragmentArgs(activeTask.id).toBundle()
        launchFragmentInContainer<TaskDetailFragment>(bundle, R.style.AppTheme)

    }
  1. 使用 @ExperimentalCoroutinesApi 标注整个类。

完成后,代码将如下所示。

TaskDetailFragmentTest.kt

@MediumTest
@ExperimentalCoroutinesApi
@RunWith(AndroidJUnit4::class)
class TaskDetailFragmentTest {

    private lateinit var repository: TasksRepository

    @Before
    fun initRepository() {
        repository = FakeAndroidTestRepository()
        ServiceLocator.tasksRepository = repository
    }

    @After
    fun cleanupDb() = runBlockingTest {
        ServiceLocator.resetRepository()
    }


    @Test
    fun activeTaskDetails_DisplayedInUi()  = runBlockingTest{
        // GIVEN - Add active (incomplete) task to the DB
        val activeTask = Task("Active Task", "AndroidX Rocks", false)
        repository.saveTask(activeTask)

        // WHEN - Details fragment launched to display task
        val bundle = TaskDetailFragmentArgs(activeTask.id).toBundle()
        launchFragmentInContainer<TaskDetailFragment>(bundle, R.style.AppTheme)

    }

}
  1. 运行 activeTaskDetails_DisplayedInUi() 测试。

与之前类似,您应该会看到 fragment,但这次因为它正确设置了仓库,所以现在会显示任务信息。

928dc8f5392a5823.png

10. 任务:使用 Espresso 编写您的第一个集成测试

在此步骤中,您将使用 Espresso UI 测试库完成您的第一个集成测试。您已经构建了代码,以便为您的 UI 添加包含断言的测试。为此,您将使用 Espresso 测试库

Espresso 可帮助您完成以下操作

  • 与视图交互,例如点击按钮、滑动条或向下滚动屏幕。
  • 断言某些视图显示在屏幕上或处于特定状态(例如包含特定文本,或者复选框已被勾选等)。

步骤 1. 注意 Gradle 依赖项

您应该已经拥有主要的 Espresso 依赖项,因为它默认包含在 Android 项目中。

app/build.gradle

dependencies {

  // ALREADY in your code
    androidTestImplementation "androidx.test.espresso:espresso-core:$espressoVersion"
   
 // Other dependencies
}

androidx.test.espresso:espresso-core—此核心 Espresso 依赖项在您新建 Android 项目时默认包含。它包含用于大多数视图及其操作的基本测试代码。

步骤 2. 关闭动画

Espresso 测试在真实设备上运行,因此本质上是 instrumentation 测试。其中一个问题是动画:如果动画出现延迟,并且您尝试测试某个视图是否显示在屏幕上,但它仍在播放动画,Espresso 可能会意外导致测试失败。这会使得 Espresso 测试不稳定。

对于 Espresso UI 测试,最佳做法是关闭动画(您的测试运行速度也会更快!)。

  1. 在您的测试设备上,前往设置 > 开发者选项
  2. 停用以下三个设置:窗口动画缩放过渡动画缩放动画时长缩放

192483c9a6e83a0.png

步骤 3. 查看 Espresso 测试

在编写 Espresso 测试之前,先看看一些 Espresso 代码。

onView(withId(R.id.task_detail_complete_checkbox)).perform(click()).check(matches(isChecked()))

此语句的功能是找到 ID 为 task_detail_complete_checkbox 的复选框视图,点击它,然后断言它已被勾选。

大多数 Espresso 语句由四个部分组成

  1. 静态 Espresso 方法
onView

onView 是一个静态 Espresso 方法的示例,它启动一个 Espresso 语句。onView 是最常见的方法之一,但还有其他选项,例如 onData

  1. ViewMatcher
withId(R.id.task_detail_title_text)

withIdViewMatcher 的一个示例,它通过 ID 获取视图。您可以在文档中查找其他视图匹配器。

  1. ViewAction
perform(click())

perform 方法接受一个 ViewActionViewAction 是可以对视图执行的操作,例如此处,它是点击视图。

  1. ViewAssertion
check(matches(isChecked()))

check 接受一个 ViewAssertionViewAssertion 用于检查或断言关于视图的一些内容。您最常用的 ViewAssertionmatches 断言。要完成断言,请使用另一个 ViewMatcher,在此示例中为 isChecked

e26de7f5db091867.png

请注意,在 Espresso 语句中您不一定总是同时调用 performcheck。您可以只使用 check 来进行断言,或者只使用 perform 来执行 ViewAction

  1. 打开 TaskDetailFragmentTest.kt
  2. 更新 activeTaskDetails_DisplayedInUi 测试。

TaskDetailFragmentTest.kt

    @Test
    fun activeTaskDetails_DisplayedInUi() = runBlockingTest{
        // GIVEN - Add active (incomplete) task to the DB
        val activeTask = Task("Active Task", "AndroidX Rocks", false)
        repository.saveTask(activeTask)

        // WHEN - Details fragment launched to display task
        val bundle = TaskDetailFragmentArgs(activeTask.id).toBundle()
        launchFragmentInContainer<TaskDetailFragment>(bundle, R.style.AppTheme)

        // THEN - Task details are displayed on the screen
        // make sure that the title/description are both shown and correct
        onView(withId(R.id.task_detail_title_text)).check(matches(isDisplayed()))
        onView(withId(R.id.task_detail_title_text)).check(matches(withText("Active Task")))
        onView(withId(R.id.task_detail_description_text)).check(matches(isDisplayed()))
        onView(withId(R.id.task_detail_description_text)).check(matches(withText("AndroidX Rocks")))
        // and make sure the "active" checkbox is shown unchecked
        onView(withId(R.id.task_detail_complete_checkbox)).check(matches(isDisplayed()))
        onView(withId(R.id.task_detail_complete_checkbox)).check(matches(not(isChecked())))
    }

如果需要,导入语句如下

import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isChecked
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import org.hamcrest.core.IsNot.not
  1. // THEN 注释之后的所有内容都使用了 Espresso。检查测试结构以及如何使用 withIdcheck 对详情页面的外观进行断言。
  2. 运行测试并确认它通过。

步骤 4.(可选)编写您自己的 Espresso 测试

现在自己编写一个测试。

  1. 创建一个名为 completedTaskDetails_DisplayedInUi 的新测试,并复制此骨架代码。

TaskDetailFragmentTest.kt

    @Test
    fun completedTaskDetails_DisplayedInUi() = runBlockingTest{
        // GIVEN - Add completed task to the DB
       
        // WHEN - Details fragment launched to display task
        
        // THEN - Task details are displayed on the screen
        // make sure that the title/description are both shown and correct
}
  1. 参考上一个测试,完成此测试。
  2. 运行并确认测试通过。

完成后的 completedTaskDetails_DisplayedInUi 应如下所示。

TaskDetailFragmentTest.kt

    @Test
    fun completedTaskDetails_DisplayedInUi() = runBlockingTest{
        // GIVEN - Add completed task to the DB
        val completedTask = Task("Completed Task", "AndroidX Rocks", true)
        repository.saveTask(completedTask)

        // WHEN - Details fragment launched to display task
        val bundle = TaskDetailFragmentArgs(completedTask.id).toBundle()
        launchFragmentInContainer<TaskDetailFragment>(bundle, R.style.AppTheme)

        // THEN - Task details are displayed on the screen
        // make sure that the title/description are both shown and correct
        onView(withId(R.id.task_detail_title_text)).check(matches(isDisplayed()))
        onView(withId(R.id.task_detail_title_text)).check(matches(withText("Completed Task")))
        onView(withId(R.id.task_detail_description_text)).check(matches(isDisplayed()))
        onView(withId(R.id.task_detail_description_text)).check(matches(withText("AndroidX Rocks")))
        // and make sure the "active" checkbox is shown unchecked
        onView(withId(R.id.task_detail_complete_checkbox)).check(matches(isDisplayed()))
        onView(withId(R.id.task_detail_complete_checkbox)).check(matches(isChecked()))
    }

11. 任务:使用 Mockito 编写导航测试

在最后一步中,您将学习如何使用一种不同的测试替身(称为 mock)和测试库 Mockito 来测试 Navigation component

在此 Codelab 中,您使用了名为 fake 的测试替身。Fakes 是许多类型的测试替身之一。您应该使用哪种测试替身来测试 Navigation component

思考一下导航是如何发生的。想象一下在 TasksFragment 中按下其中一个任务以导航到任务详情屏幕。

920d31294d1cef2e.png

这是 TasksFragment 中的代码,当按下时,它会导航到任务详情屏幕。

TasksFragment.kt

private fun openTaskDetails(taskId: String) {
    val action = TasksFragmentDirections.actionTasksFragmentToTaskDetailFragment(taskId)
    findNavController().navigate(action)
}

导航是因为调用了 navigate 方法。如果您需要编写断言语句,目前没有直接的方法来测试您是否已导航到 TaskDetailFragment。导航是一个复杂的操作,除了初始化 TaskDetailFragment 之外,并不会产生清晰的输出或状态变化。

您可以断言的是 navigate 方法已使用正确的 action 参数被调用。这正是 mock 测试替身所做的事情——它检查是否调用了特定方法。

Mockito 是一个用于创建测试替身的框架。虽然 API 和名称中使用了 mock 一词,但它不仅仅用于创建 mock。它也可以创建 stub 和 spy。

您将使用 Mockito 来创建一个 mock NavigationController,它可以断言 navigate 方法是否被正确调用。

第 1 步:添加 Gradle 依赖项

  1. 添加 gradle 依赖项。

app/build.gradle

    // Dependencies for Android instrumented unit tests
    androidTestImplementation "org.mockito:mockito-core:$mockitoVersion"

    androidTestImplementation "com.linkedin.dexmaker:dexmaker-mockito:$dexMakerVersion" 

    androidTestImplementation "androidx.test.espresso:espresso-contrib:$espressoVersion"



  • org.mockito:mockito-core—这是 Mockito 依赖项。
  • dexmaker-mockito—在 Android 项目中使用 Mockito 需要此库。Mockito 需要在运行时生成类。在 Android 上,这是使用 dex 字节码完成的,因此此库使 Mockito 能够在 Android 上运行时生成对象。
  • androidx.test.espresso:espresso-contrib—此库由外部贡献组成(因此得名),其中包含用于更高级视图的测试代码,例如 DatePickerRecyclerView。它还包含无障碍检查以及一个名为 CountingIdlingResource 的类,该类稍后会介绍。

步骤 2. 创建 TasksFragmentTest

  1. 打开 TasksFragment
  2. 右键点击 TasksFragment 类名,然后选择生成,再选择测试。在 androidTest 源集内创建一个测试。
  3. 将此代码复制到 TasksFragmentTest 中。

TasksFragmentTest.kt

@RunWith(AndroidJUnit4::class)
@MediumTest
@ExperimentalCoroutinesApi
class TasksFragmentTest {

    private lateinit var repository: TasksRepository

    @Before
    fun initRepository() {
        repository = FakeAndroidTestRepository()
        ServiceLocator.tasksRepository = repository
    }

    @After
    fun cleanupDb() = runBlockingTest {
        ServiceLocator.resetRepository()
    }

}

此代码与您编写的 TaskDetailFragmentTest 代码类似。它设置并拆解一个 FakeAndroidTestRepository。添加一个导航测试,以测试当您点击任务列表中的某个任务时,它是否会带您前往正确的 TaskDetailFragment

  1. 添加测试 clickTask_navigateToDetailFragmentOne

TasksFragmentTest.kt

    @Test
    fun clickTask_navigateToDetailFragmentOne() = runBlockingTest {
        repository.saveTask(Task("TITLE1", "DESCRIPTION1", false, "id1"))
        repository.saveTask(Task("TITLE2", "DESCRIPTION2", true, "id2"))

        // GIVEN - On the home screen
        val scenario = launchFragmentInContainer<TasksFragment>(Bundle(), R.style.AppTheme)
        
    }
  1. 使用 Mockito 的 mock 函数创建一个 mock。

TasksFragmentTest.kt

 val navController = mock(NavController::class.java)

要在 Mockito 中进行 mock,请传入您想要 mock 的类。

接下来,您需要将您的 NavController 与 fragment 关联起来。onFragment 允许您在 fragment 本身调用方法。

  1. 将您新的 mock 设置为 fragment 的 NavController
scenario.onFragment {
    Navigation.setViewNavController(it.view!!, navController)
}
  1. 添加代码,点击 RecyclerView 中文本为“TITLE1”的项。
// WHEN - Click on the first list item
        onView(withId(R.id.tasks_list))
            .perform(RecyclerViewActions.actionOnItem<RecyclerView.ViewHolder>(
                hasDescendant(withText("TITLE1")), click()))

RecyclerViewActionsespresso-contrib 库的一部分,它允许您对 RecyclerView 执行 Espresso 操作

  1. 验证 navigate 是否被调用,并带有正确的参数。
// THEN - Verify that we navigate to the first detail screen
verify(navController).navigate(
    TasksFragmentDirections.actionTasksFragmentToTaskDetailFragment( "id1")

Mockito 的 verify 方法使得这成为一个 mock——您可以确认被 mock 的 navController 调用了特定方法(navigate),并带有参数(actionTasksFragmentToTaskDetailFragment,ID 为“id1”)。

完整的测试代码如下所示

@Test
fun clickTask_navigateToDetailFragmentOne() = runBlockingTest {
    repository.saveTask(Task("TITLE1", "DESCRIPTION1", false, "id1"))
    repository.saveTask(Task("TITLE2", "DESCRIPTION2", true, "id2"))

    // GIVEN - On the home screen
    val scenario = launchFragmentInContainer<TasksFragment>(Bundle(), R.style.AppTheme)
    
                val navController = mock(NavController::class.java)
    scenario.onFragment {
        Navigation.setViewNavController(it.view!!, navController)
    }

    // WHEN - Click on the first list item
    onView(withId(R.id.tasks_list))
        .perform(RecyclerViewActions.actionOnItem<RecyclerView.ViewHolder>(
            hasDescendant(withText("TITLE1")), click()))


    // THEN - Verify that we navigate to the first detail screen
    verify(navController).navigate(
        TasksFragmentDirections.actionTasksFragmentToTaskDetailFragment( "id1")
    )
}
  1. 运行您的测试!

总结来说,要测试导航,您可以执行以下操作

  1. 使用 Mockito 创建一个 NavController mock。
  2. 将该 mock 的 NavController 附加到 fragment。
  3. 验证 navigate 是否被调用,并带有正确的 action 和参数。

步骤 3.(可选)编写 clickAddTaskButton_navigateToAddEditFragment

看看您是否能自己编写一个导航测试,请尝试此任务。

  1. 编写测试 clickAddTaskButton_navigateToAddEditFragment,该测试检查如果您点击 + FAB,是否会导航到 AddEditTaskFragment

答案如下所示。

TasksFragmentTest.kt

    @Test
    fun clickAddTaskButton_navigateToAddEditFragment() {
        // GIVEN - On the home screen
        val scenario = launchFragmentInContainer<TasksFragment>(Bundle(), R.style.AppTheme)
        val navController = mock(NavController::class.java)
        scenario.onFragment {
            Navigation.setViewNavController(it.view!!, navController)
        }

        // WHEN - Click on the "+" button
        onView(withId(R.id.add_task_fab)).perform(click())

        // THEN - Verify that we navigate to the add screen
        verify(navController).navigate(
            TasksFragmentDirections.actionTasksFragmentToAddEditTaskFragment(
                null, getApplicationContext<Context>().getString(R.string.add_task)
            )
        )
    }

12. 解决方案代码

点击此处查看您开始时的代码与最终代码之间的差异。

要下载完成的 Codelab 代码,您可以使用以下 git 命令

$ git clone https://github.com/google-developer-training/advanced-android-testing.git
$ cd android-testing
$ git checkout end_codelab_2

或者,您可以将仓库下载为 Zip 文件,解压后在 Android Studio 中打开。

13. 总结

此 Codelab 介绍了如何手动设置依赖注入、服务定位器,以及如何在 Android Kotlin 应用中使用假对象和 mock 对象。具体来说

  • 您想要测试什么以及您的测试策略决定了您将为应用实现的测试类型。单元测试具有针对性且速度快。集成测试验证程序各部分之间的交互。端到端测试验证功能,保真度最高,通常是插桩测试,并且可能需要更长时间才能运行。
  • 您的应用架构会影响测试的难度。
  • 为了隔离应用的各个部分进行测试,您可以使用测试替身。测试替身是专门为测试而设计的类版本。例如,您可以 fake(伪造/模拟)从数据库或互联网获取数据。
  • 使用依赖注入将实际类替换为测试类,例如仓库或网络层。
  • 使用插桩测试 (androidTest) 启动 UI 组件。
  • 当您无法使用构造函数依赖注入(例如启动 fragment)时,您通常可以使用服务定位器。服务定位器模式是依赖注入的替代方案。它涉及创建一个名为“服务定位器”的单例类,其目的是为常规代码和测试代码提供依赖项。

14. 了解详情

示例

  • 官方测试示例 - 这是官方测试示例,基于此处使用的相同待办事项笔记应用。此示例中的概念超出了三个测试 Codelab 的范围。
  • Sunflower 演示 - 这是主要的 Android Jetpack 示例,它也使用了 Android 测试库
  • Espresso 测试示例

Udacity 课程

Android 开发者文档

视频

其他

15. 下一个 Codelab

开始下一课:5.3: 测试主题调查