1. 欢迎
简介
本 Codelab 是关于测试替身的第二个测试 Codelab:何时在 Android 中使用测试替身,以及如何使用依赖注入、Service Locator 模式和库来实现测试替身。通过本 Codelab,您将学习如何编写
- Repository 单元测试
- Fragment 和 ViewModel 集成测试
- Fragment 导航测试
前置知识
您应熟悉
- 第一个 Codelab 中涵盖的测试概念:在 Android 上编写和运行单元测试,使用 JUnit、Hamcrest、AndroidX test、Robolectric,以及测试 LiveData
- 以下核心 Android Jetpack 库:
ViewModel
、LiveData
和 Navigation Component - 应用架构,遵循应用架构指南和Android Fundamentals Codelab 中的模式
- Android 上的协程基础知识
您将学习
- 如何规划测试策略
- 如何创建和使用测试替身,即 Fake(伪对象)和 Mock(模拟对象)
- 如何在 Android 上使用手动依赖注入进行单元测试和集成测试
- 如何应用 Service Locator 模式
- 如何测试 Repository、Fragment、ViewModel 和 Navigation Component
您将使用以下库和代码概念
您将执行的操作
- 使用测试替身和依赖注入为 Repository 编写单元测试。
- 使用测试替身和依赖注入为 ViewModel 编写单元测试。
- 使用 Espresso UI 测试框架为 Fragment 及其 ViewModel 编写集成测试。
- 使用 Mockito 和 Espresso 编写导航测试。
2. 应用概览
在本系列 Codelab 中,您将使用 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 保存。
- 在任务列表中,点击您刚刚完成的任务标题,查看该任务的详细信息屏幕以查看其余说明。
- 在列表或详细信息屏幕上,勾选该任务的复选框,将其状态设置为Completed(已完成)。
- 返回任务屏幕,打开过滤菜单,并按Active(活动)和Completed(已完成)状态过滤任务。
- 打开导航抽屉,然后点击Statistics(统计信息)。
- 返回概览屏幕,从导航抽屉菜单中选择Clear completed(清除已完成),以删除所有状态为Completed(已完成)的任务。
步骤 2:探索示例应用代码
TO-DO 应用基于 Architecture Blueprints 测试和架构示例。该应用遵循应用架构指南中的架构。它使用 ViewModel 和 Fragment、一个 Repository 以及 Room。如果您熟悉以下任何示例,本应用具有类似的架构
- Android Kotlin Fundamentals 培训 Codelab
- Advanced Android 培训 Codelab
- Room with a View Codelab
- Android Sunflower 示例
- 使用 Kotlin 开发 Android 应用 Udacity 培训课程
更重要的是您理解应用的整体架构,而不是深入理解任何一层的逻辑。
以下是您会找到的软件包摘要
软件包: | ||
| 添加或修改任务屏幕:添加或修改任务的 UI 层代码。 | |
| 数据层:处理任务的数据层。包含数据库、网络和 Repository 代码。 | |
| 统计信息屏幕:统计信息屏幕的 UI 层代码。 | |
| 任务详情屏幕:单个任务的 UI 层代码。 | |
| 任务列表屏幕:所有任务列表的 UI 层代码。 | |
| 工具类:应用各部分中使用的共享类,例如,用于多个屏幕上的滑动刷新布局。 |
数据层 (.data)
此应用包含一个模拟的网络层(位于 remote 软件包中)和一个数据库层(位于 local 软件包中)。为简单起见,在此项目中,网络层仅使用带有延迟的 HashMap
进行模拟,而非执行实际的网络请求。
DefaultTasksRepository
协调或介导网络层和数据库层之间的数据,并将数据返回给 UI 层。
UI 层 ( .addedittask, .statistics, .taskdetail, .tasks)
每个 UI 层软件包都包含一个 Fragment 和一个 ViewModel,以及 UI 所需的任何其他类(例如任务列表的 Adapter)。TaskActivity
是包含所有 Fragment 的 Activity。
导航
应用的导航由 Navigation Component 控制。它在 nav_graph.xml
文件中定义。ViewModel 中使用 Event
类触发导航;ViewModel 还确定要传递哪些参数。Fragment 观察 Event
并执行屏幕之间的实际导航。
3. 概念:测试策略
在本 Codelab 中,您将学习如何使用测试替身和依赖注入来测试 Repository、ViewModel 和 Fragment。在深入了解这些概念之前,了解将指导您编写这些测试的原因和方式非常重要。
本节涵盖了一些通用的测试最佳实践及其在 Android 中的应用。
测试金字塔
在考虑测试策略时,有三个相关的测试方面
- 范围 - 测试触及多少代码?测试可以针对单个方法、整个应用或介于两者之间的任何部分。
- 速度 - 测试运行有多快?测试速度从毫秒到几分钟不等。
- 逼真度 - 测试有多“接近真实世界”?例如,如果您测试的代码部分需要进行网络请求,测试代码是否会实际进行此网络请求,还是伪造结果?如果测试实际与网络通信,则表示其逼真度更高。权衡之处在于测试运行时间可能更长,如果网络中断可能导致错误,或者使用成本可能很高。
这些方面之间存在固有的权衡。例如,速度和逼真度就是一种权衡——通常情况下,测试越快,逼真度越低,反之亦然。将自动化测试划分为以下三类是一种常见方法
- 单元测试 — 这些是高度集中的测试,针对单个类(通常是该类中的单个方法)运行。如果单元测试失败,您可以确切知道代码中的问题所在。它们的逼真度较低,因为在实际应用中,涉及的内容远不止一个方法或类的执行。它们速度足够快,每次更改代码后都可以运行。它们通常是本地运行的测试(在
test
源集中)。示例:测试 ViewModel 和 Repository 中的单个方法。 - 集成测试 — 这些测试多个类之间的交互,以确保它们一起使用时行为符合预期。构建集成测试的一种方法是测试单个功能,例如保存任务的功能。它们测试的代码范围比单元测试更大,但仍经过优化以快速运行,而不是具有完整的逼真度。根据情况,它们既可以在本地运行,也可以作为仪器化测试运行。示例:测试单个 Fragment 和 ViewModel 对的所有功能。
- 端到端测试 (E2e) — 测试多个功能协同工作。它们测试应用的大部分内容,模拟真实使用情况,因此通常速度较慢。它们具有最高的逼真度,并告诉您应用作为一个整体确实可以正常工作。总的来说,这些测试将是仪器化测试(在
androidTest
源集中)。示例:启动整个应用并同时测试一些功能。
这些测试的建议比例通常用金字塔表示,其中绝大多数测试是单元测试。
架构与测试
您在测试金字塔的所有不同级别测试应用的能力与您的应用架构固有地相关。例如,架构极差的应用可能将其所有逻辑都放在一个方法中。您或许可以为此编写端到端测试,因为这些测试往往测试应用的大部分内容,但单元测试或集成测试呢?将所有代码放在一个地方,很难仅测试与单个单元或功能相关的代码。
一种更好的方法是将应用逻辑分解为多个方法和类,从而允许对每个部分进行独立测试。架构是一种划分和组织代码的方式,可以更轻松地进行单元测试和集成测试。您将测试的 TO-DO 应用遵循特定的架构
在本课程中,您将了解如何在适当的隔离环境中测试上述架构的各个部分
- 首先,您将对 Repository 进行单元测试。
- 然后,您将在 ViewModel 中使用测试替身,这对于 ViewModel 的单元测试和集成测试是必需的。
- 接下来,您将学习为Fragment 及其 ViewModel 编写集成测试。
- 最后,您将学习编写包含 Navigation Component 的集成测试。
端到端测试将在下一课中介绍。
4. 任务:创建一个 Fake Data Source
当您为一个类的部分(一个方法或一小组方法)编写单元测试时,您的目标是仅测试该类中的代码。
仅测试特定类中的代码可能比较棘手。让我们来看一个例子。打开 main
源集中的 data.source.DefaultTasksRepository
类。这是应用中的 Repository,也是您接下来将为其编写单元测试的类。
您的目标是仅测试该类中的代码。然而,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
是您可能对 Repository 进行的最“基本”的调用之一。此方法包括从 SQLite 数据库读取和进行网络调用(对 updateTasksFromRemoteDataSource
的调用)。这涉及的代码比仅 Repository 代码多得多。
以下是一些更具体的测试 Repository 困难的原因
- 即使为此 Repository 执行最简单的测试,您也需要考虑创建和管理数据库。这引出了诸如“这应该是本地测试还是仪器化测试?”以及是否应该使用 AndroidX Test 获取模拟 Android 环境等问题。
- 代码的某些部分(例如网络代码)可能需要很长时间才能运行,甚至偶尔失败,从而产生运行时间长且不稳定的测试。
- 您的测试可能会失去诊断测试失败是由哪段代码引起的的能力。您的测试可能会开始测试非 Repository 代码,例如,您所谓的“Repository”单元测试可能会因为某些依赖代码(例如数据库代码)中的问题而失败。
测试替身
解决此问题的方法是,在测试 Repository 时,不要使用实际的网络或数据库代码,而是使用测试替身。测试替身是专为测试而创建的类版本。它旨在在测试中替换类的实际版本。这类似于特技替身是专门从事特技表演的演员,并在危险动作中取代真实演员。
以下是几种类型的测试替身
Fake(伪对象) | 一种测试替身,具有该类的“可工作”实现,但其实现方式使其适用于测试而不适用于生产环境。 |
Mock(模拟对象) | 一种测试替身,可跟踪其哪些方法被调用。然后,它根据其方法是否被正确调用来决定测试通过或失败。 |
Stub(桩) | 一种测试替身,不包含任何逻辑,仅返回您编程让它返回的内容。例如,可以将 |
Dummy(哑对象) | 一种测试替身,仅用于传递但不实际使用,例如仅需要将其作为参数提供的情况。如果您有一个 |
Spy(间谍对象) | 一种测试替身,还跟踪一些额外信息;例如,如果您创建了一个 |
如需详细了解测试替身,请查阅Testing on the Toilet: Know Your Test Doubles。
Android 中最常用的测试替身是 Fake(伪对象) 和 Mock(模拟对象)。
在本任务中,您将创建一个 FakeDataSource
测试替身,以便在与实际数据源解耦的情况下对 DefaultTasksRepository
进行单元测试。
步骤 1:创建 FakeDataSource
类
在本步骤中,您将创建一个名为 FakeDataSouce
的类,它将是 LocalDataSource
和 RemoteDataSource
的测试替身。
- 在 test 源集中,右键点击并选择 New -> Package。
- 创建一个 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
所需的方法。
- 使用快速修复菜单,然后选择 Implement members。
- 选择所有方法,然后按 OK。
步骤 3:在 FakeDataSource
中实现 getTasks
方法
FakeDataSource
是一种特定类型的测试替身,称为 fake(伪对象)。fake 是一种测试替身,它具有该类的“可工作”实现,但其实现方式使其适用于测试而不适用于生产环境。“可工作”实现意味着该类会根据输入产生逼真的输出。
例如,您的伪数据源不会连接到网络或将任何内容保存到数据库中,而是仅使用内存中的列表。这将“按您预期的方式工作”,即获取或保存任务的方法将返回预期结果,但您永远无法在生产环境中使用此实现,因为它不会保存到服务器或数据库中。
一个 FakeDataSource
- 允许您测试
DefaultTasksRepository
中的代码,而无需依赖实际的数据库或网络。 - 为测试提供了“足够真实”的实现。
- 更改
FakeDataSource
构造函数,创建一个名为tasks
的var
,其类型为MutableList<Task>?
,默认值为一个空的 MutableList。
class FakeDataSource(var tasks: MutableList<Task>? = mutableListOf()) : TasksDataSource { // Rest of class }
这是“伪装”成数据库或服务器响应的任务列表。目前,目标是测试 Repository 的 getTasks
方法。这会调用数据源的 getTasks
、deleteAllTasks
和 saveTask
方法。
编写这些方法的伪版本
- 编写
getTasks
:如果tasks
不是null
,则返回Success
结果。如果tasks
是null
,则返回Error
结果。 - 编写
deleteAllTasks
:清除 mutable tasks 列表。 - 编写
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 语句
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
类名,然后选择 Generate(生成),再选择 Test.(测试)。 - 按照提示在 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
成员变量(每个变量对应 Repository 的一个数据源)和一个用于测试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
测试吧!
- 为 Repository 的
getTasks
方法编写一个测试。检查当您调用getTasks
并传入true
(表示应从远程数据源重新加载)时,它是否返回远程数据源的数据(而不是本地数据源的数据)。
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
将测试协程所需的依赖项添加到 test 源集。
app/build.gradle
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"
别忘了同步!
kotlinx-coroutines-test
是协程测试库,专用于测试协程。要运行您的测试,请使用函数 runBlockingTest
。这是协程测试库提供的一个函数。它接受一段代码块,然后在一个特殊的协程上下文中运行此代码块,该上下文同步且立即运行,这意味着操作将按确定性顺序发生。这实际上使您的协程像非协程一样运行,因此它专用于测试代码。
当您调用 suspend
函数时,请在测试类中使用 runBlockingTest
。您将在本系列中的下一个 Codelab 中了解更多关于 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. 任务:设置一个 Fake Repository
您刚刚了解了如何对 Repository 进行单元测试。在接下来的步骤中,您将再次使用依赖注入并创建另一个测试替身,这一次是为了展示如何为 ViewModel 编写单元测试和集成测试。
单元测试应该仅测试您感兴趣的类或方法。这称为在隔离环境中测试,即清晰地隔离您的“单元”,并仅测试属于该单元的代码。
因此,TasksViewModelTest
应该只测试 TasksViewModel
代码,而不应该测试数据库、网络或 Repository 类中的代码。因此,对于您的 ViewModel,就像您刚刚为 Repository 所做的那样,您将创建一个伪 Repository 并应用依赖注入在测试中使用它。
在本任务中,您将对 ViewModel 应用依赖注入。
步骤 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
并右键点击类名。然后选择 Refactor -> Extract -> Interface。
- 选择 Extract to separate file。
- 在 Extract Interface 窗口中,将接口名称更改为
TasksRepository
。 - 在 Members to form interface 部分,勾选所有成员,除了两个 companion 成员和 private 方法。
- 点击 Refactor(重构)。新的
TasksRepository
接口应出现在 data/source 软件包中。
现在 DefaultTasksRepository
实现了 TasksRepository
接口。
- 运行您的应用(而不是测试),确保一切正常运行。
步骤 2. 创建 FakeTestRepository
现在您已经有了接口,可以创建 DefaultTasksRepository
测试替身了。
- 在 test 源集中,在 data/source 中创建 Kotlin 文件和类
FakeTestRepository.kt
,并继承自TasksRepository
接口。
FakeTestRepository.kt
class FakeTestRepository : TasksRepository {
}
系统会告知您需要实现接口方法。
- 将鼠标悬停在错误上方,直到看到建议菜单,然后点击并选择 Implement members。
- 选择所有方法,然后按 OK。
步骤 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
更接近模拟 Repository 的“真实”实现,并且对于 Fake 来说是更好的选择,这样它们的行为更接近真实实现。
当您在测试类中时(即包含 @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
方法
在测试时,最好在您的 Repository 中已经有一些 Tasks
。您可以多次调用 saveTask
,但为了更简单,可以添加一个专门用于测试的辅助方法来让您添加任务。
- 添加
addTasks
方法,它接受可变数量的任务 (vararg
of tasks),将每个任务添加到HashMap
中,然后刷新任务。
FakeTestRepository.kt
fun addTasks(vararg tasks: Task) {
for (task in tasks) {
tasksServiceData[task.id] = task
}
runBlocking { refreshTasks() }
}
至此,您已经有了一个实现了几个关键方法的用于测试的伪 Repository。接下来,在您的测试中使用它吧!
7. 任务:在 ViewModel 中使用 Fake Repository
在本任务中,您将在 ViewModel
内部使用一个伪类。使用构造函数依赖注入,通过将 TasksRepository
变量添加到 TasksViewModel
的构造函数中,来接受两个数据源。
对于 ViewModel,这个过程有所不同,因为您不会直接构建它们。例如
class TasksFragment : Fragment() {
private val viewModel by viewModels<TasksViewModel>()
// Rest of class...
}
如上述代码所示,您正在使用 viewModel
的 属性委托 来创建 ViewModel。要更改 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
构建方式的标准方法。既然您已经有了工厂,请在您构建 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
现在,您可以在 ViewModel 测试中使用伪 Repository,而不是使用实际的 Repository。
- 打开
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
,然后使用此 Repository 构造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
来代替真实的 Repository。
8. 任务:从测试中启动 Fragment
接下来,您将编写集成测试来测试您的 Fragment 和 ViewModel 交互。您将了解您的 ViewModel 代码是否适当地更新了您的 UI。为此,您可以使用
- ServiceLocator 模式
- Espresso 和 Mockito 库
集成测试测试多个类之间的交互,以确保它们一起使用时行为符合预期。这些测试可以在本地运行(test
源集),也可以作为仪器化测试运行(androidTest
源集)。
在您的情况下,您将针对每个 Fragment 编写集成测试,以测试 Fragment 和 ViewModel 的主要功能。
步骤 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 test 库kotlinx-coroutines-test
— 协程测试库androidx.fragment:fragment-testing
— 用于在测试中创建 Fragment 并更改其状态的 AndroidX test 库。
由于您将在 androidTest
源集中使用这些库,因此请使用 androidTestImplementation
将它们添加为依赖项。
步骤 2. 创建 TaskDetailFragmentTest
类
TaskDetailFragment
显示单个任务的信息。
您将从为 TaskDetailFragment
编写 Fragment 测试开始,因为它与其他 Fragment 相比功能相当基本。
- 打开
taskdetail.TaskDetailFragment
。 - 生成
TaskDetailFragment
的测试,就像您之前做的那样。接受默认选项,并将其放在 androidTest 源集中(而不是test
源集)。
- 将以下注解添加到
TaskDetailFragmentTest
类。
TaskDetailFragmentTest.kt
@MediumTest
@RunWith(AndroidJUnit4::class)
class TaskDetailFragmentTest {
}
这些注解的目的是
@MediumTest
— 将测试标记为“中等运行时”集成测试(相对于@SmallTest
单元测试和@LargeTest
端到端测试)。这有助于您对测试进行分组并选择要运行的测试大小。@RunWith(AndroidJUnit4::class)
— 用于使用 AndroidX Test 的任何类中。
步骤 3. 从测试中启动 Fragment
在本任务中,您将使用 AndroidX Testing 库启动 TaskDetailFragment
。FragmentScenario
是 AndroidX Test 中的一个类,它封装了一个 Fragment 并允许您直接控制 Fragment 的生命周期以便进行测试。为了为 Fragment 编写测试,您需要为您正在测试的 Fragment (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
,它表示传递给 Fragment 的任务的 Fragment 参数。 - 函数
launchFragmentInContainer
使用此 Bundle 和一个主题创建一个FragmentScenario
。
这还不是一个完整的测试,因为它没有断言任何内容。现在,运行测试并观察会发生什么。
- 这是一个仪器化测试,因此请确保模拟器或您的设备可见。
- 运行测试。
应该会发生一些事情。
- 首先,因为这是一个仪器化测试,测试将在您的物理设备(如果已连接)或模拟器上运行。
- 它应该会启动 Fragment。
- 请注意,它不会导航到任何其他 Fragment,也没有与 Activity 关联的任何菜单 - 它只是 Fragment 本身。
最后,仔细查看并注意 Fragment 显示“无数据”,因为它未成功加载任务数据。
您的测试既需要加载 TaskDetailFragment
(您已完成此操作),又需要断言数据已正确加载。为什么没有数据?这是因为您创建了一个任务,但没有将其保存到 Repository。
@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
,但您需要某种方法将您的真实 Repository 替换为您的 Fragment 所使用的伪 Repository。接下来您将完成此操作!
9. 任务:创建 ServiceLocator
在本任务中,您将使用 ServiceLocator
向 Fragment 提供伪 Repository。这将使您能够编写 Fragment 和 ViewModel 集成测试。
在这里,您不能像之前那样使用构造函数依赖注入来为 ViewModel 或 Repository 提供依赖项。构造函数依赖注入要求您构建该类。Fragment 和 Activity 就是您不构建且通常无法访问其构造函数的类的示例。
由于您不构建 Fragment,因此无法使用构造函数依赖注入将 Repository 测试替身 (FakeTestRepository
) 替换到 Fragment 中。请改用 Service Locator 模式。Service Locator 模式是依赖注入的替代方案。它涉及创建一个名为“Service Locator”的单例类,其目的是为常规代码和测试代码提供依赖项。在常规应用代码(main
源集)中,所有这些依赖项都是常规应用依赖项。对于测试,您修改 Service Locator 以提供依赖项的测试替身版本。
不使用 Service Locator | 使用 Service Locator |
对于本 Codelab 应用,请执行以下操作
- 创建一个 Service Locator 类,它能够构建和存储 Repository。默认情况下,它构建一个“正常”的 Repository。
- 重构您的代码,以便在需要 Repository 时使用 Service Locator。
- 在您的测试类中,调用 Service Locator 上的一个方法,用您的测试替身替换“正常”的 Repository。
步骤 1. 创建 ServiceLocator
让我们创建一个 ServiceLocator
类。它将与应用的其余代码一起位于 main 源集中,因为它由主应用代码使用。
注意:ServiceLocator
是一个单例,因此请对该类使用 Kotlin object
关键字。
- 在 main 源集的顶层目录中创建文件 ServiceLocator.kt。
- 定义一个名为
ServiceLocator
的object
。 - 创建
database
和repository
实例变量,并将它们都设置为null
。 - 使用
@Volatile
注解 Repository,因为它可能被多个线程使用(@Volatile
的详细解释请见此处)。
您的代码应如下所示。
object ServiceLocator {
private var database: ToDoDatabase? = null
@Volatile
var tasksRepository: TasksRepository? = null
}
目前,ServiceLocator
唯一需要做的就是知道如何返回一个 TasksRepository
。如果需要,它将返回一个预先存在的 DefaultTasksRepository
,或者创建一个新的并返回。
定义以下函数
provideTasksRepository
— 提供一个已存在的 Repository 或创建一个新的。此方法应对this
进行synchronized
同步,以避免在多线程运行的情况下意外创建两个 Repository 实例。createTasksRepository
— 创建新 Repository 的代码。将调用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
中一处创建 Repository。
重要的是您只创建 Repository 类的一个实例。为了确保这一点,您将在 TodoApplication 类中使用 Service Locator。
- 在您的包层次结构的顶层,打开
TodoApplication
,并为您的 Repository 创建一个val
,然后使用ServiceLocator.provideTaskRepository
获取一个 Repository 并赋值给它。
TodoApplication.kt
class TodoApplication : Application() {
val taskRepository: TasksRepository
get() = ServiceLocator.provideTasksRepository(this)
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) Timber.plant(DebugTree())
}
}
现在您已在应用中创建了 Repository,您可以移除 DefaultTasksRepository
中旧的 getRepository
方法。
- 打开
DefaultTasksRepository
并删除 companion object。
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
提供的 Repository,而不是直接创建 Repository。
- 打开
TaskDetailFragement
,找到类顶部的对getRepository
的调用。 - 将此调用替换为从
TodoApplication
获取 Repository 的调用。
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
,更新获取 Repository 的代码,使其使用从TodoApplication
获取的 Repository。
// REPLACE this code
private val tasksRepository = DefaultTasksRepository.getRepository(application)
// WITH this code
private val tasksRepository = (application as TodoApplication).taskRepository
- 运行您的应用(不是测试)!
由于您只是进行了重构,应用应该可以无问题地正常运行。
步骤 3. 创建 FakeAndroidTestRepository
您在 test 源集中已经有一个 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 是 public 的原因是为了测试。
ServiceLocator.kt
@Volatile
var tasksRepository: TasksRepository? = null
@VisibleForTesting set
无论您单独运行测试还是在一组测试中运行,您的测试都应该运行完全相同。这意味着您的测试不应该具有相互依赖的行为(即避免在测试之间共享对象)。
由于 ServiceLocator
是一个单例,它有可能在测试之间被意外共享。为了帮助避免这种情况,创建一个在测试之间正确重置 ServiceLocator
状态的方法。
- 添加一个名为
lock
的实例变量,其值为Any
。
ServiceLocator.kt
private val lock = Any()
- 添加一个测试专用方法
resetRepository
,用于清除数据库并将 Repository 和数据库都设置为 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 方法和一个 tear down 方法,以便在每个测试之前设置
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 中。
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,不同的是,这次由于您正确设置了 Repository,它现在显示了任务信息。
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 测试,最佳实践是关闭动画(你的测试也会运行得更快!)
- 在你的测试设备上,前往 Settings > Developer options。
- 禁用这三个设置:Window animation scale、Transition animation scale 和 Animator duration scale。
第 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 语句
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
和 check 来对详情页面的外观进行断言。- 运行测试并确认它通过。
第 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 编写导航测试
在这最后一步中,你将学习如何测试导航组件,使用一种不同类型的测试替身,称为 mock(模拟对象),以及测试库 Mockito。
在此 Codelab 中,你使用了一种称为 fake(伪造对象)的测试替身。伪造对象是多种测试替身中的一种。你应该使用哪种测试替身来测试导航组件?
思考一下导航是如何发生的。想象一下在 TasksFragment
中按下一个任务,以导航到任务详情屏幕。
这是 TasksFragment
中,当任务被按下时导航到任务详情屏幕的代码。
TasksFragment.kt
private fun openTaskDetails(taskId: String) {
val action = TasksFragmentDirections.actionTasksFragmentToTaskDetailFragment(taskId)
findNavController().navigate(action)
}
导航的发生是因为调用了 navigate
方法。如果你需要编写一个断言语句,没有直接的方法可以测试你是否导航到了 TaskDetailFragment
。导航是一个复杂的操作,除了初始化 TaskDetailFragment
之外,不会产生明确的输出或状态变化。
你可以断言的是 navigate
方法是否以正确的 action 参数被调用。这正是 mock(模拟对象)测试替身的作用——它检查特定的方法是否被调用。
Mockito 是一个用于创建测试替身的框架。尽管 API 和名称中使用了 mock 这个词,但它不仅仅用于创建模拟对象。它还可以创建 stub(桩)和 spy(间谍)。
你将使用 Mockito 创建一个 mock 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
)的测试代码。它还包含 Accessibility checks(可访问性检查)以及稍后将介绍的名为CountingIdlingResource
的类。
第 2 步:创建 TasksFragmentTest
- 打开
TasksFragment
。 - 右键点击
TasksFragment
类名,选择 Generate,然后选择 Test。在 androidTest source set 中创建测试。 - 将此代码复制到
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
函数创建一个 mock。
TasksFragmentTest.kt
val navController = mock(NavController::class.java)
在 Mockito 中创建 mock 时,传入你想要模拟的类。
接下来,你需要将你的 NavController
与 fragment 关联起来。onFragment
允许你直接在 fragment 上调用方法。
- 将你的新 mock 设置为 fragment 的
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
方法使其成为一个 mock——你可以确认被模拟的 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
mock。 - 将该 mock
NavController
附加到 fragment。 - 验证 navigate 是否以正确的 action 和参数被调用。
第 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 应用中设置手动依赖注入、服务定位器,以及如何使用 fake 和 mock。具体来说
- 你想要测试的内容和你的测试策略决定了你将为你的应用实现的测试类型。单元测试是专注于单个单元且快速的。集成测试验证程序各个部分之间的交互。端到端测试验证整个功能,具有最高的逼真度,通常是插桩测试,并且运行时间可能更长。
- 你应用的架构影响了测试的难度。
- 为了隔离你应用的各个部分进行测试,可以使用测试替身。测试替身是专门为测试而创建的类的版本。例如,你可以伪造从数据库或互联网获取数据。
- 使用依赖注入将真实的类替换为测试类,例如仓库或网络层。
- 使用插桩测试(
androidTest
)来启动 UI 组件。 - 当你无法使用构造函数依赖注入(例如启动一个 fragment)时,通常可以使用服务定位器。服务定位器模式是依赖注入的一种替代方案。它涉及创建一个名为“服务定位器”的单例类,其目的是为常规代码和测试代码提供依赖项。
14. 了解更多
示例
- 官方测试示例 - 这是官方测试示例,它基于此处使用的相同的 TO-DO Notes 应用。此示例中的概念超出了这三个测试 Codelab 中介绍的内容。
- Sunflower 演示 - 这是主要的 Android Jetpack 示例,它也使用了 Android 测试库
- Espresso 测试示例
Udacity 课程
Android 开发者文档
- 应用架构指南
runBlocking
和runBlockingTest
FragmentScenario
- Espresso
- Mockito
- JUnit4
- AndroidX Test Library
- AndroidX Architecture Components Core Test Library
- Source sets
- 从命令行测试
- Android 上的依赖注入
视频
- Android 上的依赖注入:有主见的指南 (Android Dev Summit ‘19)
- 构建可测试的 Android 应用 (Google I/O'19)
- Fragment:过去、现在和未来 (Android Dev Summit ‘19) - 测试和 Fragment 部分
其他
15. 下一个 Codelab
开始下一课:5.3:测试主题概述