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

1. 欢迎

简介

第二个测试代码实验室将重点介绍测试替身:何时在 Android 中使用它们,以及如何使用依赖注入、服务定位器模式和库来实现它们。在此过程中,您将学习如何编写

  • 仓库单元测试
  • 片段和视图模型集成测试
  • 片段导航测试

您应该已经了解的内容

您应该熟悉

您将学到的知识

  • 如何规划测试策略
  • 如何创建和使用测试替身,即模拟和存根
  • 如何在 Android 上使用手动依赖注入进行单元测试和集成测试
  • 如何应用服务定位器模式
  • 如何测试仓库、片段、视图模型和导航组件

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

您将执行的操作

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

2. 应用程序概述

在本系列代码实验室中,您将使用 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 应用程序基于 架构蓝图 测试和架构示例。该应用程序遵循来自 应用程序架构指南 的架构。它使用带有片段的视图模型、仓库和 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 层包都包含一个片段和一个视图模型,以及 UI 所需的任何其他类(例如任务列表的适配器)。TaskActivity 是包含所有片段的活动。

导航

应用程序的导航由 导航组件 控制。它在 nav_graph.xml 文件中定义。导航是在视图模型中使用 Event 类触发的;视图模型还确定要传递哪些参数。片段观察 Event 并执行屏幕之间的实际导航。

3. 概念:测试策略

在本代码实验室中,您将学习如何使用测试替身和依赖注入测试仓库、视图模型和片段。在深入了解它们是什么之前,了解指导您编写这些测试的原因至关重要。

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

测试金字塔

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

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

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

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

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

7017a2dd290e68aa.png

架构和测试

您在测试金字塔的不同层面上测试应用程序的能力与您的 **应用程序架构** 密切相关。例如,一个 **非常** 架构不良的应用程序可能会将所有逻辑都放在一个方法中。您可能能够为此编写端到端测试,因为这些测试往往测试应用程序的大部分内容,但是如何编写单元测试或集成测试呢?如果所有代码都在一个地方,就很难只测试与单个单元或功能相关的代码。

更好的方法是将应用程序逻辑分解成多个方法和类,从而允许独立测试每个部分。架构是一种划分和组织代码的方式,它可以更轻松地进行单元测试和集成测试。您将要测试的 TO-DO 应用程序遵循特定的架构。

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 的调用)。这涉及到比 **仅仅** 存储库代码更多代码。

以下是一些测试存储库更困难的具体原因:

  • 您需要考虑创建和管理数据库才能完成此存储库的甚至是最简单的测试。这会提出诸如“这应该是本地测试还是仪器测试?”以及是否应该使用 AndroidX Test 来获取模拟 Android 环境之类的问题。
  • 代码的某些部分,例如网络代码,可能需要很长时间才能运行,或者偶尔还会失败,从而导致长时间运行的、不稳定的测试。
  • 您的测试可能会失去诊断哪个代码对测试失败负有责任的能力。您的测试可能会开始测试非存储库代码,因此,例如,您的“存储库”单元测试可能会由于某些依赖代码(例如数据库代码)中的问题而失败。

测试替身

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

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

具有类的“工作”实现的测试替身,但其实现方式使其适合测试,但不适合生产环境。 “工作”实现是指该类在给定输入时会产生现实的输出。

模拟

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

存根

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

虚拟

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

间谍

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

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

Android 中最常用的测试替身是 **假** 和 **模拟**。

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

步骤 1:创建 FakeDataSource 类

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

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

efdc92ba8079ed1.png

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

46428a328ea88457.png

步骤 2:实现 TasksDataSource 接口

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

688533d909b2330b.png

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

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

}

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

  1. 使用快速修复菜单并选择 **实现成员**。

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. 实例化您的 tasksRepository,使用您刚刚创建的两个伪数据源以及 Dispatchers.Unconfined

最终方法应类似于以下代码。

DefaultTasksRepositoryTest.kt

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

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

是时候编写 DefaultTasksRepository 测试了!

  1. 为存储库的 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. 在您的 DefaultTasksRepositoryTest 中,添加 @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. 任务:设置一个伪存储库

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

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

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

在本任务中,您将依赖注入应用于视图模型。

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 中使用一个伪造类。使用构造函数依赖注入,通过在 TasksViewModel 的构造函数中添加一个 TasksRepository 变量,通过构造函数依赖注入传入两个数据源。

此过程在视图模型中略有不同,因为你不会直接构造它们。例如

class TasksFragment : Fragment() {

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

}

如上面的代码所示,你正在使用 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 构造方式的标准方法。现在你有了工厂,在任何构造视图模型的地方使用它。

  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。它位于 tasks 文件夹下的 test 源集。
  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. 任务:从测试中启动片段

接下来,你将编写集成测试来测试你的片段和视图模型的交互。你将找出你的视图模型代码是否适当地更新了你的 UI。为此,你将使用

  • ServiceLocator 模式
  • Espresso 和 Mockito 库

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

7017a2dd290e68aa.png

在本例中,你将采用每个片段并编写片段和视图模型的集成测试,以测试片段的主要功能。

步骤 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 函数创建一个 FragmentScenario,包含此 bundle 和一个主题。

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

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

应该会发生一些事情。

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

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

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 将您的伪造存储库提供给您的片段。这将允许您编写片段和视图模型集成测试。

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

由于您没有构造片段,因此您无法使用构造函数依赖注入将存储库测试双重(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—提供已存在的存储库或创建一个新的存储库。此方法应该在 thissynchronized,以避免在多个线程运行的情况下,意外创建两个存储库实例。
  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 为公共的原因是由于测试的注解。

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. 添加一个 setup 和 teardown 方法,在每个测试之前设置一个 FakeAndroidTestRepository,并在每个测试之后清理它。

TaskDetailFragmentTest.kt

    private lateinit var repository: TasksRepository

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

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

最终的测试看起来像下面的代码。

TaskDetailFragmentTest.kt

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

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

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

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

TaskDetailFragmentTest.kt

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

    private lateinit var repository: TasksRepository

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

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


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

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

    }

}
  1. 运行 activeTaskDetails_DisplayedInUi() 测试。

与之前非常相似,你应该可以看到 Fragment,但这一次,因为你正确地设置了仓库,它现在显示了任务信息。

928dc8f5392a5823.png

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

在这个步骤中,你将使用 Espresso UI 测试库来完成你的第一个集成测试。你已经构建了代码,因此你可以添加带有断言的 UI 测试。为此,你将使用 Espresso 测试库

Espresso 可以帮助你

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

步骤 1. 注意 Gradle 依赖项

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

app/build.gradle

dependencies {

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

androidx.test.espresso:espresso-core—当你创建一个新的 Android 项目时,默认情况下会包含这个核心的 Espresso 依赖项。它包含针对大多数视图及其上的操作的基本测试代码。

步骤 2. 关闭动画

Espresso 测试在真实设备上运行,因此本质上是 Instrumentation 测试。出现的一个问题是动画:如果动画滞后,并且你试图测试视图是否在屏幕上,但它仍在动画中,Espresso 可能会意外地导致测试失败。这会使 Espresso 测试变得不稳定。

对于 Espresso UI 测试,最好关闭动画(你的测试也将运行得更快!)。

  1. 在你的测试设备上,转到 设置 > 开发者选项
  2. 禁用以下三个设置:窗口动画缩放比例过渡动画缩放比例动画持续时间缩放比例

192483c9a6e83a0.png

步骤 3. 查看 Espresso 测试

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

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

此语句的作用是查找 ID 为 task_detail_complete_checkbox 的复选框视图,单击它,然后断言它被选中。

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

  1. 静态 Espresso 方法
onView

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

  1. ViewMatcher
withId(R.id.task_detail_title_text)

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

  1. ViewAction
perform(click())

The perform method which takes a ViewAction. A ViewAction is something that can be done to the view, for example here, it's clicking the view.

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

check which takes a ViewAssertion. ViewAssertions check or assert something about the view. The most common ViewAssertion you'll use is the matches assertion. To finish the assertion, use another ViewMatcher, in this case isChecked.

e26de7f5db091867.png

请注意,你并不总是需要在 Espresso 语句中同时调用 performcheck。你可以只使用 check 来执行断言的语句,或者只使用 perform 来执行 ViewAction

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

TaskDetailFragmentTest.kt

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

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

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

以下是需要的导入语句

import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isChecked
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import org.hamcrest.core.IsNot.not
  1. // THEN 注释之后的代码都使用 Espresso。检查测试结构和 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 方法已使用正确的操作参数被调用。这正是 **模拟** 测试替身所做的——它检查特定方法是否被调用。

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

你将使用 Mockito 创建一个模拟 NavigationController,它可以断言导航方法被正确调用。

步骤 1. 添加 Gradle 依赖项

  1. 添加 Gradle 依赖项。

app/build.gradle

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

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

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



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

步骤 2. 创建 TasksFragmentTest

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

TasksFragmentTest.kt

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

    private lateinit var repository: TasksRepository

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

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

}

此代码与你编写的 TaskDetailFragmentTest 代码类似。它设置并拆卸了 FakeAndroidTestRepository。添加一个导航测试,以测试当你单击任务列表中的任务时,它是否会将你带到正确的 TaskDetailFragment

  1. 添加测试 clickTask_navigateToDetailFragmentOne

TasksFragmentTest.kt

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

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

TasksFragmentTest.kt

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

要在 Mockito 中进行模拟,请传入你想要模拟的类。

接下来,你需要将你的 NavController 与 Fragment 关联起来。 onFragment 使你可以对 Fragment 本身调用方法。

  1. 使你的新模拟对象成为 Fragment 的 NavController
scenario.onFragment {
    Navigation.setViewNavController(it.view!!, navController)
}
  1. 添加代码以单击文本为“TITLE1”的 RecyclerView 中的项目。
// 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 使用参数 (actionTasksFragmentToTaskDetailFragment,其 ID 为“id1”) 调用了特定方法 (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)
            )
        )
    }

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 应用程序中使用模拟和存根。特别是

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

14. 了解更多

示例

  • 官方测试示例 - 这是官方的测试示例,它基于此处使用的相同 TO-DO Notes 应用程序。本示例中的概念超出了三个测试 codelab 中涵盖的内容。
  • 向日葵演示 - 这是主要的 Android Jetpack 示例,它也使用 Android 测试库
  • Espresso 测试示例

Udacity 课程

Android 开发者文档

视频

其他

15. 下一个 codelab

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