Kotlin 高级 Android 开发 05.2:测试替身和依赖注入简介

1. 欢迎

简介

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

  • 资源库单元测试
  • 片段和 ViewModel 集成测试
  • 片段导航测试

您应该已经了解的内容

您应该熟悉:

您将学习的内容

  • 如何规划测试策略
  • 如何创建和使用测试替身,即模拟对象和桩对象
  • 如何在 Android 中为单元测试和集成测试使用手动依赖注入
  • 如何应用服务定位器模式
  • 如何测试资源库、片段、ViewModel 和导航组件

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

您将执行的操作

  • 使用测试替身和依赖注入编写资源库的单元测试。
  • 使用测试替身和依赖注入编写 ViewModel 的单元测试。
  • 使用 Espresso UI 测试框架编写片段及其 ViewModel 的集成测试。
  • 使用 Mockito 和 Espresso 编写导航测试。

2. 应用概述

在这个 Codelab 系列中,您将使用 TO-DO Notes 应用。该应用允许您记下要完成的任务,并将其显示在一个列表中。然后,您可以将它们标记为已完成或未完成、对其进行筛选或删除它们。

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:运行示例应用

下载 TO-DO 应用后,在 Android Studio 中打开并运行它。它应该可以编译。通过执行以下操作来探索该应用:

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

483916536f10c42a.png

步骤 2:浏览示例应用代码

TO-DO 应用基于架构蓝图测试和架构示例。该应用遵循应用架构指南中的架构。它使用带有片段的 ViewModel、资源库和 Room。如果您熟悉以下任何示例,则此应用具有类似的架构:

您理解应用的总体架构比深入理解任何一层中的逻辑更重要。

f2e425a052f7caf7.png

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

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

.addedittask

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

.data

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

.statistics

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

.taskdetail

任务详细信息屏幕:单个任务的 UI 层代码。

.tasks

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

.util

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

数据层 (.data)

此应用包含一个模拟网络层(位于remote包中)和一个数据库层(位于local包中)。为简便起见,在此项目中,网络层仅使用带有延迟的HashMap进行模拟,而不是发出实际的网络请求。

DefaultTasksRepository协调或仲裁网络层和数据库层之间的关系,并向 UI 层返回数据。

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

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

导航

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

3. 概念:测试策略

在本 Codelab 中,您将学习如何使用测试替身和依赖注入测试资源库、ViewModel 和片段。在深入了解这些内容之前,了解指导您编写这些测试的原因非常重要。

本节介绍一些通用的测试最佳实践,因为它们适用于 Android。

测试金字塔

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

  • 范围—测试涉及多少代码?测试可以在单个方法上运行,也可以跨整个应用程序运行,或者介于两者之间。
  • 速度—测试运行速度如何?测试速度从毫秒到几分钟不等。
  • 保真度—测试有多“真实”?例如,如果您正在测试的代码的一部分需要发出网络请求,那么测试代码是否实际发出此网络请求,或者是否模拟结果?如果测试实际与网络通信,则表示它具有更高的保真度。权衡是测试运行时间可能更长,如果网络中断可能会导致错误,或者使用成本可能很高。

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

  • 单元测试—这些测试高度聚焦于单个类,通常是类中的单个方法。如果单元测试失败,您可以准确地知道代码中的哪个位置出现问题。它们的保真度较低,因为在现实世界中,您的应用包含的内容远不止执行一个方法或类。它们速度足够快,可以在每次更改代码时运行。它们最常在本地运行测试(在 test 源集内)。示例:测试视图模型和资源库中的单个方法。
  • 集成测试—这些测试会测试多个类的交互,以确保它们在组合使用时按预期运行。构建集成测试的一种方法是让它们测试单个功能,例如保存任务的能力。与单元测试相比,它们测试的代码范围更大,但仍然针对快速运行进行了优化,而不是具有完全保真度。它们可以根据情况在本地运行或作为 Instrumentation 测试运行。示例:测试单个片段和视图模型对的所有功能。
  • 端到端测试 (E2e)—测试组合在一起工作的功能。它们测试应用程序的大部分内容,模拟真实的用法,因此通常速度较慢。它们具有最高的保真度,并告诉您您的应用程序作为一个整体实际上是否有效。这些测试通常是 Instrumentation 测试(在 androidTest 源集内)。示例:启动整个应用程序并一起测试一些功能。

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

7017a2dd290e68aa.png

架构和测试

您在测试金字塔的所有不同级别测试应用程序的能力与您的应用程序架构密切相关。例如,架构极其糟糕的应用程序可能会将其所有逻辑都放在一个方法中。您或许可以为此编写端到端测试,因为这些测试倾向于测试应用程序的大部分内容,但是编写单元测试或集成测试呢?由于所有代码都位于一个位置,因此很难仅测试与单个单元或功能相关的代码。

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

f2e425a052f7caf7.png

在本课程中,您将学习如何正确隔离地测试上述架构的各个部分。

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

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

4. 任务:创建虚假数据源

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

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

您的目标是仅测试该类中的代码。但是,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 是您可能对资源库进行的最“基本”调用之一。此方法包括从 SQLite 数据库读取和进行网络调用(对 updateTasksFromRemoteDataSource 的调用)。这涉及的代码远不止仅仅是资源库代码。

以下是测试资源库很困难的一些更具体的原因

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

测试替身

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

以下是几种类型的测试替身

伪造的

具有类“有效”实现的测试替身,但它的实现方式使其适合测试但不适合生产。“有效”实现意味着该类将根据输入产生真实的输出。

模拟的

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

存根的

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

虚拟的

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

间谍的

还跟踪某些附加信息的测试替身;例如,如果您制作了一个 SpyTaskRepository,它可能会跟踪 addTask 方法被调用的次数。

有关测试替身的更多信息,请查看 厕所上的测试:了解您的测试替身

Android 中最常用的测试替身是伪造的模拟的

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

步骤 1:创建 FakeDataSource 类

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

  1. test 源集中,右键单击选择新建 -> 包

efdc92ba8079ed1.png

  1. 创建一个包含source 包的data 包。
  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. 使用快速修复菜单并选择实现成员

890b25398497ec8d.png

  1. 选择所有方法并按确定

61433018aef0bb29.png

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

FakeDataSource 是一种称为伪造的特定类型的测试替身。伪造是一个具有类“有效”实现的测试替身,但它的实现方式使其适合测试但不适合生产。“有效”实现意味着该类将根据输入产生真实的输出。

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

一个FakeDataSource

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

这是“伪造”数据库或服务器响应的任务列表。目前,目标是测试**存储库**的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,但尚不清楚如何在测试中使用它。它需要替换TasksRemoteDataSourceTasksLocalDataSource,但仅在测试中。 TasksRemoteDataSourceTasksLocalDataSource都是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类名,然后选择**生成**,然后选择**测试**。
  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成员变量(每个存储库的数据源一个)和一个将要测试的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. 使用你刚刚创建的两个伪造数据源和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

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。你将在本系列的下一个代码实验室中了解有关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. 任务:设置一个模拟仓库 (Fake Repository)

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

单元测试应该*只*测试你感兴趣的类或方法。这被称为在**隔离**状态下进行测试,你清楚地隔离你的“单元”,并且只测试该单元中的一部分代码。

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

在这个任务中,你将依赖注入应用于 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 并**右键单击**类名。然后选择**重构 -> 抽取 -> 接口**。

638b33aa1d3fe91d.png

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

76daf401b9f0bb9c.png

  1. 在**抽取接口**窗口中,将接口名称更改为 TasksRepository
  2. 在**构成接口的成员**部分,选中所有成员,**除了**两个伴生成员和**私有**方法。

d97bdbdf2478fe24.png

  1. 单击**重构**。新的 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. 将鼠标悬停在错误上,直到看到建议菜单,然后单击并选择**实现成员**。
  2. 选择所有方法并按确定

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 更接近于仓库“真实”实现的行为模拟,并且对于模拟对象来说更可取,这样它们的行为更接近于真实的实现。

当你在测试类中时,也就是带有 @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 的方法

在测试时,最好在你的仓库中已经有几个 Tasks。你可以多次调用 saveTask,但为了简化操作,可以添加一个专门用于测试的辅助方法,让你可以添加任务。

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

FakeTestRepository.kt

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

此时,你已经有了一个用于测试的模拟仓库,并实现了一些关键方法。接下来,在你的测试中使用它!

7. 任务:在 ViewModel 中使用模拟仓库

在这个任务中,你将在 ViewModel 中使用模拟类。使用构造函数依赖注入,通过添加 TasksRepository 变量到 TasksViewModel 的构造函数中,通过构造函数依赖注入来接收两个数据源。

这个过程在 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 文件的底部,类的外部,添加一个 TasksViewModelFactory,它接收一个普通的 TasksRepository

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 测试中使用模拟仓库,而不是使用真实的仓库。

  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,然后使用此仓库构造 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. 也更新 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. 运行你的代码,并确保一切正常工作。

你现在可以使用 FakeTestRepository 来代替 TasksFragmentTaskDetailFragment 中的真实仓库。

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

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

  • 服务定位器模式
  • Espresso 和 Mockito 库

**集成测试**测试多个类的交互,以确保它们在组合使用时按预期工作。这些测试可以在本地运行(test 源集)或作为 Instrumentation 测试运行(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 测试库
  • kotlinx-coroutines-test—协程测试库
  • androidx.fragment:fragment-testing—用于在测试中创建片段并更改其状态的 AndroidX 测试库。

由于您将在 androidTest 源集里使用这些库,因此请使用 androidTestImplementation 将其添加为依赖项。

步骤 2. 创建 TaskDetailFragmentTest 类

TaskDetailFragment 显示单个任务的信息。

dae7832a0afea061.png

您将首先为 TaskDetailFragment 编写片段测试,因为它与其他片段相比功能比较基础。

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

d1f60b80b9a92218.png

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

TaskDetailFragmentTest.kt

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

}

这些注解的目的是

步骤 3. 从测试启动片段

在此任务中,您将使用 AndroidX 测试库 启动 TaskDetailFragmentFragmentScenario 是 AndroidX Test 中的一个类,它围绕片段进行封装,并为您提供直接控制片段生命周期以进行测试的方法。要为片段编写测试,您可以为要测试的片段 (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,它表示传递到片段的任务的片段参数。
  • launchFragmentInContainer 函数使用此 bundle 和主题创建一个 FragmentScenario

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

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

应该会发生一些事情。

  • 首先,因为这是一个 Instrumentation 测试,所以测试将在您的物理设备(如果已连接)或模拟器上运行。
  • 它应该启动片段。
  • 注意它如何不导航到任何其他片段或与活动关联的任何菜单 - 它只是片段。

最后,仔细观察并注意片段显示“无数据”,因为它没有成功加载任务数据。

d14df7b104bdafe.png

您的测试既需要加载 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,但是您需要某种方法将您的真实存储库替换为您的片段的伪造存储库。您接下来将执行此操作!

9. 任务:创建 ServiceLocator

在此任务中,您将使用 ServiceLocator 向片段提供伪造存储库。这将允许您编写片段和视图模型集成测试。

您不能在这里使用构造函数依赖注入,就像您之前需要向视图模型或存储库提供依赖项时那样。构造函数依赖注入要求您 *构造* 类。片段和活动是您不构造且通常无法访问其构造函数的类的示例。

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

不使用服务定位器

使用服务定位器

对于此 codelab 应用程序,请执行以下操作

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

步骤 1. 创建 ServiceLocator

让我们创建一个 ServiceLocator 类。它将与其他应用程序代码一起位于主源集中,因为它由主应用程序代码使用。

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

  1. 在主源集的顶层创建文件 **ServiceLocator.kt**。
  2. 定义一个名为 ServiceLocatorobject
  3. 创建 databaserepository 实例变量并将两者都设置为 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. 在 Application 中使用 ServiceLocator

您将对主应用程序代码(而不是您的测试)进行更改,以便在一个地方(您的 ServiceLocator)创建存储库。

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

  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. 对于StatisticsViewModelAddEditTaskViewModel,更新获取仓库的代码,以便使用来自TodoApplication的仓库。
// 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,该方法清除数据库并将仓库和数据库都设置为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并在每次测试之后清理它。

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. 在启动片段之前,将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()测试。

与之前一样,你应该会看到片段,但这次,因为你正确地设置了仓库,所以它现在显示了任务信息。

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—当你创建一个新的Android项目时,此核心Espresso依赖项默认包含在内。它包含大多数视图及其操作的基本测试代码。

步骤2. 关闭动画

Espresso测试在真实设备上运行,因此本质上是仪器测试。出现的一个问题是动画:如果动画滞后,并且你尝试测试视图是否在屏幕上,但它仍在动画播放中,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())

接受ViewActionperform方法。ViewAction是可以对视图执行的操作,例如这里,它正在单击视图。

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

接受ViewAssertioncheckViewAssertion检查或断言视图的某些内容。你将使用的最常见的ViewAssertionmatches断言。要完成断言,请使用另一个ViewMatcher,在本例中为isChecked

e26de7f5db091867.png

请注意,你并不总是同时调用performcheck来创建Espresso语句。你可以创建只使用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。检查测试结构以及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()))
    }

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

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

在这个代码实验室中,你使用了一种称为模拟的测试替身。模拟是许多测试替身类型中的一种。你应该使用哪种测试替身来测试导航组件

考虑一下导航是如何发生的。想象一下,按下TasksFragment中的一个任务以导航到任务详细信息屏幕。

920d31294d1cef2e.png

这是TasksFragment中在按下时导航到任务详细信息屏幕的代码。

TasksFragment.kt

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

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

您可以断言的是,navigate方法是用正确的action参数调用的。这正是**模拟**测试替身所做的——它检查是否调用了特定方法。

Mockito是一个用于创建测试替身的框架。虽然API和名称中使用了mock这个词,但这**并非**仅仅用于创建模拟对象。它也可以创建存根和间谍。

您将使用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——这个库是由外部贡献(因此得名)组成的,其中包含针对更高级视图(如DatePickerRecyclerView)的测试代码。它还包含辅助功能检查和名为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与片段关联。onFragment允许您在片段本身调用方法。

  1. 将您的新模拟对象设置为片段的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方法使这成为一个模拟——您可以确认模拟的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模拟对象。
  2. 将该模拟的NavController附加到片段。
  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)
            )
        )
    }

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应用程序中设置手动依赖项注入、服务定位器以及如何在Android Kotlin应用程序中使用模拟和存根。特别是

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

14. 了解更多

示例

  • 官方测试示例 - 这是官方测试示例,它基于此处使用的相同的待办事项应用程序。此示例中的概念超出了三个测试codelab中介绍的内容。
  • 向日葵演示 - 这是主要的Android Jetpack示例,它也使用了Android测试库
  • Espresso 测试示例

Udacity 课程

Android 开发者文档

视频

其他

15. 下一个 codelab

开始下一课:5.3:测试主题概述