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

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

简介

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

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

您应当已掌握的内容

您应熟悉以下内容/操作:

学习内容

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

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

您将执行的操作

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

在这一系列 Codelab 中,您将使用 TO-DO Notes 应用。该应用允许您编写任务以完成任务,并以列表形式显示这些任务。然后,您可以将这些任务标记为“已完成”、过滤或将其删除。

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

下载代码

首先,请下载代码:

下载 Zip 文件

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

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

请按以下说明花时间熟悉一下代码。

第 1 步:运行示例应用

下载 TO-DO 应用后,在 Android Studio 中打开它并运行它。它应该编译。执行以下操作,浏览该应用:

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

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

“待完成”应用基于热门的架构蓝图测试和架构示例(使用示例的响应式架构版本)。应用遵循应用架构指南中的架构。它将 ViewModel 与 Fragment、存储库和 Room 一起使用。如果您熟悉以下任一示例,这款应用都有类似的架构:

更重要的是了解应用的一般架构,而不是深入了解任一层的逻辑。

以下是软件包摘要:

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

.addedittask

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

.data

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

.statistics

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

.taskdetail

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

.tasks

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

.util

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

数据层 (.data)

该应用包含模拟网络层(位于 remote 软件包中)和数据库层(位于 local 软件包中)。为简单起见,在此项目中,网络层仅通过 HashMap 进行模拟,而非实际发出网络请求。

DefaultTasksRepository 会在网络层与数据库层之间协调或协调,数据会返回到界面层。

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

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

导航

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

在此 Codelab 中,您将学习如何使用测试替身和依赖项注入来测试代码库、视图模型和 Fragment。在深入了解这些测试之前,请务必先了解用于引导测试编写内容的原因以及如何编写这些测试。

本部分将介绍一些适用于 Android 的一般测试最佳做法。

测试金字塔

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

  • 范围 - 测试涉及多少代码?测试可以通过单个方法运行,也可以跨应用运行或介于两者之间。
  • 速度 - 测试的运行速度如何?测试速度可以从毫秒到几分钟不等。
  • 保真度 - 测试实际程度如何?例如,如果您正在测试的部分代码需要发出网络请求,测试代码是否确实会发出此网络请求,或者这是否是虚假结果?如果测试确实与网络通信,这意味着测试的保真度更高。需要权衡的是,测试可能需要运行较长时间,如果网络出现故障,可能会导致错误,或者使用起来成本高昂。

这两个方面存在内在取舍。例如,速度和保真度需要做出权衡,一般来说,测试速度越快,保真度越低,反之亦然。一种自动化测试方法通常分为以下三类:

  • 单元测试 - 这些测试针对一个类(通常是该类中的单个方法)运行且高度集中的测试。如果单元测试失败,您可以确切地知道问题出在何处。它们的保真度较低,因为在现实世界中,您的应用涉及的不仅仅是某个方法或类的执行。它们足够快,可以在您每次更改代码时运行。它们通常是在本地运行的测试(在 test 源代码集中)。示例:在视图模型和代码库中测试单个方法。
  • 集成测试 - 这些测试用于测试多个类的交互,以确保它们在一起使用时会按预期运行。构建集成测试的方法之一是让它们测试单个功能,例如保存任务的功能。它们测试的代码范围要大于单元测试,但仍然经过了优化,可以快速运行,而不是采用全保真模式。它们既可以在本地运行,也可以作为插桩测试运行,具体取决于具体情况。示例:测试单个 fragment 和视图模型对的所有功能。
  • 端到端测试 (E2e) - 测试功能组合使用的功能。它们会测试应用的很大部分,模拟真实使用情况,因此速度通常较慢。它们具有最高的保真度,可告知您的应用作为一个整体运行。总的来说,这些测试将采用插桩测试(在 androidTest 源代码集中)
    示例:启动整个应用并同时测试几项功能。

这些测试的建议比例通常由金字塔表示,绝大多数测试都是单元测试。

架构和测试

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

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



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

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

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

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

仅测试一个或多个特定类中的代码可能会比较困难。下面我们来看一个示例。打开 main 源代码集中的 data.source.DefaultTaskRepository 类。这是应用的代码库,这是您将编写单元测试的类。

您的目标是仅测试该类中的代码。不过,DefaultTaskRepository 依赖于其他类(例如 LocalTaskDataSourceRemoteTaskDataSource)才能运行。换言之,LocalTaskDataSourceRemoteTaskDataSourceDefaultTaskRepository 的依赖项。

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



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

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

getTasks 是您可以对代码库进行的最“基本”调用之一。此方法包括从 SQLite 数据库中读取数据和进行网络调用(调用 updateTasksFromRemoteDataSource)。这不仅仅需要存储库代码,还需要执行大量代码。

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

  • 您需要考虑考虑创建和管理数据库,以便对此代码库执行最简单的测试。这会提出很多问题,如“这是一个本地测试或插桩测试”;您应该使用 AndroidX Test 获取模拟 Android 环境。
  • 某些代码部分(例如网络代码)可能需要很长时间才能运行,有时甚至会失败,从而导致运行时间较长、不稳定的测试。
  • 您的测试可能无法再诊断测试失败的是哪个代码。您的测试可能会开始测试非代码库代码,因此,举例来说,如果某些依赖代码(例如数据库代码)出现问题,您的单元测试“代码库”单元测试可能会失败。

测试双精度

此问题的解决方法是,在测试存储库时,不要使用实际的网络或数据库代码,而是改用测试替身。Test Double 是专门用于测试的类的一个版本。它旨在替换测试中类的实际版本。它与特技双人表演类似,是特技表演,由危险动作的演员扮演。

以下类型的双精度型测试:

虚假

测试类,具有类的“工作”实现,但其实现方式适合测试,但不适合用于生产。

模拟

一个测试替身,可跟踪调用了哪些方法。然后,它会通过测试是否通过,具体取决于测试方法是否正确调用。

测试双精度,不包含任何逻辑并仅返回您要返回的内容。例如,StubTaskRepository 可以通过编程从 getTasks 返回某些任务组合。

虚拟

传递但未使用(例如,如果您只需要以参数形式提供)的测试替身。如果有一个 NoOpTaskRepository,则它只会在任何方法中实现不含任何代码的 TaskRepository

间谍

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

如需详细了解测试替身,请参阅在马桶上测试:了解测试替身

Android 中最常用的测试替身为 FakesMocks

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

第 1 步:创建 FakeDataSource 类

在此步骤中,您将创建一个名为 FakeDataSouce 的类,该类将是 LocalDataSourceRemoteDataSource 的测试双精度数。

  1. test 源代码集中,右键点击 New -> Package

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

第 2 步:实现 TasksDataSource 接口

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

  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. 使用快速修复菜单,然后选择实施成员


  1. 选择所有方法,然后按 OK

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

FakeDataSource 是一种特定类型的测试替身,称为“虚假”。虚构是具有类的“工作”测试双重实现,但其实现方式使其适合测试但不适合生产。“正在执行”的实现意味着,类将在提供输入的情况下生成现实输出。

例如,您的虚构数据源不会连接到网络或将任何内容保存到数据库,而只会使用内存中的列表。该方法可以实现您的预期,在该方法中,用于获取或保存任务的方法会返回预期结果,但您绝不能在生产环境中使用此实现,因为它不会保存到服务器或数据库中。

一个 FakeDataSource

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


这是虚构的数据库或服务器响应任务列表。现在,目标是测试 repositorygetTasks 方法。这将调用数据源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

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

在此步骤中,您将使用名为“手动依赖项注入”的技巧,以便使用您刚刚创建的模拟测试替身。

主要问题是您使用的是 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. DefaultTaskRepository 的构造函数从接受 Application 更改为接受数据源和协程调度程序(您还需要替换这些测试 - 协程的第三课部分将对此进行详细介绍)。

DefaultTasksRepository.kt

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

// WITH

class DefaultTasksRepository(
    private val tasksRemoteDataSource: TasksDataSource,
    private val tasksLocalDataSource: TasksDataSource,
    private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO) { // Rest of class }
  1. 由于您传入了依赖项,因此请移除 init 方法。您不再需要创建依赖项。
  2. 同时删除旧实例变量。您要在构造函数中定义它们:

DefaultTasksRepository.kt

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

DefaultTasksRepository.kt

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

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

您现在使用的是构造函数依赖项注入!

第 2 步:在测试中使用 FakeDataSource

现在,您的代码使用了构造函数依赖项注入,接下来,您可以使用虚构数据源测试 DefaultTasksRepository 了。

  1. 右键点击 DefaultTasksRepository 类名称,然后依次选择 GenerateTest
  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: 时您会收到错误消息

第 4 步:添加 runBlockingTest

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

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

app/build.gradle

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

别忘了同步!

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

调用 suspend 函数时,请在测试类中使用 runBlockingTest。在本系列的下一个 Codelab 中,您将详细了解 runBlockingTest 的工作原理以及如何测试协程。

  1. 在类上方添加 @ExperimentalCoroutinesApi。这表示您知道您在类中使用了实验性协程 API (runBlockingTest)。否则,您会收到警告。
  2. 返回您的 DefaultTasksRepositoryTest,添加 runBlockingTest,使其作为一个“块”代码接受您的整个测试。

此最终测试类似于以下代码。

DefaultTasksRepositoryTest.kt

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


@ExperimentalCoroutinesApi
class DefaultTasksRepositoryTest {

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

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

    // Class under test
    private lateinit var tasksRepository: DefaultTasksRepository

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

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

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

}
  1. 运行新的 getTasks_requestsAllTasksFromRemoteDataSource 测试,确认其有效且错误已不存在!

您已经了解了如何对代码库进行单元测试。在接下来的这些步骤中,您将再次使用依赖项注入并创建另一项测试 - 这次将介绍如何为视图模型编写单元测试和集成测试。

单元测试应仅测试您感兴趣的类或方法。这就是所谓的“隔离测试”,在这种测试中,您可以清楚地隔离您的“单元”,并且仅测试属于该单元的代码。

因此,TasksViewModelTest 应只测试 TasksViewModel 代码,而不应在数据库、网络或存储库类中进行测试。因此,对于视图模型,就像您为存储库所做的那样,您需要创建一个虚构代码库并应用依赖项注入,以便在测试中使用。

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

第 1 步:创建 TasksRepository 接口

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

实际操作过程如何?查看 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 Surface)。

  1. 打开 DefaultTasksRepository 并右键点击类名称。然后选择 Refactor -> Extract -&gt Interface

  1. 选择解压到单独的文件

  1. Extract Interface 窗口中,将接口名称更改为 TasksRepository
  2. 要加入表单的成员界面中,选中除两个随播广告成员和不公开方法之外的所有成员。


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

DefaultTasksRepository 现在实现了 TasksRepository

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

第 2 步:创建 FakeTestRepository

现在,您已经有了接口,可以创建 DefaultTaskRepository 双精度测试了。

  1. 测试源代码集内的 data/source 中,创建 Kotlin 文件和类 FakeTestRepository.kt,并从 TasksRepository 接口进行扩展。

FakeTestRepository.kt

class FakeTestRepository : TasksRepository  {
}

系统会通知您,您需要实现接口方法。

  1. 将鼠标悬停在错误上,直到看到建议菜单,然后点击并选择实现成员
  1. 选择所有方法,然后按 OK

第 3 步:实现 FakeTestRepository 方法

现在,您有了一个 FakeTestRepository 类,其中包含“未实现”方法。与实现 FakeDataSource 的方式类似,FakeTestRepository 将由数据结构提供支持,而不是处理本地和远程数据源之间的复杂中介。

请注意,您的 FakeTestRepository 不需要使用 FakeDataSource 或类似内容;它只需根据输入返回实际的虚构输出。您将使用 LinkedHashMap 来存储任务列表,并使用 MutableLiveData 来存储可观察任务。

  1. FakeTestRepository 中,同时添加一个代表当前任务列表的 LinkedHashMap 变量和一个用于可观察任务的 MutableLiveData

FakeTestRepository.kt

class FakeTestRepository : TasksRepository {

    var tasksServiceData: LinkedHashMap<String, Task> = LinkedHashMap()

    private val observableTasks = MutableLiveData<Result<List<Task>>>()


    // Rest of class
}

实现以下方法:

  1. getTasks - 此方法应采用 tasksServiceData 方法,使用 tasksServiceData.values.toList() 将其转换为列表,然后以 Success 结果的形式返回该列表。
  2. refreshTasks - 将 observableTasks 的值更新为 getTasks() 返回的值。
  3. observeTasks - 使用 runBlocking 创建协程并运行 refreshTasks,然后返回 observableTasks

以下是这些方法的代码。

FakeTestRepository.kt

class FakeTestRepository : TasksRepository {

    var tasksServiceData: LinkedHashMap<String, Task> = LinkedHashMap()

    private val observableTasks = MutableLiveData<Result<List<Task>>>()

    override suspend fun getTasks(forceUpdate: Boolean): Result<List<Task>> {
        return Result.Success(tasksServiceData.values.toList())
    }

    override suspend fun refreshTasks() {
        observableTasks.value = getTasks()
    }

    override fun observeTasks(): LiveData<Result<List<Task>>> {
        runBlocking { refreshTasks() }
        return observableTasks
    }

    // Rest of class

}

第 4 步:向 addTasks 添加测试方法

测试时,最好在代码库中添加一些 Tasks。您可以多次调用 saveTask,但为了方便起见,请为专用于添加任务的测试添加辅助方法。

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

FakeTestRepository.kt

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

此时,您有一个虚构代码库,用于实现几个关键方法以进行测试。接下来,在您的测试中使用此代码!

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

此过程与视图模型稍有不同,因为您不是直接构建它们的。例如:

class TasksFragment : Fragment() {

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

}


如以上代码所示,您使用的是创建视图模型的 viewModel's 属性委托。如需更改视图模型的构造方式,您需要添加并使用 ViewModelProvider.Factory。如果您不熟悉 ViewModelProvider.Factory,请点击此处了解详情。

第 1 步:在 TasksViewModel 中制作和使用 ViewModelFactory

首先,更新与 Tasks 屏幕相关的类和测试。

  1. 打开 TasksViewModel
  2. 更改 TasksViewModel 的构造函数以接受 TasksRepository,而不是在类中构造构造函数。

TasksViewModel.kt

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

    private val tasksRepository = DefaultTasksRepository.getRepository(application)

    // Rest of class
}

// WITH

class TasksViewModel( private val tasksRepository: TasksRepository ) : ViewModel() { 
    // Rest of class 
}

由于您更改了构造函数,因此现在需要使用工厂来构建 TasksViewModel。将工厂类放在与 TasksViewModel 相同的文件中,但您也可以将其放入自己的文件中。

  1. TasksViewModel 文件底部的类之外,添加 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 Test ApplicationProvider.getApplicationContext 代码,因此您还可以移除 @RunWith(AndroidJUnit4::class) 注解。
  2. 运行测试,确保它们仍然正常运行!

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

第 3 步:同时更新 TaskDetail Fragment 和 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. 更新 TasksFragment 以使用出厂设置。

TasksFragment.kt

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

// WITH

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

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

接下来,您将编写集成测试来测试 Fragment 和视图模型交互。了解视图模型代码是否会适当地更新界面。为此,您可以使用

  • ServiceLocator 模式
  • Espresso 和 Mockito 库

集成测试可测试多个类的交互情况,确保它们搭配使用时发挥预期的作用。这些测试既可以在本地运行(test 源代码集),也可以作为插桩测试(androidTest 源代码集)运行。

在本例中,您将接受每个 fragment 并针对 fragment 和视图模型编写集成测试,以测试 fragment 的主要功能。

第 1 步:添加 Gradle 依赖项

  1. 添加以下 Gradle 依赖项。

app/build.gradle

    // Dependencies for Android instrumented unit tests
    androidTestImplementation "junit:junit:$junitVersion"
    androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"

    // Testing code should not be included in the main code.
    // Once https://issuetracker.google.com/128612536 is fixed this can be fixed.

    implementation "androidx.fragment:fragment-testing:$fragmentVersion"
    implementation "androidx.test:core:$androidXTestCoreVersion"

这些依赖项包括:

  • junit:junit - 编写基本测试语句所需的 JUnit。
  • androidx.test:core:核心 AndroidX 测试库
  • kotlinx-coroutines-test - 协程测试库
  • androidx.fragment:fragment-testing - 用于在测试中创建 Fragment 并更改其状态的 AndroidX 测试库。

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

第 2 步:创建 TaskDetailFragmentTest 类

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

首先,为 TaskDetailFragment 编写一个 fragment 测试,因为与其他 fragment 相比,该 fragment 具有相当基本的功能。

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

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

TaskDetailFragmentTest.kt

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

}

这些注解的用途如下:

第 3 步:从测试中启动 fragment

在此任务中,您将使用 AndroidX Testing 库启动 TaskDetailFragmentFragmentScenario 是 AndroidX Test 中的一个类,用于封装 Fragment,使您能够直接控制 Fragment 的生命周期以进行测试。如需为 fragment 编写测试,您需要为待测试的 fragment (TaskDetailFragment) 创建 FragmentScenario

  1. 将此测试复制TaskDetailFragmentTest 中。

TaskDetailFragmentTest.kt

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

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

    }

上述代码:

这还没有完成的测试,因为它尚未断言任何内容。现在,运行测试并观察结果。

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

应该发生以下几种情况。

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

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

您的测试都需要加载 TaskDetailFragment(您已完成),并断言数据已正确加载。为什么没有数据?这是因为您创建了一个任务,但该任务未保存到代码库中。

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

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

    }

您具有此 FakeTestRepository,但需要通过某种方式将实际代码库替换为 fragment 中的虚构代码库。您接下来要试试看!

在此任务中,您将使用 ServiceLocator 向 fragment 提供虚构代码库。这样,您就可以编写 fragment 并查看模型集成测试。

当您需要在需要为视图模型或代码库提供依赖项时,在此处可以像以前一样使用构造函数依赖项注入。构造函数依赖项注入要求您构造类。fragment 和 activity 是您不构造且通常无权访问构造函数的类的示例。

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

不使用服务定位器


使用服务定位器

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

  1. 创建能够构造和存储代码库的服务定位器类。默认情况下,它会构建一个“常规”代码库。
  2. 重构您的代码,以便在需要存储库时使用服务定位器。
  3. 在测试类中,对服务定位器调用一个方法,将“normal”存储区替换为您的双精度测试存储区。

第 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)创建代码库。

请务必只创建存储库类的一个实例。为此,请在我的 Application 类中使用服务定位器。

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

TodoApplication.kt

class TodoApplication : Application() {

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

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

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

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

DefaultTasksRepository.kt

// DELETE THIS COMPANION OBJECT
companion object {
    @Volatile
    private var INSTANCE: DefaultTasksRepository? = null

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

现在,无论您在何处使用 getRepository,请改用应用的 taskRepository。这样可确保您将获取 ServiceLocator 提供的任何代码库,而不是直接创建代码库。

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

TaskDetailFragment.kt

// REPLACE this code
private val viewModel by viewModels<TaskDetailViewModel> {
    TaskDetailViewModelFactory(DefaultTasksRepository.getRepository(requireActivity().application))
}

// WITH this code

private val viewModel by viewModels<TaskDetailViewModel> {
    TaskDetailViewModelFactory((requireContext().applicationContext as TodoApplication).taskRepository)
}
  1. TasksFragment 执行相同的操作。

TasksFragment.kt

// REPLACE this code
    private val viewModel by viewModels<TasksViewModel> {
        TasksViewModelFactory(DefaultTasksRepository.getRepository(requireActivity().application))
    }


// WITH this code

    private val viewModel by viewModels<TasksViewModel> {
        TasksViewModelFactory((requireContext().applicationContext as TodoApplication).taskRepository)
    }
  1. 对于 StatisticsViewModelAddEditTaskViewModel,请更新用于获取代码库的代码,以便使用 TodoApplication 中的代码库。

TasksFragment.kt

// 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. 在启动 fragment 之前,将 activeTask 保存在代码库中。
repository.saveTask(activeTask)

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

TaskDetailFragmentTest.kt

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

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

    }
  1. 为整个类添加 @ExperimentalCoroutinesApi 注解。

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

TaskDetailFragmentTest.kt

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

    private lateinit var repository: TasksRepository

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

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


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

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

    }

}
  1. 运行 activeTaskDetails_DisplayedInUi() 测试。

与之前很相似,您应该会看到 fragment,但这次除外,因为您已正确设置代码库,它现在会显示任务信息。


在此步骤中,您将使用 Espresso 界面测试库完成首次集成测试。您已构建代码,以便可以使用界面断言添加测试。为此,您需要使用 Espresso 测试库

Espresso 可以帮助您:

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

第 1 步:注意 Gradle 依赖项

您已经有主要的 Espresso 依赖项了,因为默认情况下它会包含在 Android 项目中。

app/build.gradle

dependencies {

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

androidx.test.espresso:espresso-core - 当您创建新的 Android 项目时,此核心 Espresso 依赖项会默认包含在内。它包含大多数视图及其操作的基本测试代码。

第 2 步:关闭动画

Espresso 测试在真实设备上运行,因此本质上是插桩测试。出现的问题之一是动画:如果动画延迟,并且您尝试测试某个视图是否在屏幕上显示,但该视图仍在呈现动画效果,那么 Espresso 可能会意外地导致测试失败。这可能会导致 Espresso 测试不稳定。

对于 Espresso 界面测试,最佳做法是关闭动画(同时测试速度会更快!):

  1. 在测试设备上,依次转到设置 > 开发者选项
  2. 停用以下三个设置:窗口动画缩放过渡动画缩放动画时长缩放

第 3 步:查看 Espresso 测试

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

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

此语句将查找 ID 为 task_detail_complete_checkbox 的复选框视图,点击该视图,然后断言其已选中。

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

1. 静态 Espresso 方法

onView

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

2. ViewMatcher

withId(R.id.task_detail_title_text)

withId 就是按 ID 获取视图的 ViewMatcher 示例。您还可以在文档中找到其他视图匹配器。

3.ViewAction

perform(click())

采用 ViewActionperform 方法。ViewAction 是可对视图执行的操作,例如,它点击视图。

4.ViewAssertion

check(matches(isChecked()))

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

请注意,并非总是在 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()))
    }

在这最后一步中,您将学习如何使用名为 mock 的其他类型的测试双精度测试以及 Mockito 测试库来测试 Navigation 组件

在此 Codelab 中,您使用了名为“fake”的测试用双精度型变量。虚假对象是许多类型的测试替身之一。您应该使用哪个测试替身来测试导航组件

想一想如何进行导航。想象一下,按 TasksFragment 中的其中一个任务可以导航到任务详情屏幕。

这是 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,它可断言导航方法调用正确。

第 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 类名称,然后依次选择 GenerateTest。在 androidTest 源代码集中创建一个测试。
  3. 将此代码复制到 TasksFragmentTest

TasksFragmentTest.kt

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

    private lateinit var repository: TasksRepository

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

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

}

此代码类似于您编写的 TaskDetailFragmentTest 代码。它会设置并删除 FakeAndroidTestRepository。添加一个导航测试,以测试当您点击任务列表中的任务时,它会转到正确的 TaskDetailFragment

  1. 添加测试 clickTask_navigateToDetailFragmentOne

TasksFragmentTest.kt

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

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

TasksFragmentTest.kt

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

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

接下来,您需要将 NavController 与 fragment 关联。onFragment,可让您对 Fragment 本身调用方法。

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

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

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

Mockito' 的 verify 方法可模拟此效果 - 您能够使用参数(actionTasksFragmentToTaskDetailFragment 且 ID 为“id1”)确认模拟 navController 调用了特定方法 (navigate)。

完整的测试如下所示:

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

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

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


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

总的来说,要测试导航,您可以执行以下操作:

  1. 使用 Mockito 创建 NavController 模拟。
  2. 将模拟的 NavController 附加到 fragment。
  3. 验证是否使用正确的操作和参数调用了 Navigation。

第 3 步:可选,写入 clickAddTaskButton_NavigateToAddEditFragment

如需了解您是否可以自行编写导航测试,请尝试此任务。

  1. 编写测试 clickAddTaskButton_navigateToAddEditFragment,用于检查您点击 + FAB 后是否会转到 AddEditTaskFragment

答案如下。

TasksFragmentTest.kt

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

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

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

点击此处,查看开始代码和最终代码之间的差异。

如需下载已完成的 Codelab 的代码,您可以使用下面的 git 命令:

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


另一种方法是,以 Zip 文件的形式下载代码库,将其解压缩,然后在 Android Studio 中打开它。

下载 Zip 文件

此 Codelab 介绍了如何设置手动依赖项注入、服务定位器,以及如何在 Android Kotlin 应用中使用虚假对象和模拟。请特别注意以下几点:

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

Udacity 课程:

Android 开发者文档:

视频:

其他:

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