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

1. 欢迎

简介

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

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

您应该已了解的内容

您应该熟悉:

您将学习的内容

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

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

您将执行的操作

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

2. 应用概述

在本系列 Codelab 中,您将使用待办事项应用程序。该应用允许您记下要完成的任务,并将其显示在列表中。然后,您可以将它们标记为已完成或未完成,对其进行筛选或删除它们。

e490df637e1bf10c.gif

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

下载代码

要开始使用,请下载代码:

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

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

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

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

步骤 1:运行示例应用

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

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

483916536f10c42a.png

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

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

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

f2e425a052f7caf7.png

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

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

.addedittask

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

.data

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

.statistics

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

.taskdetail

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

.tasks

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

.util

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

数据层 (.data)

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

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

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

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

导航

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

3. 概念:测试策略

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

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

测试金字塔

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

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

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

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

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

7017a2dd290e68aa.png

架构和测试

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

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

f2e425a052f7caf7.png

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

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

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

4. 任务:创建一个伪造的数据源

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

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

您的目标是只测试该类中的代码。但是,DefaultTasksRepository 依赖于其他类,例如 TasksLocalDataSourceTasksRemoteDataSource 才能运行。换句话说,TasksLocalDataSourceTasksRemoteDataSourceDefaultTasksRepository依赖项

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

518a4ea76fcb835a.png

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

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

getTasks 是您可能对存储库进行的最“基本”的调用之一。此方法包括从 SQLite 数据库读取和进行网络调用(对 updateTasksFromRemoteDataSource 的调用)。这涉及比仅仅是存储库代码多得多的代码。

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

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

测试替身

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

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

伪造的

具有类“工作”实现的测试替身,但其实现方式使其适合测试但不适合生产。“工作”实现意味着该类将在给定输入的情况下产生现实的输出。

模拟的

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

存根的

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

虚拟的

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

间谍的

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

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

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

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

步骤 1:创建 FakeDataSource 类

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

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

efdc92ba8079ed1.png

  1. 创建一个包含source包的data包。
  2. data/source包中创建一个名为FakeDataSource的新类。

46428a328ea88457.png

步骤 2:实现 TasksDataSource 接口

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

688533d909b2330b.png

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

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

}

Android Studio 会抱怨您尚未实现 TasksDataSource 的必需方法。

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

890b25398497ec8d.png

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

61433018aef0bb29.png

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

FakeDataSource 是一种称为伪造的特定类型的测试替身。伪造的测试替身具有类的“工作”实现,但其实现方式使其适合测试但不适合生产。“工作”实现意味着该类将在给定输入的情况下产生现实的输出。

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

一个 FakeDataSource

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

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

编写这些方法的模拟版本

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

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

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


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

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

如果需要,这里有一些导入语句

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

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

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

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

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

目前,依赖项是在DefaultTasksRepositoryinit方法中构造的。

DefaultTasksRepository.kt

class DefaultTasksRepository private constructor(application: Application) {

    private val tasksRemoteDataSource: TasksDataSource
    private val tasksLocalDataSource: TasksDataSource

   // Some other code

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

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

因为您是在DefaultTasksRepository内部创建和赋值taskLocalDataSourcetasksRemoteDataSource,所以它们本质上是硬编码的。无法替换您的模拟测试对象。

您要做的相反的事情是**提供**这些数据源到类中,而不是对它们进行硬编码。提供依赖项被称为**依赖注入**。有多种提供依赖项的方法,因此也有不同类型的依赖注入。

**构造函数依赖注入**允许您通过将其传递到构造函数中来替换模拟测试对象。

**无注入**

**注入**

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

  1. DefaultTasksRepository的构造函数从接收Application更改为接收两个数据源和协程调度器。

DefaultTasksRepository.kt

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

// WITH

class DefaultTasksRepository(
    private val tasksRemoteDataSource: TasksDataSource,
    private val tasksLocalDataSource: TasksDataSource,
    private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO) { // Rest of class }
  1. 因为您已传入依赖项,所以删除init方法。您不再需要创建依赖项。
  2. 还要删除旧的实例变量。您将在构造函数中定义它们。

DefaultTasksRepository.kt

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

DefaultTasksRepository.kt

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

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

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

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

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

  1. 右键单击DefaultTasksRepository类名,选择**生成**,然后选择**测试**。
  2. 按照提示在**test**源集中创建DefaultTasksRepositoryTest
  3. 在新的DefaultTasksRepositoryTest类顶部,添加下面的成员变量以表示模拟数据源中的数据。

DefaultTasksRepositoryTest.kt

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

DefaultTasksRepositoryTest.kt

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

    // Class under test
    private lateinit var tasksRepository: DefaultTasksRepository

创建一个方法来设置和初始化可测试的DefaultTasksRepository。这个DefaultTasksRepository将使用您的模拟测试对象FakeDataSource

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

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

DefaultTasksRepositoryTest.kt

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

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

是时候编写DefaultTasksRepository测试了!

  1. 为仓库的getTasks方法编写一个测试。检查当您使用true调用getTasks(意味着它应该从远程数据源重新加载)时,它是否返回来自远程数据源(而不是本地数据源)的数据。

DefaultTasksRepositoryTest.kt

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

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

当您调用时,您将收到错误

getTasks

bb35045989edbd3f.png

步骤 4:添加 runBlockingTest

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

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

app/build.gradle

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

别忘了同步!

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

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

  1. 在类上方添加@ExperimentalCoroutinesApi。这表示您知道您正在类中使用实验性协程 api(runBlockingTest)。如果没有它,您将收到警告。
  2. 回到您的DefaultTasksRepositoryTest中,添加runBlockingTest,以便它将您的整个测试作为代码“块”接收。

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

DefaultTasksRepositoryTest.kt

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


@ExperimentalCoroutinesApi
class DefaultTasksRepositoryTest {

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

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

    // Class under test
    private lateinit var tasksRepository: DefaultTasksRepository

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

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

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

}
  1. 运行新的getTasks_requestsAllTasksFromRemoteDataSource测试并确认它有效且错误已消失!

6. 任务:设置模拟仓库

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

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

因此,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内使用模拟类。使用构造函数依赖注入,通过添加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。它位于test源集的tasks文件夹下。
  2. TasksViewModelTest中添加一个FakeTestRepository属性。

TaskViewModelTest.kt

@RunWith(AndroidJUnit4::class)
class TasksViewModelTest {

    // Use a fake repository to be injected into the viewmodel
    private lateinit var tasksRepository: FakeTestRepository
    
    // Rest of class
}
  1. 更新setupViewModel方法以创建具有三个任务的FakeTestRepository,然后使用此存储库构造tasksViewModel

TasksViewModelTest.kt

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

        tasksViewModel = TasksViewModel(tasksRepository)
        
    }
  1. 因为你不再使用AndroidX Test ApplicationProvider.getApplicationContext代码,你也可以删除@RunWith(AndroidJUnit4::class)注解。
  2. 运行你的测试,确保它们都仍然有效!

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

步骤3. 也更新TaskDetailFragment和ViewModel

TaskDetailFragmentTaskDetailViewModel进行相同的更改。这将为你在下一步编写TaskDetail测试做准备。

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

TaskDetailViewModel.kt

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

    private val tasksRepository = DefaultTasksRepository.getRepository(application)

    // Rest of class
}

// WITH

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

TaskDetailViewModel.kt

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

TaskDetailFragment.kt

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

// WITH

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

你现在能够在TasksFragmentTaskDetailFragment中使用FakeTestRepository来代替真实的存储库。

8. 任务:从测试启动Fragment

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

  • 服务定位器模式
  • 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 函数使用此 bundle 和主题创建一个 FragmentScenario

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

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

应该会发生一些事情。

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

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

d14df7b104bdafe.png

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

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

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

    }

你拥有这个 FakeTestRepository,但是你需要某种方法将你的真实存储库替换为你用于片段的模拟存储库。接下来你将执行此操作!

9. 任务:创建 ServiceLocator

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

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

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

**不使用服务定位器**

**使用服务定位器**

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

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

步骤 1. 创建 ServiceLocator

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

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

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

你的代码应如下所示。

object ServiceLocator {

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

}

目前,你的 ServiceLocator 只需要知道如何返回 TasksRepository。它将返回预先存在的 DefaultTasksRepository,或者根据需要创建并返回新的 DefaultTasksRepository

定义以下函数:

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

完整的代码如下。

ServiceLocator.kt

object ServiceLocator {

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

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

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

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

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

步骤 2. 在应用程序中使用 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. 添加一个设置方法和一个拆卸方法,以便在每次测试之前设置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—此核心 Espresso 依赖项在您创建新的 Android 项目时默认包含。它包含大多数视图及其操作的基本测试代码。

步骤 2. 关闭动画

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

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

  1. 在您的测试设备上,转到**设置 > 开发者选项**。
  2. 禁用以下三个设置:**窗口动画比例**、**转换动画比例**和**动画持续时间比例**。

192483c9a6e83a0.png

步骤 3. 查看 Espresso 测试

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

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

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

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

  1. 静态 Espresso 方法
onView

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

  1. ViewMatcher
withId(R.id.task_detail_title_text)

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

  1. ViewAction
perform(click())

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和名称中使用了“mock”一词,但这**并非**仅仅用于创建模拟对象。它还可以创建存根和间谍。

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

步骤1. 添加Gradle依赖项

  1. 添加gradle依赖项。

app/build.gradle

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

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

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



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

步骤2. 创建TasksFragmentTest

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

TasksFragmentTest.kt

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

    private lateinit var repository: TasksRepository

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

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

}

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

  1. 添加测试clickTask_navigateToDetailFragmentOne

TasksFragmentTest.kt

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

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

TasksFragmentTest.kt

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

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

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

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

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

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

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

完整的测试如下所示

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

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

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


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

总而言之,要测试导航,您可以

  1. 使用Mockito创建一个NavController模拟对象。
  2. 将该模拟的NavController附加到片段。
  3. 验证是否使用正确的操作和参数调用了navigate。

步骤3. 可选,编写clickAddTaskButton_navigateToAddEditFragment

要查看您是否可以自己编写导航测试,请尝试此任务。

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

答案如下。

TasksFragmentTest.kt

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

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

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

12. 解决方案代码

单击此处以查看您开始的代码和最终代码之间的差异。

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

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

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

13. 摘要

此codelab介绍了如何在Android Kotlin应用程序中设置手动依赖项注入、服务定位器以及如何使用模拟和存根。特别是

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

14. 了解更多

示例

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

Udacity课程

Android开发者文档

视频

其他

15. 下一个codelab

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