1. 欢迎
简介
第二个测试 Codelab 主要介绍测试替身:何时在 Android 中使用它们,以及如何使用依赖注入、服务定位器模式和库来实现它们。在此过程中,您将学习如何编写:
- 资源库单元测试
- 片段和 ViewModel 集成测试
- 片段导航测试
您应该已了解的内容
您应该熟悉:
- 第一个 Codelab 中介绍的测试概念:在 Android 上编写和运行单元测试,使用 JUnit、Hamcrest、AndroidX Test、Robolectric 以及测试 LiveData
- 以下核心 Android Jetpack 库:
ViewModel
、LiveData
和导航组件 - 遵循应用架构指南和Android 基础知识 Codelab中模式的应用架构
- Android 上协程的基础知识
您将学习的内容
- 如何规划测试策略
- 如何创建和使用测试替身,即模拟对象和模拟桩
- 如何在 Android 中对单元测试和集成测试使用手动依赖注入
- 如何应用服务定位器模式
- 如何测试资源库、片段、ViewModel 和导航组件
您将使用以下库和代码概念:
您将执行的操作
- 使用测试替身和依赖注入编写资源库的单元测试。
- 使用测试替身和依赖注入编写 ViewModel 的单元测试。
- 使用 Espresso UI 测试框架编写片段及其 ViewModel 的集成测试。
- 使用 Mockito 和 Espresso 编写导航测试。
2. 应用概述
在本系列 Codelab 中,您将使用待办事项应用程序。该应用允许您记下要完成的任务,并将其显示在列表中。然后,您可以将它们标记为已完成或未完成,对其进行筛选或删除它们。
此应用使用 Kotlin 编写,包含几个屏幕,使用 Jetpack 组件,并遵循应用架构指南中的架构。通过学习如何测试此应用,您将能够测试使用相同库和架构的应用。
下载代码
要开始使用,请下载代码:
或者,您可以克隆代码的 Github 仓库:
$ git clone https://github.com/google-developer-training/advanced-android-testing.git $ cd android-testing $ git checkout end_codelab_1
您可以在android-testing Github 代码库中浏览代码。
请花一些时间熟悉代码,按照以下说明操作。
步骤 1:运行示例应用
下载待办事项应用后,在 Android Studio 中打开并运行它。它应该可以编译。通过执行以下操作来浏览应用:
- 使用加号浮动操作按钮创建一个新任务。先输入标题,然后输入有关任务的其他信息。使用绿色勾号 FAB 保存。
- 在任务列表中,点击您刚刚完成的任务的标题,查看该任务的详细信息屏幕以查看其余说明。
- 在列表中或详细信息屏幕上,选中该任务的复选框,将其状态设置为已完成。
- 返回任务屏幕,打开筛选菜单,按活动和已完成状态筛选任务。
- 打开导航抽屉并点击统计信息。
- 返回概述屏幕,然后从导航抽屉菜单中选择清除已完成的任务以删除所有状态为已完成的任务。
步骤 2:浏览示例应用代码
待办事项应用基于架构蓝图测试和架构示例。该应用遵循应用架构指南中的架构。它使用带有片段的 ViewModel、资源库和 Room。如果您熟悉以下任何示例,则此应用具有类似的架构:
- Android Kotlin 基础知识培训 Codelab
- 高级 Android 培训 Codelab
- 带视图的 Room Codelab
- Android 向日葵示例
- 使用 Kotlin 开发 Android 应用 Udacity 培训课程
重要的是您理解应用的总体架构,而不是对任何一层中的逻辑有深入的了解。
以下是您将找到的包的摘要:
包: | ||
| 添加或编辑任务屏幕:添加或编辑任务的 UI 层代码。 | |
| 数据层:处理任务的数据层。它包含数据库、网络和资源库代码。 | |
| 统计信息屏幕:统计信息屏幕的 UI 层代码。 | |
| 任务详细信息屏幕:单个任务的 UI 层代码。 | |
| 任务屏幕:所有任务列表的 UI 层代码。 | |
| 实用程序类:应用各个部分中使用的共享类,例如,用于多个屏幕上使用的滑动刷新布局。 |
数据层 (.data)
此应用包含一个模拟网络层(在远程包中)和一个数据库层(在本地包中)。为简便起见,在此项目中,网络层仅使用带延迟的HashMap
进行模拟,而不是发出真实的网络请求。
DefaultTasksRepository
协调或仲裁网络层和数据库层之间的关系,并向 UI 层返回数据。
UI 层(.addedittask、.statistics、.taskdetail、.tasks)
每个 UI 层包都包含一个片段和一个 ViewModel,以及 UI 所需的任何其他类(例如任务列表的适配器)。TaskActivity
是包含所有片段的活动。
导航
应用的导航由导航组件控制。它在nav_graph.xml
文件中定义。导航使用Event
类在 ViewModel 中触发;ViewModel 还确定要传递的参数。片段观察Event
并执行屏幕之间的实际导航。
3. 概念:测试策略
在本 Codelab 中,您将学习如何使用测试替身和依赖注入测试资源库、ViewModel 和片段。在深入了解它们是什么之前,重要的是要了解指导您编写这些测试的原因和方法。
本部分介绍一些通用的测试最佳实践,因为它们适用于 Android。
测试金字塔
在考虑测试策略时,有三个相关的测试方面:
- 范围—测试触及多少代码?测试可以在单个方法、整个应用程序或介于两者之间的某个位置运行。
- 速度—测试运行速度有多快?测试速度可以从毫秒到几分钟不等。
- 保真度—测试的“真实性”如何?例如,如果您正在测试的代码的一部分需要发出网络请求,则测试代码是否实际发出此网络请求,或者它是否伪造了结果?如果测试实际与网络通信,则意味着它具有更高的保真度。权衡是测试可能需要更长时间才能运行,如果网络中断可能会导致错误,或者使用成本可能很高。
这些方面之间存在固有的权衡。例如,速度和保真度是一种权衡——通常,测试速度越快,保真度越低,反之亦然。将自动化测试划分为以下三个类别的一种常见方法是:
- 单元测试—这些是高度集中的测试,运行于单个类(通常是该类中的单个方法)上。如果单元测试失败,您可以准确知道代码中的问题出在哪里。由于在现实世界中,您的应用涉及的内容远不止一个方法或类的执行,因此它们的保真度较低。它们的速度足够快,可以在每次更改代码时运行。它们最常是本地运行的测试(在
test
源代码集中)。示例:测试 ViewModel 和资源库中的单个方法。 - 集成测试—这些测试会测试多个类的交互,以确保它们在一起使用时按预期工作。构建集成测试的一种方法是让它们测试单个功能,例如保存任务的功能。它们测试的代码范围比单元测试更大,但仍然针对快速运行进行了优化,而不是具有完全保真度。它们可以根据情况在本地运行或作为 Instrumentation 测试运行。示例:测试单个 Fragment 和 ViewModel 对的所有功能。
- 端到端测试 (E2e)—测试一起工作的功能组合。它们测试应用程序的大部分内容,密切模拟实际使用情况,因此通常速度较慢。它们具有最高的保真度,并告诉您应用程序作为一个整体是否真正有效。总的来说,这些测试将是 Instrumentation 测试(在
androidTest
源集内)示例:启动整个应用程序并一起测试一些功能。
这些测试的建议比例通常用一个金字塔表示,其中绝大多数测试是单元测试。
架构和测试
您在测试金字塔的所有不同级别测试应用程序的能力与您的应用程序架构紧密相关。例如,架构极其糟糕的应用程序可能会将其所有逻辑都放在一个方法中。您也许可以为此编写端到端测试,因为这些测试往往会测试应用程序的大部分内容,但是编写单元测试或集成测试呢?由于所有代码都在一个地方,因此很难只测试与单个单元或功能相关的代码。
更好的方法是将应用程序逻辑分解成多个方法和类,允许单独测试每个部分。架构是一种划分和组织代码的方法,它允许更容易地进行单元测试和集成测试。您将要测试的待办事项应用程序遵循特定的架构
在本课程中,您将看到如何以适当的隔离方式测试上述架构的各个部分
- 首先,您将单元测试存储库。
- 然后,您将在 ViewModel 中使用测试替身,这对于单元测试和集成测试ViewModel 是必要的。
- 接下来,您将学习如何为Fragment 及其 ViewModel编写集成测试。
- 最后,您将学习如何编写包含导航组件的集成测试。
端到端测试将在下一课中介绍。
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
的调用)。这涉及比仅仅是存储库代码多得多的代码。
以下是测试存储库很困难的一些更具体的原因
- 即使是对这个存储库进行最简单的测试,也需要考虑创建和管理数据库。这会引发诸如“这应该是本地测试还是 Instrumentation 测试?”以及是否应该使用 AndroidX Test 来获得模拟的 Android 环境等问题。
- 某些代码部分,例如网络代码,可能需要很长时间才能运行,或者偶尔还会失败,从而导致运行时间长、测试不稳定的情况。
- 您的测试可能会失去诊断哪个代码是导致测试失败的罪魁祸首的能力。您的测试可能会开始测试非存储库代码,例如,您的所谓的“存储库”单元测试可能会由于依赖代码(例如数据库代码)中的问题而失败。
测试替身
解决方法是,在测试存储库时,不要使用真实的网络或数据库代码,而是使用测试替身。测试替身是专门为测试而设计的类的版本。它旨在在测试中替换类的真实版本。这类似于替身演员专门从事特技表演,并代替真实演员进行危险动作。
以下是一些测试替身的类型
伪造的 | 具有类“工作”实现的测试替身,但其实现方式使其适合测试但不适合生产。“工作”实现意味着该类将在给定输入的情况下产生现实的输出。 |
模拟的 | 跟踪其哪些方法被调用的测试替身。然后,它根据其方法是否被正确调用来通过或失败测试。 |
存根的 | 不包含任何逻辑并且只返回您编程它返回内容的测试替身。例如, |
虚拟的 | 传递但未使用测试替身,例如,如果您只需要将其作为参数提供。如果您有一个 |
间谍的 | 还跟踪某些附加信息的测试替身;例如,如果您创建了一个 |
有关测试替身的更多信息,请查看厕所上的测试:了解您的测试替身。
Android 中最常用的测试替身是伪造的和模拟的。
在此任务中,您将创建一个 FakeDataSource
测试替身,以对与实际数据源分离的 DefaultTasksRepository
进行单元测试。
步骤 1:创建 FakeDataSource 类
在此步骤中,您将创建一个名为 FakeDataSouce
的类,它将是 LocalDataSource
和 RemoteDataSource
的测试替身。
- 在test源集中,右键单击选择新建 -> 包。
- 创建一个包含source包的data包。
- 在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
列表实例化模拟数据源。 - 使用您刚刚创建的两个模拟数据源和
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
测试了!
- 为仓库的
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
如何工作以及如何测试协程的更多信息。
- 在类上方添加
@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
内使用模拟类。使用构造函数依赖注入,通过添加TasksRepository
变量到TasksViewModel
的构造函数中,通过构造函数依赖注入传入两个数据源。
这个过程在视图模型中有点不同,因为你不会直接构造它们。例如
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
。它位于test源集的tasks文件夹下。 - 在
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))
}
- 运行你的代码,确保一切正常。
你现在能够在TasksFragment
和TaskDetailFragment
中使用FakeTestRepository
来代替真实的存储库。
8. 任务:从测试启动Fragment
接下来,你将编写集成测试来测试你的片段和视图模型交互。你将找出你的视图模型代码是否适当地更新了你的UI。为此,你将使用
- 服务定位器模式
- 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
函数使用此 bundle 和主题创建一个FragmentScenario
。
这还不是一个完整的测试,因为它没有断言任何内容。现在,运行测试并观察会发生什么。
- 这是一个 instrumentation 测试,因此请确保模拟器或你的设备可见。
- **运行** 测试。
应该会发生一些事情。
- 首先,因为这是一个 instrumentation 测试,所以测试将在你的物理设备(如果已连接)或模拟器上运行。
- 它应该启动片段。
- 注意它如何不导航到任何其他片段或没有与活动相关的任何菜单——它只是片段。
最后,仔细观察并注意片段显示“无数据”,因为它没有成功加载任务数据。
你的测试需要加载 TaskDetailFragment
(你已经完成了)并断言数据已正确加载。为什么没有数据?这是因为你创建了一个任务,但你没有将其保存到存储库。
@Test
fun activeTaskDetails_DisplayedInUi() {
// This DOES NOT save the task anywhere
val activeTask = Task("Active Task", "AndroidX Rocks", false)
val bundle = TaskDetailFragmentArgs(activeTask.id).toBundle()
launchFragmentInContainer<TaskDetailFragment>(bundle, R.style.AppTheme)
}
你拥有这个 FakeTestRepository
,但是你需要某种方法将你的真实存储库替换为你用于片段的模拟存储库。接下来你将执行此操作!
9. 任务:创建 ServiceLocator
在此任务中,你将使用 ServiceLocator
为你的片段提供模拟存储库。这将允许你编写片段和视图模型集成测试。
你不能在这里使用构造函数依赖注入,就像你之前那样,当你需要向视图模型或存储库提供依赖项时。构造函数依赖注入要求你构造类。片段和活动是你不会构造并且通常无法访问其构造函数的类的示例。
由于你没有构造片段,因此你不能使用构造函数依赖注入将存储库测试替身(FakeTestRepository
)交换到片段。相反,使用 服务定位器 模式。服务定位器模式是依赖注入的替代方案。它涉及创建一个名为“服务定位器”的单例类,其目的是为常规代码和测试代码提供依赖项。在常规应用程序代码(main
源集)中,所有这些依赖项都是常规应用程序依赖项。对于测试,你修改服务定位器以提供依赖项的测试替身版本。
**不使用服务定位器** | **使用服务定位器** |
对于此 codelab 应用程序,请执行以下操作:
- 创建一个能够构造和存储存储库的服务定位器类。默认情况下,它构造一个“普通”存储库。
- 重构你的代码,以便当你需要存储库时,使用服务定位器。
- 在你的测试类中,调用服务定位器上的一个方法,该方法将“普通”存储库与你的测试替身交换。
步骤 1. 创建 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. 在应用程序中使用 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
变量。 - 添加一个设置方法和一个拆卸方法,以便在每次测试之前设置
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
中。 - 在启动片段之前,将
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()
测试。
与之前一样,您应该会看到片段,但这次由于您正确设置了存储库,它现在显示任务信息。
10. 任务:使用 Espresso 编写第一个集成测试
在此步骤中,您将使用 Espresso UI 测试库来完成您的第一个集成测试。您已构建好代码,以便您可以添加包含 UI 断言的测试。为此,您将使用Espresso 测试库。
Espresso 可帮助您
- 与视图交互,例如单击按钮、滑动条或向下滚动屏幕。
- 断言某些视图是否在屏幕上或处于特定状态(例如包含特定文本,或复选框是否已选中等)。
步骤 1. 注意 Gradle 依赖项
您已经拥有主要的 Espresso 依赖项,因为它默认包含在 Android 项目中。
app/build.gradle
dependencies {
// ALREADY in your code
androidTestImplementation "androidx.test.espresso:espresso-core:$espressoVersion"
// Other dependencies
}
androidx.test.espresso:espresso-core
—此核心 Espresso 依赖项在您创建新的 Android 项目时默认包含。它包含大多数视图及其操作的基本测试代码。
步骤 2. 关闭动画
Espresso 测试在真实设备上运行,因此本质上是仪表测试。出现的一个问题是动画:如果动画滞后,并且您尝试测试视图是否在屏幕上,但它仍在动画中,Espresso 可能会意外地使测试失败。这会使 Espresso 测试不稳定。
对于 Espresso UI 测试,最好关闭动画(您的测试运行速度也会更快!)
- 在您的测试设备上,转到**设置 > 开发者选项**。
- 禁用以下三个设置:**窗口动画比例**、**转换动画比例**和**动画持续时间比例**。
步骤 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())
perform
方法接受ViewAction
。ViewAction
是可以对视图执行的操作,例如这里,它是在单击视图。
check(matches(isChecked()))
check
接受ViewAssertion
。ViewAssertion
检查或断言视图的某些内容。您将使用的最常见的ViewAssertion
是matches
断言。要完成断言,请使用另一个ViewMatcher
,在本例中为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和名称中使用了“mock”一词,但这**并非**仅仅用于创建模拟对象。它还可以创建存根和间谍。
您将使用Mockito创建一个模拟的NavigationController
,它可以断言navigate方法是否被正确调用。
步骤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
与片段关联。onFragment
允许您在片段本身调用方法。
- 将您的新模拟对象设为片段的
NavController
。
scenario.onFragment {
Navigation.setViewNavController(it.view!!, navController)
}
- 添加代码以单击
RecyclerView
中带有文本“TITLE1”的项目。
// 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
调用了具有参数的特定方法(navigate
)(actionTasksFragmentToTaskDetailFragment
,ID为“id1”)。
完整的测试如下所示
@Test
fun clickTask_navigateToDetailFragmentOne() = runBlockingTest {
repository.saveTask(Task("TITLE1", "DESCRIPTION1", false, "id1"))
repository.saveTask(Task("TITLE2", "DESCRIPTION2", true, "id2"))
// GIVEN - On the home screen
val scenario = launchFragmentInContainer<TasksFragment>(Bundle(), R.style.AppTheme)
val navController = mock(NavController::class.java)
scenario.onFragment {
Navigation.setViewNavController(it.view!!, navController)
}
// WHEN - Click on the first list item
onView(withId(R.id.tasks_list))
.perform(RecyclerViewActions.actionOnItem<RecyclerView.ViewHolder>(
hasDescendant(withText("TITLE1")), click()))
// THEN - Verify that we navigate to the first detail screen
verify(navController).navigate(
TasksFragmentDirections.actionTasksFragmentToTaskDetailFragment( "id1")
)
}
- 运行您的测试!
总而言之,要测试导航,您可以
- 使用Mockito创建一个
NavController
模拟对象。 - 将该模拟的
NavController
附加到片段。 - 验证是否使用正确的操作和参数调用了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. 了解更多
示例
- 官方测试示例 - 这是官方测试示例,它基于此处使用的相同的待办事项应用程序。此示例中的概念超出了三个测试codelab中介绍的内容。
- 向日葵演示 - 这是主要的Android Jetpack示例,它也使用了Android测试库
- Espresso测试示例
Udacity课程
Android开发者文档
- 应用程序架构指南
runBlocking
和runBlockingTest
FragmentScenario
- Espresso
- Mockito
- JUnit4
- AndroidX 测试库
- AndroidX 架构组件核心测试库
- 源集
- 从命令行进行测试
- Android上的依赖注入
视频
- 关于Android依赖注入的独到见解(Android Dev Summit ‘19)
- 构建可测试的Android应用程序(Google I/O '19)
- 片段:过去、现在和未来(Android Dev Summit ‘19) - 测试和片段部分
其他
15. 下一个codelab
开始下一课:5.3:测试主题调查