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

1. 欢迎

简介

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

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

您应该已经知道的知识

您应该熟悉以下内容:

您将学到的知识

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

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

您将执行的操作

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

2. 应用程序概述

在本系列 Codelab 中,您将使用 TO-DO 笔记应用程序。该应用程序允许您记下要完成的任务,并在列表中显示它们。然后,您可以将它们标记为已完成或未完成,筛选它们,或删除它们。

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. 概念:测试策略

在本 Codelab 中,您将学习如何使用测试替身和依赖注入来测试存储库、视图模型和片段。在深入了解它们之前,了解指导您编写这些测试的内容和方式的推理非常重要。

本节介绍一些通用的测试最佳实践,因为它们适用于 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,该 var 是一个 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. 按照提示在**测试**源集中创建 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. 在您的 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. 在**测试**源集中,在**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 更接近于对存储库“真实”实现的模拟,并且更适合 Fakes,以便它们的行為更接近真实实现。

当您在测试类中时,这意味着带有 @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 的构造函数中,将两个数据源作为构造函数依赖项注入。

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

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。它位于 测试 源集中的 任务 文件夹下。
  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 测试 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

接下来,您将编写集成测试来测试您的片段和视图模型交互。您将找出您的视图模型代码是否适当地更新了您的 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 测试中的一个类,它围绕片段进行包装,并使您能够直接控制片段的生命周期以进行测试。若要编写片段的测试,请为要测试的片段创建 FragmentScenarioTaskDetailFragment)。

  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

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

  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

在此任务中,你将使用 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—提供一个已存在的存储库或创建一个新的存储库。此方法应 synchronizedthis 上,以避免在多个线程运行的情况下,意外地创建两个存储库实例。
  2. createTasksRepository—创建新存储库的代码。将调用 createTaskLocalDataSource 并创建一个新的 TasksRemoteDataSource
  3. createTaskLocalDataSource—创建新的本地数据源的代码。将调用 createDataBase
  4. createDataBase—创建新数据库的代码。

完成的代码如下所示。

ServiceLocator.kt

object ServiceLocator {

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

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

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

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

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

步骤 2. 在应用程序中使用 ServiceLocator

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

确保你只创建存储库类的单个实例非常重要。为了确保这一点,你将在 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 的设置器标记为 @VisibleForTesting。此注释是一种表达设置器为公共的原因是由于测试的方法。

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 测试在真实设备上运行,因此本质上是 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)

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

  1. ViewAction
perform(click())

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

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

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

e26de7f5db091867.png

请注意,您并不总是会在 Espresso 语句中同时调用 performcheck。您可以拥有仅使用 check 进行断言的语句,或者仅使用 perform 执行 ViewAction

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

TaskDetailFragmentTest.kt

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

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

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

以下是导入语句(如果需要)

import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isChecked
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import org.hamcrest.core.IsNot.not
  1. // THEN 注释之后的所有内容都使用 Espresso。检查测试结构以及 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 与片段关联。 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. 验证是否已使用正确的操作和参数调用了导航。

步骤 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. 了解更多

示例

  • 官方测试示例 - 这是官方测试示例,它基于这里使用的相同待办事项应用程序。本示例中的概念超出了三个测试 codelab 中涵盖的内容。
  • Sunflower 演示 - 这是主要 Android Jetpack 示例,它也使用了 Android 测试库
  • Espresso 测试示例

Udacity 课程

Android 开发者文档

视频

其他

15. 下一个 codelab

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