1. 欢迎
简介
第二个测试代码实验室将重点介绍测试替身:何时在 Android 中使用它们,以及如何使用依赖注入、服务定位器模式和库来实现它们。在此过程中,您将学习如何编写
- 仓库单元测试
- 片段和视图模型集成测试
- 片段导航测试
您应该已经了解的内容
您应该熟悉
- 第一个代码实验室中介绍的测试概念:在 Android 上编写和运行单元测试,使用 JUnit、Hamcrest、AndroidX 测试、Robolectric 以及测试 LiveData
- 以下核心 Android Jetpack 库:
ViewModel
、LiveData
和 导航组件 - 应用程序架构,遵循来自 应用程序架构指南 和 Android 基础知识代码实验室 的模式
- Android 上协程的基础知识
您将学到的知识
- 如何规划测试策略
- 如何创建和使用测试替身,即模拟和存根
- 如何在 Android 上使用手动依赖注入进行单元测试和集成测试
- 如何应用服务定位器模式
- 如何测试仓库、片段、视图模型和导航组件
您将使用以下库和代码概念
您将执行的操作
- 使用测试替身和依赖注入编写仓库的单元测试。
- 使用测试替身和依赖注入编写视图模型的单元测试。
- 使用 Espresso UI 测试框架编写片段及其视图模型的集成测试。
- 使用 Mockito 和 Espresso 编写导航测试。
2. 应用程序概述
在本系列代码实验室中,您将使用 TO-DO Notes 应用程序。该应用程序允许您记下要完成的任务并在列表中显示它们。然后,您可以将它们标记为已完成或未完成,过滤它们或删除它们。
此应用程序是用 Kotlin 编写的,包含一些屏幕,使用 Jetpack 组件,并遵循来自 应用程序架构指南 的架构。通过学习如何测试此应用程序,您将能够测试使用相同库和架构的应用程序。
下载代码
要开始,请下载代码
或者,您可以克隆代码的 Github 存储库
$ git clone https://github.com/google-developer-training/advanced-android-testing.git $ cd android-testing $ git checkout end_codelab_1
您可以在 android-testing Github 存储库 中浏览代码。
花点时间熟悉代码,按照以下说明操作。
步骤 1:运行示例应用程序
下载 TO-DO 应用程序后,在 Android Studio 中打开它并运行它。它应该会编译。通过执行以下操作来探索应用程序
- 使用加号浮动操作按钮创建一个新任务。首先输入标题,然后输入有关任务的附加信息。使用绿色勾号 FAB 保存它。
- 在任务列表中,单击您刚完成的任务的标题,并查看该任务的详细信息屏幕,以查看其余的描述。
- 在列表中或详细信息屏幕上,选中该任务的复选框以将其状态设置为 已完成。
- 返回任务屏幕,打开过滤器菜单,并按 活动 和 已完成 状态过滤任务。
- 打开导航抽屉并单击 统计信息。
- 返回概述屏幕,然后从导航抽屉菜单中选择 清除已完成 以删除所有状态为 已完成 的任务
步骤 2:探索示例应用程序代码
TO-DO 应用程序基于 架构蓝图 测试和架构示例。该应用程序遵循来自 应用程序架构指南 的架构。它使用带有片段的视图模型、仓库和 Room。如果您熟悉以下任何示例,则此应用程序具有类似的架构
- Android Kotlin 基础知识培训代码实验室
- 高级 Android 培训代码实验室
- Room with a View 代码实验室
- Android Sunflower 示例
- Udacity 培训课程“使用 Kotlin 开发 Android 应用程序”
您更重要的是要了解应用程序的总体架构,而不是深入了解任何一个层的逻辑。
以下是您将找到的包的摘要
包: | ||
| 添加或编辑任务屏幕:用于添加或编辑任务的 UI 层代码。 | |
| 数据层:这处理任务的数据层。它包含数据库、网络和仓库代码。 | |
| 统计信息屏幕:统计信息屏幕的 UI 层代码。 | |
| 任务详细信息屏幕:单个任务的 UI 层代码。 | |
| 任务屏幕:所有任务列表的 UI 层代码。 | |
| 实用程序类:在应用程序的各个部分中使用的共享类,例如用于在多个屏幕上使用的滑动刷新布局。 |
数据层 (.data)
此应用程序包含模拟网络层(在 remote 包中)和数据库层(在 local 包中)。为简单起见,在此项目中,网络层使用带有延迟的 HashMap
模拟,而不是进行实际的网络请求。
DefaultTasksRepository
协调或介于网络层和数据库层之间,它是将数据返回到 UI 层的内容。
UI 层(.addedittask、.statistics、.taskdetail、.tasks)
每个 UI 层包都包含一个片段和一个视图模型,以及 UI 所需的任何其他类(例如任务列表的适配器)。TaskActivity
是包含所有片段的活动。
导航
应用程序的导航由 导航组件 控制。它在 nav_graph.xml
文件中定义。导航是在视图模型中使用 Event
类触发的;视图模型还确定要传递哪些参数。片段观察 Event
并执行屏幕之间的实际导航。
3. 概念:测试策略
在本代码实验室中,您将学习如何使用测试替身和依赖注入测试仓库、视图模型和片段。在深入了解它们是什么之前,了解指导您编写这些测试的原因至关重要。
本节介绍一些通用的测试最佳实践,这些最佳实践适用于 Android。
测试金字塔
在考虑测试策略时,有三个相关的测试方面
- 范围 - 测试接触了多少代码?测试可以在单个方法上运行,在整个应用程序中运行,也可以介于两者之间。
- 速度 - 测试运行的速度如何?测试速度可以从毫秒到几分钟不等。
- 保真度 - 测试有多“真实”?例如,如果您正在测试的代码的一部分需要发出网络请求,测试代码是否真的发出了这个网络请求,还是它模拟了结果?如果测试实际上与网络进行通信,这意味着它具有更高的保真度。权衡是测试运行可能需要更长时间,如果网络中断可能会导致错误,或者使用成本很高。
这些方面之间存在固有的权衡。例如,速度和保真度是一种权衡 - 通常情况下,测试越快,保真度就越低,反之亦然。将自动化测试划分的一种常见方法是将它们分为以下三类
- 单元测试 - 这些测试非常集中,在单个类上运行,通常是该类中的单个方法。如果单元测试失败,您就可以确切地知道代码中的问题出在哪里。它们具有较低的保真度,因为在现实世界中,您的应用程序涉及的内容远不止执行一个方法或类。它们运行速度足够快,可以每次更改代码时都运行它们。它们最常在本地运行测试(在
test
源集)。示例:测试视图模型和仓库中的单个方法。 - 集成测试 - 这些测试测试多个类的交互,以确保它们在组合使用时按预期工作。构建集成测试的一种方法是让它们测试单个功能,例如保存任务的功能。它们测试的代码范围比单元测试更大,但仍针对快速运行进行了优化,而不是具有完整的保真度。它们可以本地运行或作为仪器测试运行,具体取决于情况。示例:测试单个片段和视图模型对的所有功能。
- 端到端测试(E2e) - 测试一起工作的功能组合。它们测试应用程序的大部分内容,模拟真实的使用,因此通常速度很慢。它们具有最高的保真度,并告诉您您的应用程序实际上是否作为一个整体运行。大体上,这些测试将是仪器测试(在
androidTest
源集)。示例:启动整个应用程序并一起测试一些功能。
这些测试的建议比例通常用一个金字塔来表示,其中绝大多数测试是单元测试。
架构和测试
您在测试金字塔的不同层面上测试应用程序的能力与您的 **应用程序架构** 密切相关。例如,一个 **非常** 架构不良的应用程序可能会将所有逻辑都放在一个方法中。您可能能够为此编写端到端测试,因为这些测试往往测试应用程序的大部分内容,但是如何编写单元测试或集成测试呢?如果所有代码都在一个地方,就很难只测试与单个单元或功能相关的代码。
更好的方法是将应用程序逻辑分解成多个方法和类,从而允许独立测试每个部分。架构是一种划分和组织代码的方式,它可以更轻松地进行单元测试和集成测试。您将要测试的 TO-DO 应用程序遵循特定的架构。
在本课中,您将学习如何对上述架构的各个部分进行独立测试。
- 首先,您将 **对存储库进行单元测试**。
- 然后,您将在视图模型中使用测试替身,这是 **对视图模型进行单元测试和集成测试** 所必需的。
- 接下来,您将学习如何为 **片段及其视图模型编写集成测试**。
- 最后,您将学习如何编写包含 **导航组件** 的 **集成测试**。
端到端测试将在下一课中介绍。
4. 任务:创建假数据源
当您为类的某个部分(一个方法或一小部分方法)编写单元测试时,您的目标是 **只测试该类中的代码**。
只测试特定类或类的代码可能很棘手。让我们看一个例子。在 main
源集中的 data.source.DefaultTasksRepository
类中打开。这是应用程序的存储库,也是您将在下一步编写单元测试的类。
您的目标是仅测试该类中的代码。然而,DefaultTasksRepository
依赖于其他类,例如 TasksLocalDataSource
和 TasksRemoteDataSource
,才能正常工作。换句话说,TasksLocalDataSource
和 TasksRemoteDataSource
是 DefaultTasksRepository
的 **依赖项**。
因此,DefaultTasksRepository
中的每个方法都会调用数据源类中的方法,而这些方法又会调用其他类中的方法,以将信息保存到数据库或与网络进行通信。
例如,看一下 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 环境之类的问题。
- 代码的某些部分,例如网络代码,可能需要很长时间才能运行,或者偶尔还会失败,从而导致长时间运行的、不稳定的测试。
- 您的测试可能会失去诊断哪个代码对测试失败负有责任的能力。您的测试可能会开始测试非存储库代码,因此,例如,您的“存储库”单元测试可能会由于某些依赖代码(例如数据库代码)中的问题而失败。
测试替身
解决方案是,当您测试存储库时,**不要使用实际的网络或数据库代码**,而是使用测试替身。**测试替身** 是专门为测试而设计的类的版本。它旨在替换测试中的类的实际版本。这类似于特技替身是如何专门从事特技表演的演员,并代替真正的演员进行危险动作的。
以下是一些测试替身类型:
假 | 具有类的“工作”实现的测试替身,但其实现方式使其适合测试,但不适合生产环境。 “工作”实现是指该类在给定输入时会产生现实的输出。 |
模拟 | 跟踪其哪些方法被调用的测试替身。然后,它根据其方法是否被正确调用来通过或失败测试。 |
存根 | 不包含任何逻辑并且只返回您对其编程返回内容的测试替身。例如, |
虚拟 | 传递但未使用,例如,如果您只需要将其作为参数提供。如果您有一个 |
间谍 | 还跟踪一些额外信息的测试替身;例如,如果您制作了一个 |
有关测试替身的更多信息,请查看 厕所上的测试:了解您的测试替身。
Android 中最常用的测试替身是 **假** 和 **模拟**。
在此任务中,您将创建一个 FakeDataSource
测试替身,以对 DefaultTasksRepository
进行单元测试,将其与实际数据源分离。
步骤 1:创建 FakeDataSource 类
在此步骤中,您将创建一个名为 FakeDataSouce
的类,它将是 LocalDataSource
和 RemoteDataSource
的测试替身。
- 在 **test** 源集中,右键单击并选择 **新建 -> 包**。
- 创建一个 **data** 包,并在其中创建一个 **source** 包。
- 在 **data/source** 包中创建一个名为 **
FakeDataSource
** 的新类。
步骤 2:实现 TasksDataSource 接口
为了能够使用新的类 FakeDataSource
作为测试替身,它必须能够替换其他数据源。这些数据源是 TasksLocalDataSource
和 TasksRemoteDataSource
。
- 请注意,这两个数据源都实现了
TasksDataSource
接口。
class TasksLocalDataSource internal constructor(
private val tasksDao: TasksDao,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) : TasksDataSource { ... }
object TasksRemoteDataSource : TasksDataSource { ... }
- 让
FakeDataSource
实现TasksDataSource
。
class FakeDataSource : TasksDataSource {
}
Android Studio 会提示您尚未为 TasksDataSource
实现所需的方法。
- 使用快速修复菜单并选择 **实现成员**。
- 选择所有方法并按 **确定**。
步骤 3:在 FakeDataSource 中实现 getTasks 方法
FakeDataSource
是一种特定类型的测试替身,称为 **假**。假是一种测试替身,它具有类的“工作”实现,但其实现方式使其适合测试,但不适合生产环境。“工作”实现是指该类在给定输入时会产生现实的输出。
例如,您的假数据源不会连接到网络或将任何内容保存到数据库中——相反,它只会使用一个内存列表。这将“按预期工作”,因为获取或保存任务的方法将返回预期结果,但您永远不能在生产环境中使用此实现,因为它没有保存到服务器或数据库中。
一个 FakeDataSource
- 使您无需依赖实际的数据库或网络即可测试
DefaultTasksRepository
中的代码。 - 为测试提供“足够真实”的实现。
- 将
FakeDataSource
构造函数更改为创建一个名为tasks
的var
,它是一个MutableList<Task>?
,默认值为一个空的可变列表。
class FakeDataSource(var tasks: MutableList<Task>? = mutableListOf()) : TasksDataSource { // Rest of class }
这是“假装”为数据库或服务器响应的任务列表。目前,目标是测试 **存储库的 **getTasks
方法。此方法调用 **数据源的 **getTasks
、deleteAllTasks
和 saveTask
方法。
编写这些方法的假版本
- 编写
getTasks
:如果tasks
不为null
,则返回一个Success
结果。如果tasks
为null
,则返回一个Error
结果。 - 编写
deleteAllTasks
:清除可变任务列表。 - 编写
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
,但尚不清楚您如何在测试中使用它。它需要替换 TasksRemoteDataSource
和 TasksLocalDataSource
,但仅限于测试。 TasksRemoteDataSource
和 TasksLocalDataSource
都是 DefaultTasksRepository
的依赖项,这意味着 DefaultTasksRepositories
需要或“依赖”这些类才能运行。
现在,这些依赖项是在 DefaultTasksRepository
的 init
方法中构造的。
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
中创建并分配了 taskLocalDataSource
和 tasksRemoteDataSource
,因此它们本质上是硬编码的。无法将您的测试替身替换进来。
您要做的相反是 **提供** 这些数据源给类,而不是对其进行硬编码。提供依赖项被称为 **依赖注入**。有不同的方法来提供依赖项,因此也有不同的依赖注入类型。
**构造函数依赖注入** 允许您通过将测试替身传递给构造函数来替换它。
**无注入** | **注入** |
步骤 1:在 DefaultTasksRepository 中使用构造函数依赖注入
- 将
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 }
- 由于您传递了依赖项,因此删除了
init
方法。您不再需要创建这些依赖项。 - 还要删除旧的实例变量。您将在构造函数中定义它们
DefaultTasksRepository.kt
// Delete these old variables
private val tasksRemoteDataSource: TasksDataSource
private val tasksLocalDataSource: TasksDataSource
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
- 最后,更新
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
。
- 右键单击
DefaultTasksRepository
类名,然后选择 **生成**,然后选择 **测试**。 - 按照提示在 **test** 源代码集中创建
DefaultTasksRepositoryTest
。 - 在新的
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 }
- 创建三个变量,两个
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
。
- 创建一个名为
createRepository
的方法,并使用@Before
对其进行注释。 - 使用
remoteTasks
和localTasks
列表实例化您的伪数据源。 - 实例化您的
tasksRepository
,使用您刚刚创建的两个伪数据源以及Dispatchers.Unconfined
。
最终方法应类似于以下代码。
DefaultTasksRepositoryTest.kt
@Before
fun createRepository() {
tasksRemoteDataSource = FakeDataSource(remoteTasks.toMutableList())
tasksLocalDataSource = FakeDataSource(localTasks.toMutableList())
// Get a reference to the class under test
tasksRepository = DefaultTasksRepository(
// TODO Dispatchers.Unconfined should be replaced with Dispatchers.Main
// this requires understanding more about coroutines + testing
// so we will keep this as Unconfined for now.
tasksRemoteDataSource, tasksLocalDataSource, Dispatchers.Unconfined
)
}
步骤 3:编写 DefaultTasksRepository getTasks() 测试
是时候编写 DefaultTasksRepository
测试了!
- 为存储库的
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 依赖项。
- 使用 **
testImplementation
** 将测试协程所需的依赖项添加到测试源代码集中。
app/build.gradle
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"
不要忘记同步!
kotlinx-coroutines-test
是专门用于测试协程的协程测试库。要运行测试,请使用函数 runBlockingTest
。这是协程测试库提供的函数。它接受一个代码块,然后在特殊协程上下文中运行此代码块,该上下文以同步且立即的方式运行,这意味着操作将以确定性顺序发生。这本质上使您的协程像非协程一样运行,因此它旨在测试代码。
当您调用 suspend
函数时,在测试类中使用 runBlockingTest
。您将在本系列中的下一部分代码实验室中详细了解 runBlockingTest
的工作原理以及如何测试协程。
- 在您的
DefaultTasksRepositoryTest
中,添加@ExperimentalCoroutinesApi
,位于类的上方。这表示您知道您在类中使用实验性协程 API (runBlockingTest
)。没有它,您会收到警告。 - 回到您的
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))
}
}
- 运行您新的
getTasks_requestsAllTasksFromRemoteDataSource
测试,并确认它有效且错误已消除!
6. 任务:设置一个伪存储库
您刚刚了解了如何对存储库进行单元测试。在接下来的步骤中,您将再次使用依赖注入并创建另一个测试替身——这次是为了展示如何为您的视图模型编写单元测试和集成测试。
单元测试应仅测试您感兴趣的类或方法。这被称为在 **隔离** 中进行测试,您清楚地隔离您的“单元”,并且仅测试属于该单元的代码。
因此,TasksViewModelTest
应该只测试 TasksViewModel
代码——它不应该测试数据库、网络或存储库类中的代码。因此,对于您的视图模型,就像您刚刚对存储库所做的那样,您将创建一个伪存储库并应用依赖注入以在测试中使用它。
在本任务中,您将依赖注入应用于视图模型。
步骤 1. 创建一个 TasksRepository 接口
使用构造函数依赖注入的第一步是创建一个由伪类和真实类共享的通用接口。
在实践中它看起来像什么?看看 TasksRemoteDataSource
、TasksLocalDataSource
和 FakeDataSource
,并注意到它们都共享相同的接口: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 表面)。
- 打开
DefaultTasksRepository
,然后 **右键单击** 类名。然后选择 **重构 -> 提取 -> 接口**。
- 选择 **提取到单独的文件**。
- 在 **提取接口** 窗口中,将接口名称更改为
TasksRepository
。 - 在 **用于形成接口的成员** 部分中,选中所有成员 **除** 两个伴侣成员和 **私有** 方法之外的所有成员。
- 单击 **重构**。新的
TasksRepository
接口应出现在 **data/source** 包中。
并且 DefaultTasksRepository
现在实现 TasksRepository
。
- **运行** 您的应用程序(不是测试),以确保一切正常。
步骤 2. 创建 FakeTestRepository
现在您有了接口,就可以创建 DefaultTasksRepository
测试替身。
- 在 **test** 源代码集中,在 **data/source** 中创建 Kotlin 文件和类
FakeTestRepository.kt
,并从TasksRepository
接口继承。
FakeTestRepository.kt
class FakeTestRepository : TasksRepository {
}
您将被告知需要实现接口方法。
- 将鼠标悬停在错误上,直到看到建议菜单,然后单击并选择 **实现成员**。
- 选择所有方法并按 **确定**。
步骤 3. 实现 FakeTestRepository 方法
你现在拥有一个带有“未实现”方法的 FakeTestRepository
类。类似于你实现 FakeDataSource
的方式,FakeTestRepository
将由一个数据结构支持,而不是处理本地和远程数据源之间复杂的协调。
请注意,你的 FakeTestRepository
不需要使用 FakeDataSource
或其他任何类似的东西;它只需要根据输入返回真实的虚拟输出。你将使用 LinkedHashMap
来存储任务列表,并使用 MutableLiveData
来存储可观察的任务。
- 在
FakeTestRepository
中,添加一个表示当前任务列表的LinkedHashMap
变量和一个用于可观察任务的MutableLiveData
。
FakeTestRepository.kt
class FakeTestRepository : TasksRepository {
var tasksServiceData: LinkedHashMap<String, Task> = LinkedHashMap()
private val observableTasks = MutableLiveData<Result<List<Task>>>()
// Rest of class
}
实现以下方法:
getTasks
- 此方法应该接收tasksServiceData
并使用tasksServiceData.values.toList()
将其转换为列表,然后将其作为Success
结果返回。refreshTasks
- 将observableTasks
的值更新为getTasks()
返回的值。observeTasks
- 使用runBlocking
创建一个协程并运行refreshTasks
,然后返回observableTasks
。
对于你的测试替身,使用 runBlocking
。 runBlocking
更接近于仓库“真实”实现的行为方式,对于伪造而言它是更好的选择,这样它们的行为更接近真实实现。
在测试类中,即带有 @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
,但为了方便起见,添加一个专门用于测试的辅助方法,允许你添加任务。
- 添加
addTasks
方法,该方法接收一个vararg
的任务,将每个任务添加到HashMap
中,然后刷新任务。
FakeTestRepository.kt
fun addTasks(vararg tasks: Task) {
for (task in tasks) {
tasksServiceData[task.id] = task
}
runBlocking { refreshTasks() }
}
此时,你已经拥有一个用于测试的伪造仓库,并实现了一些关键方法。接下来,在你的测试中使用它!
7. 任务:在 ViewModel 中使用伪造仓库
在此任务中,你将在 ViewModel
中使用一个伪造类。使用构造函数依赖注入,通过在 TasksViewModel
的构造函数中添加一个 TasksRepository
变量,通过构造函数依赖注入传入两个数据源。
此过程在视图模型中略有不同,因为你不会直接构造它们。例如
class TasksFragment : Fragment() {
private val viewModel by viewModels<TasksViewModel>()
// Rest of class...
}
如上面的代码所示,你正在使用 viewModel
的 属性委托 来创建视图模型。要更改视图模型的构造方式,你需要添加并使用 ViewModelProvider.Factory
。如果你不熟悉 ViewModelProvider.Factory
,你可以在这里了解更多信息:这里。
步骤 1. 在 TasksViewModel 中创建并使用 ViewModelFactory
首先,更新与 Tasks
屏幕相关的类和测试。
- 打开
TasksViewModel
。 - 更改
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
相同的文件中,但你也可以将其放在它自己的文件中。
- 在
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
构造方式的标准方法。现在你有了工厂,在任何构造视图模型的地方使用它。
- 更新
TasksFragment
以使用该工厂。
TasksFragment.kt
// REPLACE
private val viewModel by viewModels<TasksViewModel>()
// WITH
private val viewModel by viewModels<TasksViewModel> {
TasksViewModelFactory(DefaultTasksRepository.getRepository(requireActivity().application))
}
- 运行你的 应用 代码,并确保一切仍然正常工作!
步骤 2. 在 TasksViewModelTest 中使用 FakeTestRepository
现在,你可以在你的视图模型测试中使用伪造仓库,而不是使用真实的仓库。
- 打开
TasksViewModelTest
。它位于 tasks 文件夹下的 test 源集。 - 在
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
}
- 更新
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)
}
- 由于你不再使用 AndroidX Test
ApplicationProvider.getApplicationContext
代码,你也可以删除@RunWith(AndroidJUnit4::class)
注解。 - 运行你的测试,确保它们仍然正常工作!
通过使用构造函数依赖注入,你已经删除了 DefaultTasksRepository
作为依赖项,并在测试中用你的 FakeTestRepository
替换了它。
步骤 3. 还更新 TaskDetailFragment 和 ViewModel
对 TaskDetailFragment
和 TaskDetailViewModel
进行相同的更改。这将为你在下一步骤中编写 TaskDetail
测试做好准备。
- 打开
TaskDetailViewModel
。 - 更新构造函数
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 }
- 在
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)
}
- 更新
TaskDetailFragment
以使用该工厂。
TaskDetailFragment.kt
// REPLACE
private val viewModel by viewModels<TaskDetailViewModel>()
// WITH
private val viewModel by viewModels<TaskDetailViewModel> {
TaskDetailViewModelFactory(DefaultTasksRepository.getRepository(requireActivity().application))
}
- 运行你的代码,并确保一切正常工作。
你现在可以使用 FakeTestRepository
来代替 TasksFragment
和 TaskDetailFragment
中的真实仓库。
8. 任务:从测试中启动片段
接下来,你将编写集成测试来测试你的片段和视图模型的交互。你将找出你的视图模型代码是否适当地更新了你的 UI。为此,你将使用
- ServiceLocator 模式
- Espresso 和 Mockito 库
集成测试 测试多个类的交互,以确保它们在组合使用时按预期工作。这些测试可以在本地 (test
源集) 或作为仪器测试 (androidTest
源集) 运行。
在本例中,你将采用每个片段并编写片段和视图模型的集成测试,以测试片段的主要功能。
步骤 1. 添加 Gradle 依赖项
- 添加以下 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
显示有关单个任务的信息。
你将首先为 TaskDetailFragment
编写一个片段测试,因为它与其他片段相比,其功能非常基本。
- 打开
taskdetail.TaskDetailFragment
。 - 生成
TaskDetailFragment
的测试,就像你之前做的那样。接受默认选项,并将其放在 androidTest 源集中(而不是test
源集中)。
- 将以下注解添加到
TaskDetailFragmentTest
类中。
TaskDetailFragmentTest.kt
@MediumTest
@RunWith(AndroidJUnit4::class)
class TaskDetailFragmentTest {
}
这些注解的目的是
@MediumTest
- 将测试标记为“中等运行时间”集成测试(与@SmallTest
单元测试和@LargeTest
端到端测试相比)。这有助于你对测试进行分组并选择要运行的测试大小。@RunWith(AndroidJUnit4::class)
- 在使用 AndroidX Test 的任何类中使用。
步骤 3. 从测试中启动片段
在此任务中,你将使用 AndroidX 测试库 启动 TaskDetailFragment
。 FragmentScenario
是 AndroidX Test 中的一个类,它包装了片段,并为你提供了对片段生命周期的直接控制,以便进行测试。要编写片段的测试,你需要为你要测试的片段 (TaskDetailFragment
) 创建一个 FragmentScenario
。
- 复制 此测试到
TaskDetailFragmentTest
中。
TaskDetailFragmentTest.kt
@Test
fun activeTaskDetails_DisplayedInUi() {
// GIVEN - Add active (incomplete) task to the DB
val activeTask = Task("Active Task", "AndroidX Rocks", false)
// WHEN - Details fragment launched to display task
val bundle = TaskDetailFragmentArgs(activeTask.id).toBundle()
launchFragmentInContainer<TaskDetailFragment>(bundle, R.style.AppTheme)
}
上面的代码
- 创建一个任务。
- 创建一个
Bundle
,它表示传递到片段中的任务的片段参数。 launchFragmentInContainer
函数创建一个FragmentScenario
,包含此 bundle 和一个主题。
这还不是一个完整的测试,因为它没有断言任何东西。现在,运行测试并观察会发生什么。
- 这是一个仪器测试,所以确保模拟器或您的设备可见。
- 运行 测试。
应该会发生一些事情。
- 首先,因为这是一个仪器测试,测试将在您的物理设备(如果已连接)或模拟器上运行。
- 它应该启动片段。
- 注意它如何不通过任何其他片段导航或与活动相关联任何菜单 - 它只是片段。
最后,仔细观察并注意片段显示“无数据”,因为它没有成功加载任务数据。
您的测试既需要加载 TaskDetailFragment
(您已经完成了)并断言数据已正确加载。为什么没有数据?这是因为您创建了一个任务,但您没有将其保存到存储库。
@Test
fun activeTaskDetails_DisplayedInUi() {
// This DOES NOT save the task anywhere
val activeTask = Task("Active Task", "AndroidX Rocks", false)
val bundle = TaskDetailFragmentArgs(activeTask.id).toBundle()
launchFragmentInContainer<TaskDetailFragment>(bundle, R.style.AppTheme)
}
您有这个 FakeTestRepository
,但您需要某种方法将您的真实存储库替换为您的伪造存储库,以便用于您的片段。您将在下一步进行此操作!
9. 任务:制作一个服务定位器
在这个任务中,您将使用 ServiceLocator
将您的伪造存储库提供给您的片段。这将允许您编写片段和视图模型集成测试。
您不能在这里使用构造函数依赖注入,就像您之前做的那样,当您需要向视图模型或存储库提供依赖项时。构造函数依赖注入要求您构造类。片段和活动是您不构造并且通常无法访问构造函数的类的示例。
由于您没有构造片段,因此您无法使用构造函数依赖注入将存储库测试双重(FakeTestRepository
)交换到片段。相反,使用 服务定位器 模式。服务定位器模式是依赖注入的替代方法。它涉及创建一个名为“服务定位器”的单例类,其目的是提供依赖项,包括常规代码和测试代码。在常规应用程序代码(main
源代码集)中,所有这些依赖项都是常规应用程序依赖项。对于测试,您修改服务定位器以提供依赖项的测试双重版本。
不使用服务定位器 | 使用服务定位器 |
对于这个 codelab 应用程序,请执行以下操作
- 创建一个能够构造和存储存储库的服务定位器类。默认情况下,它会构造一个“正常”存储库。
- 重构您的代码,以便在需要存储库时,使用服务定位器。
- 在您的测试类中,调用服务定位器上的方法,该方法会将“正常”存储库替换为您的测试双重。
步骤 1. 创建 ServiceLocator
让我们创建一个 ServiceLocator
类。它将与应用程序代码的其余部分一起放在主源代码集中,因为它被主应用程序代码使用。
注意: ServiceLocator
是一个单例,因此使用 Kotlin object
关键字 用于类。
- 在主源代码集的顶层创建文件 **ServiceLocator.kt**。
- 定义一个名为
ServiceLocator
的object
。 - 创建
database
和repository
实例变量,并将两者都设置为null
。 - 使用
@Volatile
注释存储库,因为它可能被多个线程使用(@Volatile
在 这里 有详细解释)。
您的代码应如下所示。
object ServiceLocator {
private var database: ToDoDatabase? = null
@Volatile
var tasksRepository: TasksRepository? = null
}
现在,您的 ServiceLocator
只需要知道如何返回 TasksRepository
。它将返回一个预先存在的 DefaultTasksRepository
,或者在需要时创建并返回一个新的 DefaultTasksRepository
。
定义以下函数
provideTasksRepository
—提供已存在的存储库或创建一个新的存储库。此方法应该在this
上synchronized
,以避免在多个线程运行的情况下,意外创建两个存储库实例。createTasksRepository
—创建新存储库的代码。将调用createTaskLocalDataSource
并创建一个新的TasksRemoteDataSource
。createTaskLocalDataSource
—创建新本地数据源的代码。将调用createDataBase
。createDataBase
—创建新数据库的代码。
完整的代码如下所示。
ServiceLocator.kt
object ServiceLocator {
private var database: ToDoDatabase? = null
@Volatile
var tasksRepository: TasksRepository? = null
fun provideTasksRepository(context: Context): TasksRepository {
synchronized(this) {
return tasksRepository ?: createTasksRepository(context)
}
}
private fun createTasksRepository(context: Context): TasksRepository {
val newRepo = DefaultTasksRepository(TasksRemoteDataSource, createTaskLocalDataSource(context))
tasksRepository = newRepo
return newRepo
}
private fun createTaskLocalDataSource(context: Context): TasksDataSource {
val database = database ?: createDataBase(context)
return TasksLocalDataSource(database.taskDao())
}
private fun createDataBase(context: Context): ToDoDatabase {
val result = Room.databaseBuilder(
context.applicationContext,
ToDoDatabase::class.java, "Tasks.db"
).build()
database = result
return result
}
}
步骤 2. 在 Application 中使用 ServiceLocator
您将对主应用程序代码(而不是您的测试)进行更改,以便您在一个地方创建存储库,即您的 ServiceLocator
。
重要的是,您只创建存储库类的一个实例。为了确保这一点,您将在 TodoApplication 类中使用服务定位器。
- 在包层次结构的顶层,打开
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
方法。
- 打开
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
提供的任何存储库。
- 打开
TaskDetailFragement
并找到类顶部的对getRepository
的调用。 - 将此调用替换为从
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)
}
- 对
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)
}
- 对于
StatisticsViewModel
和AddEditTaskViewModel
,更新获取存储库的代码以使用来自TodoApplication
的存储库。
// REPLACE this code
private val tasksRepository = DefaultTasksRepository.getRepository(application)
// WITH this code
private val tasksRepository = (application as TodoApplication).taskRepository
- 运行您的应用程序(不是测试)!
由于您只是重构,因此应用程序应该在没有问题的情况下运行相同。
步骤 3. 创建 FakeAndroidTestRepository
您已经在测试源代码集中有一个 FakeTestRepository
。默认情况下,您不能在 test
和 androidTest
源代码集之间共享测试类。因此,您需要在 androidTest
源代码集中创建一个重复的 FakeTestRepository
类,并将其命名为 FakeAndroidTestRepository
。
- 右键单击
androidTest
源代码集并创建一个 data.source 包。 - 在此源代码包中创建一个名为
FakeAndroidTestRepository.kt
的新类。 - 将以下代码复制到该类。
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
代码添加一些代码。
- 打开
ServiceLocator.kt
。 - 将
tasksRepository
的 setter 标记为@VisibleForTesting
。此注释是一种表达 setter 为公共的原因是由于测试的注解。
ServiceLocator.kt
@Volatile
var tasksRepository: TasksRepository? = null
@VisibleForTesting set
无论您单独运行测试还是在多个测试中运行测试,您的测试都应该完全相同。这意味着您的测试不应该有任何相互依赖的行为(这意味着避免在测试之间共享对象)。
由于 ServiceLocator
是一个单例,因此它有可能在测试之间意外共享。为了帮助避免这种情况,创建一个方法,在测试结束时正确重置 ServiceLocator
状态。
- 添加一个名为
lock
的实例变量,其值为Any
。
ServiceLocator.kt
private val lock = Any()
- 添加一个特定于测试的方法,名为
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
。
- 打开
TaskDetailFragmentTest
。 - 声明一个
lateinit TasksRepository
变量。 - 添加一个 setup 和 teardown 方法,在每个测试之前设置一个
FakeAndroidTestRepository
,并在每个测试之后清理它。
TaskDetailFragmentTest.kt
private lateinit var repository: TasksRepository
@Before
fun initRepository() {
repository = FakeAndroidTestRepository()
ServiceLocator.tasksRepository = repository
}
@After
fun cleanupDb() = runBlockingTest {
ServiceLocator.resetRepository()
}
- 将
activeTaskDetails_DisplayedInUi()
函数体包裹在runBlockingTest
中。 - 在启动 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)
}
- 使用
@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)
}
}
- 运行
activeTaskDetails_DisplayedInUi()
测试。
与之前非常相似,你应该可以看到 Fragment,但这一次,因为你正确地设置了仓库,它现在显示了任务信息。
10. 任务:使用 Espresso 编写第一个集成测试
在这个步骤中,你将使用 Espresso UI 测试库来完成你的第一个集成测试。你已经构建了代码,因此你可以添加带有断言的 UI 测试。为此,你将使用 Espresso 测试库。
Espresso 可以帮助你
- 与视图交互,例如单击按钮、滑动条或向下滚动屏幕。
- 断言某些视图在屏幕上或处于特定状态(例如包含特定文本或复选框被选中等)。
步骤 1. 注意 Gradle 依赖项
你已经拥有了主要的 Espresso 依赖项,因为它默认包含在 Android 项目中。
app/build.gradle
dependencies {
// ALREADY in your code
androidTestImplementation "androidx.test.espresso:espresso-core:$espressoVersion"
// Other dependencies
}
androidx.test.espresso:espresso-core
—当你创建一个新的 Android 项目时,默认情况下会包含这个核心的 Espresso 依赖项。它包含针对大多数视图及其上的操作的基本测试代码。
步骤 2. 关闭动画
Espresso 测试在真实设备上运行,因此本质上是 Instrumentation 测试。出现的一个问题是动画:如果动画滞后,并且你试图测试视图是否在屏幕上,但它仍在动画中,Espresso 可能会意外地导致测试失败。这会使 Espresso 测试变得不稳定。
对于 Espresso UI 测试,最好关闭动画(你的测试也将运行得更快!)。
- 在你的测试设备上,转到 设置 > 开发者选项。
- 禁用以下三个设置:窗口动画缩放比例、过渡动画缩放比例和动画持续时间缩放比例。
步骤 3. 查看 Espresso 测试
在编写 Espresso 测试之前,先看看一些 Espresso 代码。
onView(withId(R.id.task_detail_complete_checkbox)).perform(click()).check(matches(isChecked()))
此语句的作用是查找 ID 为 task_detail_complete_checkbox
的复选框视图,单击它,然后断言它被选中。
大多数 Espresso 语句由四个部分组成
onView
onView
是一个静态 Espresso 方法的示例,它开始一个 Espresso 语句。 onView
是最常用的方法之一,但还有其他选项,例如 onData
。
withId(R.id.task_detail_title_text)
withId
是 ViewMatcher
的一个示例,它通过 ID 获取视图。还有其他视图匹配器,你可以在 文档 中查找它们。
perform(click())
The perform
method which takes a ViewAction
. A ViewAction
is something that can be done to the view, for example here, it's clicking the view.
check(matches(isChecked()))
check
which takes a ViewAssertion
. ViewAssertion
s check or assert something about the view. The most common ViewAssertion
you'll use is the matches
assertion. To finish the assertion, use another ViewMatcher
, in this case isChecked
.
请注意,你并不总是需要在 Espresso 语句中同时调用 perform
和 check
。你可以只使用 check
来执行断言的语句,或者只使用 perform
来执行 ViewAction
。
- 打开
TaskDetailFragmentTest.kt
。 - 更新
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
- 在
// THEN
注释之后的代码都使用 Espresso。检查测试结构和withId
的使用,并检查以确保对详细信息页面应该是什么样的断言。 - 运行测试并确认它通过了。
步骤 4. 可选,编写你自己的 Espresso 测试
现在自己编写一个测试。
- 创建一个名为
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
}
- 查看上一个测试,完成此测试。
- 运行并确认测试通过了。
完成后的 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
中的任务之一以导航到任务详细信息屏幕。
以下是 TasksFragment
中的代码,当按下该代码时会导航到任务详细信息屏幕。
TasksFragment.kt
private fun openTaskDetails(taskId: String) {
val action = TasksFragmentDirections.actionTasksFragmentToTaskDetailFragment(taskId)
findNavController().navigate(action)
}
导航是因为调用了 navigate
方法。如果你需要编写断言语句,则没有直接的方法来测试你是否已导航到 TaskDetailFragment
。导航是一个复杂的动作,除了初始化 TaskDetailFragment
之外,不会产生清晰的输出或状态更改。
你可以断言的是,navigate
方法已使用正确的操作参数被调用。这正是 **模拟** 测试替身所做的——它检查特定方法是否被调用。
Mockito 是一个用于创建测试替身的框架。虽然 API 和名称中使用了模拟这个词,但它 **不** 仅用于创建模拟对象。它还可以创建存根和间谍。
你将使用 Mockito 创建一个模拟 NavigationController
,它可以断言导航方法被正确调用。
步骤 1. 添加 Gradle 依赖项
- 添加 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
—这个库由外部贡献(因此得名)组成,其中包含针对更高级视图的测试代码,例如DatePicker
和RecyclerView
。它还包含可访问性检查和名为CountingIdlingResource
的类,该类将在后面介绍。
步骤 2. 创建 TasksFragmentTest
- 打开
TasksFragment
。 - 右键单击
TasksFragment
类名,选择 生成,然后选择 测试。在 androidTest 源代码集中创建一个测试。 - 将此代码复制到
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
。
- 添加测试
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)
}
- 使用 Mockito 的
mock
函数来创建一个模拟对象。
TasksFragmentTest.kt
val navController = mock(NavController::class.java)
要在 Mockito 中进行模拟,请传入你想要模拟的类。
接下来,你需要将你的 NavController
与 Fragment 关联起来。 onFragment
使你可以对 Fragment 本身调用方法。
- 使你的新模拟对象成为 Fragment 的
NavController
。
scenario.onFragment {
Navigation.setViewNavController(it.view!!, navController)
}
- 添加代码以单击文本为“TITLE1”的
RecyclerView
中的项目。
// WHEN - Click on the first list item
onView(withId(R.id.tasks_list))
.perform(RecyclerViewActions.actionOnItem<RecyclerView.ViewHolder>(
hasDescendant(withText("TITLE1")), click()))
RecyclerViewActions
是 espresso-contrib
库的一部分,使你能够对 RecyclerView 执行 Espresso 操作。
- 验证
navigate
是否被调用,以及是否使用了正确的参数。
// THEN - Verify that we navigate to the first detail screen
verify(navController).navigate(
TasksFragmentDirections.actionTasksFragmentToTaskDetailFragment( "id1")
Mockito 的 verify
方法是使它成为模拟对象的工具——你可以确认模拟的 navController
使用参数 (actionTasksFragmentToTaskDetailFragment
,其 ID 为“id1”) 调用了特定方法 (navigate
)。
完整的测试看起来像这样
@Test
fun clickTask_navigateToDetailFragmentOne() = runBlockingTest {
repository.saveTask(Task("TITLE1", "DESCRIPTION1", false, "id1"))
repository.saveTask(Task("TITLE2", "DESCRIPTION2", true, "id2"))
// GIVEN - On the home screen
val scenario = launchFragmentInContainer<TasksFragment>(Bundle(), R.style.AppTheme)
val navController = mock(NavController::class.java)
scenario.onFragment {
Navigation.setViewNavController(it.view!!, navController)
}
// WHEN - Click on the first list item
onView(withId(R.id.tasks_list))
.perform(RecyclerViewActions.actionOnItem<RecyclerView.ViewHolder>(
hasDescendant(withText("TITLE1")), click()))
// THEN - Verify that we navigate to the first detail screen
verify(navController).navigate(
TasksFragmentDirections.actionTasksFragmentToTaskDetailFragment( "id1")
)
}
- 运行你的测试!
总之,要测试导航,你可以
- 使用 Mockito 创建
NavController
模拟对象。 - 将该模拟的
NavController
附加到 Fragment。 - 验证 navigate 是否使用正确的操作和参数被调用。
步骤 3. 可选,编写 clickAddTaskButton_navigateToAddEditFragment
要查看你是否可以自己编写导航测试,请尝试此任务。
- 编写测试
clickAddTaskButton_navigateToAddEditFragment
,该测试检查点击 + FAB 时是否会导航到AddEditTaskFragment
。
答案如下。
TasksFragmentTest.kt
@Test
fun clickAddTaskButton_navigateToAddEditFragment() {
// GIVEN - On the home screen
val scenario = launchFragmentInContainer<TasksFragment>(Bundle(), R.style.AppTheme)
val navController = mock(NavController::class.java)
scenario.onFragment {
Navigation.setViewNavController(it.view!!, navController)
}
// WHEN - Click on the "+" button
onView(withId(R.id.add_task_fab)).perform(click())
// THEN - Verify that we navigate to the add screen
verify(navController).navigate(
TasksFragmentDirections.actionTasksFragmentToAddEditTaskFragment(
null, getApplicationContext<Context>().getString(R.string.add_task)
)
)
}
12. 解决方案代码
点击 此处 查看您开始的代码和最终代码之间的差异。
要下载完成的 codelab 的代码,可以使用以下 git 命令
$ git clone https://github.com/google-developer-training/advanced-android-testing.git $ cd android-testing $ git checkout end_codelab_2
或者,您可以将存储库下载为 Zip 文件,解压缩它,并在 Android Studio 中打开它。
13. 总结
本 codelab 涵盖了如何设置手动依赖注入、服务定位器以及如何在 Android Kotlin 应用程序中使用模拟和存根。特别是
- 您想要测试的内容和测试策略决定了您将为应用程序实现的测试类型。单元测试 专注且快速。集成测试 验证程序各部分之间的交互。端到端测试 验证功能,具有最高的保真度,通常是工具化的,并且运行时间可能更长。
- 应用程序的架构会影响测试的难易程度。
- 为了隔离应用程序的各部分以进行测试,您可以使用测试替身。测试替身 是专门为测试而设计的类版本。例如,您可以模拟从数据库或互联网获取数据。
- 使用依赖注入将真实类替换为测试类,例如,资源库或网络层。
- 使用工具化测试(
androidTest
)启动 UI 组件。 - 当您无法使用构造函数依赖注入时,例如启动片段时,通常可以使用服务定位器。服务定位器模式 是依赖注入的替代方案。它涉及创建一个名为“服务定位器”的单例类,其目的是提供依赖项,无论是在常规代码还是测试代码中。
14. 了解更多
示例
- 官方测试示例 - 这是官方的测试示例,它基于此处使用的相同 TO-DO Notes 应用程序。本示例中的概念超出了三个测试 codelab 中涵盖的内容。
- 向日葵演示 - 这是主要的 Android Jetpack 示例,它也使用 Android 测试库
- Espresso 测试示例
Udacity 课程
Android 开发者文档
- 应用程序架构指南
runBlocking
和runBlockingTest
FragmentScenario
- Espresso
- Mockito
- JUnit4
- AndroidX Test Library
- AndroidX 架构组件核心测试库
- 源集
- 从命令行测试
- Android 上的依赖注入
视频
- Android 上依赖注入的意见指南(Android Dev Summit ‘19)
- 构建可测试的 Android 应用程序(Google I/O'19)
- 片段:过去、现在和未来(Android Dev Summit ‘19) - 测试和片段部分
其他
15. 下一个 codelab
开始下一课:5.3:测试主题调查