测试替身和依赖项注入简介

此 Codelab 是“使用 Kotlin 进行高级 Android 开发”课程的一部分。如果您按顺序学习这些 Codelab,您将会充分发掘课程的价值,但并不强制要求这样做。“使用 Kotlin 进行高级 Android 开发”Codelab 着陆页列出了所有课程 Codelab。

简介

这第二个测试 Codelab 主要介绍测试替身:何时在 Android 中使用测试替身,以及如何使用依赖项注入、服务定位器模式和库来实现测试替身。在此过程中,您将学习如何编写:

  • 代码库单元测试
  • Fragment 和 ViewModel 集成测试
  • fragment 导航测试

您应当已掌握的内容

您应熟悉以下内容:

  • Kotlin 编程语言
  • 第一个 Codelab 中涵盖的测试概念:在 Android 上编写和运行单元测试、使用 JUnit、Hamcrest、AndroidX 测试、Robolectric 以及测试 LiveData
  • 以下核心 Android Jetpack 库:ViewModel、LiveData 和导航组件
  • 应用架构,遵循应用架构指南和 Android 基础知识 Codelab 中的模式
  • Android 上的协程基础知识

学习内容

  • 如何规划测试策略
  • 如何创建和使用测试替身(即伪对象和模拟对象)
  • 如何在 Android 上使用手动依赖项注入进行单元测试和集成测试
  • 如何应用服务定位器模式
  • 如何测试代码库、fragment、视图模型和 Navigation 组件

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

您将执行的操作

  • 使用测试替身和依赖项注入为仓库编写单元测试。
  • 使用测试替身和依赖项注入为视图模型编写单元测试。
  • 使用 Espresso 界面测试框架为 fragment 及其视图模型编写集成测试。
  • 使用 Mockito 和 Espresso 编写导航测试。

在此系列 Codelab 中,您将使用“待办事项记事”应用。该应用可让您记下要完成的任务,并以列表形式显示这些任务。然后,您可以将它们标记为已完成或未完成、过滤它们或删除它们。

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

下载代码

首先,请下载代码:

下载 Zip 文件

或者,您也可以克隆代码的 GitHub 代码库:

$ git clone https://github.com/googlecodelabs/android-testing.git
$ cd android-testing
$ git checkout end_codelab_1

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

第 1 步:运行示例应用

下载待办事项应用后,在 Android Studio 中打开并运行该应用。应该可以编译。通过执行以下操作探索应用:

  • 使用加号浮动操作按钮创建新任务。先输入标题,然后输入有关任务的其他信息。点击绿色对勾标记 FAB 保存。
  • 在任务列表中,点击您刚刚完成的任务的标题,然后查看该任务的详情界面,以了解其余说明。
  • 在列表或详情界面中,选中相应任务的复选框,将其状态设置为已完成。
  • 返回到任务界面,打开过滤条件菜单,然后按有效和已完成状态过滤任务。
  • 打开抽屉式导航栏,然后点击统计信息。
  • 返回到概览界面,然后在导航抽屉式菜单中选择清除已完成,以删除所有状态为已完成的任务

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

待办事项应用基于热门的架构蓝图测试和架构示例(使用该示例的响应式架构版本)。该应用遵循应用架构指南中的架构。它将 ViewModel 与 Fragment、仓库和 Room 搭配使用。如果您熟悉以下任一示例,就会发现此应用的架构与之类似:

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

以下是您会看到的套餐摘要:

套餐: com.example.android.architecture.blueprints.todoapp

.addedittask

“添加或修改任务”界面:用于添加或修改任务的界面层代码。

.data

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

.statistics

统计信息界面:统计信息界面的界面层代码。

.taskdetail

任务详情界面:单个任务的界面层代码。

.tasks

任务界面:所有任务列表的界面层代码。

.util

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

数据层 (.data)

此应用包含 remote 软件包中的模拟网络层和 local 软件包中的数据库层。为简单起见,在此项目中,网络层仅通过具有延迟的 HashMap 进行模拟,而不是发出实际的网络请求。

DefaultTasksRepository 在网络层和数据库层之间进行协调或中介,并负责将数据返回到界面层。

界面层(.addedittask、.statistics、.taskdetail、.tasks)

每个界面层软件包都包含一个 fragment 和一个视图模型,以及界面所需的任何其他类(例如任务列表的适配器)。TaskActivity 是包含所有 fragment 的 activity。

导航

应用的导航由 Navigation 组件控制。它在 nav_graph.xml 文件中定义。导航在视图模型中使用 Event 类触发;视图模型还会确定要传递哪些实参。fragment 会观察 Event,并在界面之间进行实际导航。

在此 Codelab 中,您将学习如何使用测试替身和依赖项注入来测试代码库、视图模型和 fragment。在深入了解这些内容之前,请务必先了解相关推理,以便指导您编写这些测试的内容和方式。

本部分介绍了一些适用于 Android 的一般测试最佳实践。

测试金字塔

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

  • 范围 - 测试涉及多少代码?测试可以针对单个方法、整个应用或介于两者之间的某个范围运行。
  • 速度 - 测试运行速度有多快?测试速度可能从毫秒到几分钟不等。
  • 保真度 - 测试有多“真实”?例如,如果您测试的代码的一部分需要发出网络请求,那么测试代码是实际发出此网络请求,还是伪造结果?如果测试实际与网络通信,则意味着其保真度更高。但这样做的缺点是,测试可能需要更长时间才能运行,如果网络中断,可能会导致错误,或者使用成本可能很高。

这些方面之间存在固有的权衡取舍。例如,速度和保真度之间存在权衡关系,测试速度越快,保真度通常越低,反之亦然。一种常见的自动化测试划分方式是将测试分为以下三类:

  • 单元测试 - 这些测试高度聚焦,在单个类(通常是该类中的单个方法)上运行。如果单元测试失败,您可以确切地知道代码中的问题出在何处。由于在现实世界中,您的应用涉及的不仅仅是一个方法或类的执行,因此单元测试的保真度较低。它们的速度足够快,可以在每次更改代码时运行。它们通常是本地运行的测试(位于 test 源代码集中)。示例: 测试视图模型和代码库中的单个方法。
  • 集成测试 - 这些测试用于测试多个类的互动,以确保它们在共同使用时能按预期运行。一种集成测试结构是让测试只测试一项功能,例如保存任务的功能。它们的测试范围比单元测试大,但仍经过优化,可快速运行,而不是提供完全保真度。它们可以根据具体情况在本地运行,也可以作为插桩测试运行。示例: 测试单个 fragment 和视图模型对的所有功能。
  • 端到端测试 (E2e) - 测试多种功能协同工作的情况。它们会测试应用的大部分内容,模拟真实使用情况,因此通常速度较慢。它们具有最高的保真度,可让您了解应用是否能正常运行。总的来说,这些测试将是插桩测试(位于 androidTest 源代码集中)
    示例: 启动整个应用并一起测试几个功能。

这些测试的建议比例通常以金字塔表示,其中绝大部分测试都是单元测试。

架构和测试

您能否在测试金字塔的所有不同层级测试应用,从根本上取决于应用的架构。例如,一个架构非常糟糕的应用可能会将其所有逻辑都放在一个方法中。您或许可以为此编写端到端测试,因为这些测试往往会测试应用的大部分功能,但编写单元测试或集成测试又该如何呢?由于所有代码都集中在一处,因此很难仅测试与单个单元或功能相关的代码。

更好的方法是将应用逻辑分解为多个方法和类,以便单独测试每个部分。架构是一种划分和整理代码的方式,可让您更轻松地进行单元测试和集成测试。您将测试的待办事项应用遵循特定的架构:



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

  1. 首先,您将对代码库进行单元测试。
  2. 然后,您将在视图模型中使用测试替身,这对于对视图模型进行单元测试和集成测试是必需的。
  3. 接下来,您将学习如何为 fragment 及其视图模型编写集成测试。
  4. 最后,您将学习如何编写包含导航组件的集成测试。

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

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

仅测试特定类中的代码可能比较棘手。下面我们来看一个示例。在 main 源代码集中打开 data.source.DefaultTaskRepository 类。这是应用的代码库,也是您接下来要为其编写单元测试的类。

您的目标是仅测试该类中的代码。不过,DefaultTaskRepository 依赖于 LocalTaskDataSource 和 RemoteTaskDataSource 等其他类才能正常运行。换句话说,LocalTaskDataSource 和 RemoteTaskDataSource 是 DefaultTaskRepository 的依赖项。

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



例如,请查看 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 是您可能对代码库进行的最“基本”的调用之一。此方法包括从 SQLite 数据库读取数据和进行网络调用(对 updateTasksFromRemoteDataSource 的调用)。与仅包含库代码相比,此方法涉及的代码要多得多。

以下是测试代码库之所以困难的一些更具体的原因:

  • 您需要考虑如何创建和管理数据库,才能为此代码库执行最简单的测试。这会引发一些问题,例如“这应该是本地测试还是插桩测试?”以及是否应该使用 AndroidX Test 来获取模拟的 Android 环境。
  • 某些代码(例如网络代码)可能需要很长时间才能运行,甚至偶尔会失败,从而导致测试运行时间过长且不稳定。
  • 您的测试可能会失去诊断哪些代码导致测试失败的能力。您的测试可能会开始测试非代码库代码,因此,举例来说,您本应是“代码库”的单元测试可能会因某些依赖代码(例如数据库代码)中的问题而失败。

测试替身

解决此问题的方法是,在测试存储库时,不要使用实际的网络或数据库代码,而是使用测试替身。测试替身是专门为测试而打造的类版本。它旨在替换测试中类的实际版本。这类似于特技替身演员,他们是专门从事特技表演的演员,会代替真正的演员完成危险动作。

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

虚假

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

模拟

一种用于跟踪其哪些方法被调用的测试替身。然后,根据其方法是否被正确调用来通过或未通过测试。

存根

一种不包含任何逻辑且仅返回您为其编程的返回值的测试替身。例如,可以对 StubTaskRepository 进行编程,以返回 getTasks 中的特定任务组合。

Dummy

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

Spy

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

如需详细了解测试替身,请参阅Testing on the Toilet:了解测试替身。

Android 中最常用的测试替身是 Fake 和 Mock。

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

第 1 步:创建 FakeDataSource 类

在此步骤中,您将创建一个名为 FakeDataSouce 的类,该类将是 LocalDataSource 和 RemoteDataSource 的测试替身。

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

  1. 创建一个包含源软件包的数据软件包。
  2. 在 data/source 软件包中创建一个名为 FakeDataSource 的新类。

第 2 步:实现 TasksDataSource 接口

为了能够将新类 FakeDataSource 用作测试替身,它必须能够替换其他数据源。这些数据源包括 TasksLocalDataSource 和 TasksRemoteDataSource。

  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。


  1. 选择所有方法,然后按确定。

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

FakeDataSource 是一种称为 fake 的特定类型的测试替身。伪对象是一种测试替身,它具有类的“有效”实现,但其实现方式使其非常适合测试,但不适合生产。“有效”实现是指该类在给定输入的情况下会生成真实的输出。

例如,您的虚假数据源不会连接到网络或将任何内容保存到数据库,而是只会使用内存中的列表。这种实现方式“会按预期运行”,因为用于获取或保存任务的方法会返回预期结果,但您永远无法在正式版中使用这种实现方式,因为任务不会保存到服务器或数据库。

FakeDataSource

  • 让您无需依赖实际的数据库或网络即可测试 DefaultTasksRepository 中的代码。
  • 为测试提供“足够真实”的实现。
  1. 更改 FakeDataSource 构造函数以创建一个名为 tasks 的 var,该 var 是一个 MutableList<Task>?,默认值为一个空的可变列表。
class FakeDataSource(var tasks: MutableList<Task>? = mutableListOf()) : TasksDataSource { // Rest of class }


这是“伪造”数据库或服务器响应的任务列表。目前,目标是测试代码库的 getTasks 方法。这会调用数据源的 getTasks、deleteAllTasks 和 saveTask 方法。

编写这些方法的虚假版本:

  1. 写入 getTasks:如果 tasks 不为 null,则返回 Success 结果。如果 tasks 为 null,则返回 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 语句(如有):

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

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

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

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

目前,依赖项是在 DefaultTasksRepository 的 init 方法内构建的。

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 内部创建和分配 taskLocalDataSource 和 tasksRemoteDataSource,因此它们实际上是硬编码的。无法换入测试替身。

您应该提供这些数据源给类,而不是对它们进行硬编码。提供依赖项称为依赖项注入。提供依赖项的方式有很多种,因此依赖项注入的类型也有很多种。

借助构造函数依赖项注入,您可以通过将测试替身传递到构造函数中来换入测试替身。

无注入

注入

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

  1. 将 DefaultTaskRepository 的构造函数从接受 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. 按照提示在测试源代码集中创建 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 成员变量(每个变量对应一个代码库数据源),以及一个用于测试的 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. 使用 remoteTasks 和 localTasks 列表实例化您的假数据源。
  3. 使用您刚刚创建的两个虚假数据源和 Dispatchers.Unconfined 实例化 tasksRepository。

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

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. 为仓库的 getTasks 方法编写测试。检查当您使用 true 调用 getTasks(意味着它应从远程数据源重新加载)时,它是否会返回远程数据源(而非本地数据源)中的数据。

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: 时会出错

第 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 测试,并确认测试正常运行且错误已消失!

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

单元测试应仅测试您感兴趣的类或方法。这称为隔离测试,即明确隔离“单元”,仅测试属于该单元的代码。

因此,TasksViewModelTest 应仅测试 TasksViewModel 代码,而不应测试数据库、网络或代码库类。因此,对于视图模型,您将像刚才对代码库所做的那样,创建一个虚假代码库并应用依赖项注入,以便在测试中使用它。

在此任务中,您将对视图模型应用依赖项注入。

第 1 步:创建 TasksRepository 接口

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

实际效果如何?查看 TasksRemoteDataSource、TasksLocalDataSource 和 FakeDataSource,并注意它们都共享同一个接口: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。

  1. 选择提取到单独的文件。

  1. 在 Extract Interface 窗口中,将接口名称更改为 TasksRepository。
  2. 在要形成界面的成员部分中,勾选除两个配套成员和私有方法之外的所有成员。


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

并且 DefaultTasksRepository 现在实现了 TasksRepository。

  1. 运行您的应用(而非测试),确保一切仍能正常运行。

第 2 步:创建 FakeTestRepository

现在,您已拥有接口,可以创建 DefaultTaskRepository 测试替身了。

  1. 在 test 源集中,在 data/source 中创建 Kotlin 文件和类 FakeTestRepository.kt,并从 TasksRepository 接口扩展。

FakeTestRepository.kt

class FakeTestRepository : TasksRepository  {
}

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

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

第 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。

以下是这些方法的代码。

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
    }

    // Rest of class

}

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

在测试时,最好在代码库中已有一些 Tasks。您可以多次调用 saveTask,但为了简化此操作,请添加一个专门用于测试的辅助方法,以便您添加任务。

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

FakeTestRepository.kt

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

此时,您已拥有一个用于测试的虚假代码库,其中实现了几个关键方法。接下来,在测试中使用此功能!

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

对于视图模型,此过程略有不同,因为您不会直接构建它们。例如:

class TasksFragment : Fragment() {

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

}


如上述代码所示,您使用的是用于创建视图模型的 viewModel's 属性委托。如需更改视图模型的构建方式,您需要添加并使用 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 文件底部的类之外,添加一个接受纯 TasksRepository 的 TasksViewModelFactory。

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 构建方式的标准方法。现在您已经有了工厂,可以在构建视图模型的任何位置使用它。

  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

现在,您可以在视图模型测试中使用虚假代码库,而不是使用真实代码库。

  1. 打开 TasksViewModelTest。
  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,然后使用此代码库构建 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 Test ApplicationProvider.getApplicationContext 代码,因此您还可以移除 @RunWith(AndroidJUnit4::class) 注释。
  2. 运行测试,确保它们仍然全部有效!

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

第 3 步:同时更新了 TaskDetail fragment 和 ViewModel

对 TaskDetailFragment 和 TaskDetailViewModel 进行完全相同的更改。这样一来,您就可以在接下来编写 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. 更新 TasksFragment 以使用工厂。

TasksFragment.kt

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

// WITH

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

现在,您可以在 TasksFragment 和 TasksDetailFragment 中使用 FakeTestRepository 而不是实际的代码库。

接下来,您将编写集成测试来测试 fragment 和 view-model 互动。您将了解视图模型代码是否能正确更新界面。为此,您可以使用

  • ServiceLocator 模式
  • Espresso 和 Mockito 库

集成测试 用于测试多个类的互动,以确保它们在搭配使用时能够按预期运行。这些测试可以在本地(test 源集)运行,也可以作为插桩测试(androidTest 源集)运行。

在本例中,您将获取每个 fragment,并为 fragment 和视图模型编写集成测试,以测试 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 测试库
  • kotlinx-coroutines-test - 协程测试库
  • androidx.fragment:fragment-testing - 用于在测试中创建 fragment 并更改其状态的 AndroidX 测试库。

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

第 2 步:创建 TaskDetailFragmentTest 类

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

您将首先为 TaskDetailFragment 编写 fragment 测试,因为与其他 fragment 相比,它的功能相当基本。

  1. 打开 taskdetail.TaskDetailFragment。
  2. 像之前一样,为 TaskDetailFragment 生成测试。接受默认选择,并将其放在 androidTest 源代码集中(而不是 test 源代码集中)。

  1. 将以下注释添加到 TaskDetailFragmentTest 类。

TaskDetailFragmentTest.kt

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

}

这些注释的目的是:

第 3 步:从测试中启动 fragment

在此任务中,您将使用 AndroidX 测试库启动 TaskDetailFragment。FragmentScenario 是 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)

    }

上述代码:

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

  1. 这是一项插桩测试,因此请确保模拟器或设备处于可见状态。
  2. 运行测试。

应该会发生以下情况。

  • 首先,由于这是插桩测试,因此测试将在实体设备(如果已连接)或模拟器上运行。
  • 它应该会启动 fragment。
  • 请注意,它不会通过任何其他 fragment 进行导航,也不会有任何与 activity 关联的菜单 - 它只是 fragment。

最后,仔细观察并注意,由于未成功加载任务数据,该 fragment 显示“无数据”。

您的测试既需要加载 TaskDetailFragment(您已完成此操作),也需要断言数据已正确加载。为什么没有数据?这是因为您创建了任务,但未将其保存到代码库。

    @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,但需要某种方法来将真实代码库替换为 fragment 的虚假代码库。接下来,您将执行此操作!

在此任务中,您将使用 ServiceLocator 向 fragment 提供模拟代码库。这样,您就可以编写 fragment 和视图模型集成测试。

您无法像之前那样使用构造函数依赖项注入,因为之前需要向视图模型或代码库提供依赖项。构造函数依赖项注入要求您构造类。fragment 和 activity 是您不构造且通常无法访问其构造函数的类的示例。

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

不使用 Service Locator


使用服务定位器

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

  1. 创建一个能够构建和存储仓库的服务定位器类。默认情况下,它会构建“正常”代码库。
  2. 重构代码,以便在需要代码库时使用服务定位器。
  3. 在测试类中,调用服务定位器上的一个方法,该方法会将“正常”代码库替换为测试替身。

第 1 步:创建 ServiceLocator

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

注意:ServiceLocator 是单例,因此请为该类使用 Kotlin object 关键字。

  1. 在主源代码集的顶层创建文件 ServiceLocator.kt 。
  2. 定义一个名为 ServiceLocator 的 object。
  3. 创建 database 和 repository 实例变量,并将两者都设置为 null。
  4. 使用 @Volatile 注释代码库,因为该代码库可能会被多个线程使用(@Volatile 的详细说明请参阅此处)。

您的代码应如下所示。

object ServiceLocator {

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

}

目前,ServiceLocator 唯一需要做的就是知道如何返回 TasksRepository。它会返回预先存在的 DefaultTasksRepository,或者在需要时创建并返回新的 DefaultTasksRepository。

定义以下函数:

  1. provideTasksRepository - 提供现有代码库或创建新代码库。此方法应在 this 上设为 synchronized,以避免在多个线程同时运行的情况下意外创建两个代码库实例。
  2. createTasksRepository - 用于创建新代码库的代码。将调用 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 步:在应用中使用 ServiceLocator

您将对主应用代码(而非测试)进行更改,以便在 ServiceLocator 中一处创建代码库。

请务必只创建一次存储库类实例。为确保这一点,您将在我的 Application 类中使用服务定位器。

  1. 在软件包层次结构的顶层,打开 TodoApplication 并为您的代码库创建 val,然后使用 ServiceLocator.provideTaskRepository 为其分配一个代码库。

TodoApplication.kt

class TodoApplication : Application() {

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

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

现在,您已在应用中创建了代码库,可以移除 DefaultTasksRepository 中的旧 getRepository 方法。

  1. 打开 DefaultTasksRepository 并删除伴生对象。

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。这样可确保您获取的是 ServiceLocator 提供的任何代码库,而不是直接创建代码库。

  1. 打开 TaskDetailFragement,然后在类顶部找到对 getRepository 的调用。
  2. 将此调用替换为从 TodoApplication 获取代码库的调用。

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. 对于 StatisticsViewModel 和 AddEditTaskViewModel,请更新获取代码库的代码,以使用 TodoApplication 中的代码库。

TasksFragment.kt

// REPLACE this code
    private val tasksRepository = DefaultTasksRepository.getRepository(application)



// WITH this code

    private val tasksRepository = (application as TodoApplication).taskRepository

  1. 运行您的应用(而非测试)!

由于您只是重构了代码,因此应用应该可以正常运行,不会出现问题。

第 3 步:Create FakeAndroidTestRepository

您已在测试源集中添加了 FakeTestRepository。默认情况下,您无法在 test 和 androidTest 源代码集之间共享测试类。因此,您需要在 androidTest 源代码集中创建一个重复的 FakeTestRepository 类,并将其命名为 FakeAndroidTestRepository。

  1. 右键点击 androidTest 源集,然后创建数据包。再次点击右键,然后创建源 软件包。
  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 为公开状态的原因是出于测试目的。

ServiceLocator.kt

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

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

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

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

ServiceLocator.kt

private val lock = Any()
  1. 添加一个名为 resetRepository 的测试专用方法,该方法会清除数据库并将代码库和数据库都设置为 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. 添加设置和清理方法,以便在每次测试之前设置 FakeAndroidTestRepository,并在每次测试之后清理 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,但这次,由于您已正确设置了代码库,因此它现在会显示任务信息。


在此步骤中,您将使用 Espresso 界面测试库完成您的第一个集成测试。您已对代码进行结构化处理,因此可以添加包含断言的界面测试。为此,您将使用 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 - 当您创建新的 Android 项目时,系统会默认包含此核心 Espresso 依赖项。它包含针对大多数视图及其操作的基本测试代码。

第 2 步:关闭动画

Espresso 测试在真机上运行,因此本质上属于插桩测试。出现的一个问题是动画:如果动画滞后,并且您尝试测试视图是否在屏幕上,但视图仍在动画处理中,Espresso 可能会意外导致测试失败。这可能会导致 Espresso 测试不稳定。

对于 Espresso 界面测试,最佳实践是关闭动画(这样测试也会运行得更快!):

  1. 在测试设备上,依次前往设置 > 开发者选项。
  2. 停用以下三项设置:窗口动画缩放、过渡动画缩放和 Animator 时长缩放。

第 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。

2. ViewMatcher

withId(R.id.task_detail_title_text)

withId 是 ViewMatcher 的一个示例,用于根据 ID 获取视图。您可以在文档中查找其他视图匹配器。

3. ViewAction

perform(click())

采用 ViewAction 的 perform 方法。ViewAction 是可以对视图执行的操作,例如此处是点击视图。

4. ViewAssertion

check(matches(isChecked()))

check,它接受 ViewAssertion。ViewAssertions 检查或断言视图的某些方面。您最常使用的 ViewAssertion 是 matches 断言。如需完成断言,请使用另一个 ViewMatcher,在本例中为 isChecked。

请注意,您不一定需要在一条 Espresso 语句中同时调用 perform 和 check。您可以编写仅使用 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 语句(如果需要):

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。检查测试结构和 withId 的使用情况,并检查是否已断言详情页应如何显示。
  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()))
    }

在最后一步中,您将学习如何使用一种称为模拟对象的不同类型的测试替身和测试库 Mockito 来测试 Navigation 组件。

在此 Codelab 中,您使用了名为“伪对象”的测试替身。伪对象是多种类型的测试替身之一。您应该使用哪个测试替身来测试 Navigation 组件?

考虑导航的实现方式。假设您按了 TasksFragment 中的某个任务,然后系统会转到任务详情界面。

以下是 TasksFragment 中的代码,用于在按下时导航到任务详情界面。

TasksFragment.kt

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


导航是因为调用了 navigate 方法而发生的。如果您需要编写断言语句,则无法直接测试是否已导航到 TaskDetailFragment。导航是一项复杂的操作,除了初始化 TaskDetailFragment 之外,不会产生明确的输出或状态变化。

您可以断言 navigate 方法已使用正确的 action 参数调用。这正是模拟对象测试替身的作用,它可以检查是否调用了特定方法。

Mockito 是一个用于创建测试替身的框架。虽然 API 和名称中使用了“模拟”一词,但它不仅用于创建模拟对象。它还可以创建桩和间谍。

您将使用 Mockito 创建一个模拟 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 - 此库由外部贡献组成(因此得名),其中包含更高级视图(例如 DatePicker 和 RecyclerView)的测试代码。它还包含无障碍功能检查和名为 CountingIdlingResource 的类(稍后会介绍)。

第 2 步:创建 TasksFragmentTest

  1. 打开 TasksFragment。
  2. 右键点击 TasksFragment 类名称,然后依次选择 Generate 和 Test。在 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 函数创建模拟对象。

TasksFragmentTest.kt

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

如需在 Mockito 中进行模拟,请传入要模拟的类。

接下来,您需要将 NavController 与 fragment 相关联。借助 onFragment,您可以直接在 fragment 上调用方法。

  1. 将新模拟对象设为 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()))

RecyclerViewActions 是 espresso-contrib 库的一部分,可让您对 RecyclerView 执行 Espresso 操作。

  1. 验证 navigate 是否已调用,且参数正确无误。
// THEN - Verify that we navigate to the first detail screen
verify(navController).navigate(
    TasksFragmentDirections.actionTasksFragmentToTaskDetailFragment( "id1")

Mockito 的 verify 方法使这成为模拟对象 - 您可以确认模拟的 navController 调用了具有参数(ID 为“id1”的 actionTasksFragmentToTaskDetailFragment)的特定方法 (navigate)。

完整的测试如下所示:

@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 模拟对象。
  2. 将该模拟的 NavController 附加到 fragment。
  3. 验证是否使用正确的操作和参数调用了 navigate。

第 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)
            )
        )
    }

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

如需下载完成后的 Codelab 代码,您可以使用以下 Git 命令:

$ git clone https://github.com/googlecodelabs/android-testing.git
$ cd android-testing
$ git checkout end_codelab_2


或者,您也可以下载 ZIP 文件形式的代码库,将其解压缩并在 Android Studio 中打开。

下载 Zip 文件

此 Codelab 介绍了如何设置手动依赖项注入、服务定位器,以及如何在 Android Kotlin 应用中使用伪对象和模拟对象。具体而言:

  • 您要测试的内容和测试策略决定了您要为应用实现哪种测试。单元测试重点突出且速度快。集成测试用于验证程序各部分之间的交互。端到端测试用于验证功能,保真度最高,通常会进行插桩,可能需要更长时间才能运行完毕。
  • 应用的架构会影响测试的难度。
  • TDD(测试驱动型开发)是一种先编写测试,然后再创建功能以通过测试的策略。
  • 如需隔离应用的部分内容以进行测试,您可以使用 test doubles。测试替身是专门为测试而打造的类版本。例如,您可以伪造从数据库或互联网获取数据的过程。
  • 使用依赖项注入将真实类替换为测试类,例如,存储库或网络层。
  • 使用插桩测试 (androidTest) 启动界面组件。
  • 如果您无法使用构造函数依赖项注入(例如,要启动 fragment 时),通常可以使用服务定位器。服务定位器模式是依赖项注入的替代方案。它涉及创建一个名为“服务定位器”的单例类,其目的是为常规代码和测试代码提供依赖项。

Udacity 课程:

Android 开发者文档:

视频:

其他:

如需本课程中其他 Codelab 的链接,请参阅“使用 Kotlin 进行高级 Android 开发”Codelab 着陆页。