1. 欢迎
简介
本第三个测试 Codelab 概述了其他测试主题,包括:
- 协程,包括 view model 作用域的协程
- Room
- 数据绑定
- 端到端测试
前提条件
您应该熟悉以下内容:
- 5.1 测试基础知识和5.2 依赖注入和测试替身 Codelab 中涵盖的测试概念:在 Android 上编写和运行单元测试,使用 JUnit、Hamcrest、AndroidX 测试、Robolectric、LiveData 测试、手动依赖注入、测试替身(模拟对象和伪造对象)、服务定位器、导航组件测试和 Espresso。
- 以下核心 Android Jetpack 库:
view model
、LiveData
、数据绑定和导航组件 - 应用架构,遵循应用架构指南和Android 基础知识 Codelab 中的模式。
- Android 上协程的基础知识(包括
ViewModelScope
)。
您将学到什么
- 如何测试协程,包括 view model 作用域的协程。
- 如何测试简单的错误边缘情况。
- 如何测试 Room。
- 如何使用 Espresso 测试数据绑定。
- 如何编写端到端测试。
- 如何测试全局应用导航。
您将使用
runBlocking
和runBlockingTest
TestCoroutineDispatcher
pauseDispatcher
和resumeDispatcher
inMemoryDatabaseBuilder
IdlingResource
您将执行的操作
- 编写使用
viewModelScope
测试代码的ViewModel
集成测试。 - 暂停和恢复协程执行以便进行测试。
- 修改伪造存储库以支持错误测试。
- 编写 DAO 单元测试。
- 编写本地数据源集成测试。
- 编写包含协程和数据绑定代码的端到端测试。
- 编写全局应用导航测试。
2. 应用概览
在本系列 Codelab 中,您将使用待办事项 (TO-DO) 记事应用。该应用允许您写下要完成的任务并将其显示在列表中。然后,您可以将其标记为已完成或未完成,对其进行过滤或删除。
此应用采用 Kotlin 编写,包含几个屏幕,使用 Jetpack 组件,并遵循应用架构指南中的架构。学习如何测试此应用将使您能够测试使用相同库和架构的应用。
下载代码
首先,请下载代码
或者,您可以克隆代码的 Github 仓库
$ git clone https://github.com/google-developer-training/advanced-android-testing.git $ cd android-testing $ git checkout end_codelab_2
请花点时间熟悉代码,请遵循以下说明。
第 1 步:运行示例应用
下载待办事项应用后,在 Android Studio 中打开并运行它。它应该能够编译。请按照以下步骤探索应用:
- 使用加号浮动操作按钮创建新任务。先输入标题,然后输入有关任务的其他信息。使用绿色勾选 FAB 保存它。
- 在任务列表中,点击刚刚完成的任务标题,查看该任务的详细信息屏幕,以查看其余描述。
- 在列表或详细信息屏幕上,勾选该任务的复选框,将其状态设置为已完成。
- 返回到任务屏幕,打开过滤菜单,按活动和已完成状态过滤任务。
- 打开导航抽屉并点击统计数据。
- 返回到概览屏幕,然后从导航抽屉菜单中,选择清除已完成以删除所有状态为已完成的任务
第 2 步:探索示例应用代码
待办事项应用基于架构蓝图测试和架构示例。该应用遵循应用架构指南中的架构。它使用带有 Fragment 的 ViewModel、一个仓库和 Room。如果您熟悉以下任何示例,此应用也具有类似的架构:
- Kotlin 版 Android 基础知识训练营 Codelab
- 高级 Android 训练营 Codelab
- 带 View 的 Room Codelab
- Android Sunflower 示例
- 使用 Kotlin 开发 Android 应用 Udacity 训练课程
重要的是您理解应用的整体架构,而不是深入理解任何一层的逻辑。
以下是您将找到的软件包摘要:
软件包: | ||
| 添加或编辑任务屏幕: 用于添加或编辑任务的界面层代码。 | |
| 数据层: 这处理任务的数据层。它包含数据库、网络和仓库代码。 | |
| 统计信息屏幕: 用于统计信息屏幕的界面层代码。 | |
| 任务详细信息屏幕: 用于单个任务的界面层代码。 | |
| 任务屏幕: 用于所有任务列表的界面层代码。 | |
| 实用工具类: 应用各部分使用的共享类,例如用于多个屏幕上的下拉刷新布局。 |
数据层 (.data)
此应用包含一个模拟网络层(在 remote 软件包中)和一个数据库层(在 local 软件包中)。为了简化,在此项目中,网络层仅使用带有延迟的 HashMap
进行模拟,而不是进行实际网络请求。
DefaultTasksRepository
协调或调解网络层和数据库层之间的交互,并将数据返回给界面层。
界面层 ( .addedittask, .statistics, .taskdetail, .tasks)
每个界面层软件包都包含一个 fragment 和一个 view model,以及界面所需的任何其他类(例如任务列表的适配器)。TaskActivity
是包含所有 fragment 的 activity。
导航
应用的导航由导航组件控制。它在 nav_graph.xml
文件中定义。导航在 view model 中使用 Event
类触发;view model 还确定要传递哪些参数。fragment 观察 Event
并执行屏幕之间的实际导航。
3. 任务:协程测试简介和回顾
代码执行可以是同步的,也可以是异步的。
- 当代码同步运行时,一个任务完全完成后才会执行下一个任务。
- 当代码异步运行时,任务并行运行。
异步代码几乎总是用于长时间运行的任务,例如网络或数据库调用。它也可能难以测试。这有两个常见原因:
- 异步代码往往是非确定性的。这意味着如果一个测试并行运行操作 A 和 B 多次,有时 A 会先完成,有时 B 会先完成。这可能导致不稳定的测试(结果不一致的测试)。
- 测试时,您通常需要确保异步代码具有某种同步机制。测试在测试线程上运行。当您的测试在不同线程上运行代码或创建新的协程时,此工作会异步启动,与测试线程分开。同时,测试协程将继续并行执行指令。测试可能会在任何一个启动的任务完成之前完成。
同步机制是告知测试执行“等待”直到异步工作完成的方法。
在 Kotlin 中,运行异步代码的常见机制是协程。测试异步代码时,您需要使代码具有确定性并提供同步机制。以下类和方法有助于实现此目标:
- 使用
runBlockingTest
或runBlocking
。 - 在本地测试中使用
TestCoroutineDispatcher
。 - 暂停协程执行,以便在精确的时间点测试代码状态。
您将首先探讨 runBlockingTest
和 runBlocking
之间的区别。
第 1 步:观察如何在测试中运行基本协程
要测试包含 suspend
函数的代码,您需要执行以下操作:
- 将
kotlinx-coroutines-test
测试依赖项添加到应用的 build.gradle 文件中。 - 使用
@ExperimentalCoroutinesApi
注解测试类或测试函数。 - 使用
runBlockingTest
包围代码,以便测试等待协程完成。
我们来看一个示例。
- 打开应用的
build.gradle
文件。 - 找到
kotlinx-coroutines-test
依赖项(已为您提供)
app/build.gradle
// Dependencies for Android instrumented unit tests
androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"
kotlinx-coroutines-test
是用于测试协程的实验性库。它包含用于测试协程的实用工具,包括 runBlockingTest
。
每当您想从测试中运行协程时,都必须使用 runBlockingTest
。通常,这是当您需要从测试中调用 suspend 函数时。
- 请看 TaskDetailFragmentTest.kt 中的这个示例。请注意标有
//LOOK HERE
的行
TaskDetailFragmentTest.kt
@MediumTest
@RunWith(AndroidJUnit4::class)
@ExperimentalCoroutinesApi // LOOK HERE
class TaskDetailFragmentTest {
//... Setup and teardown
@Test
fun activeTaskDetails_DisplayedInUi() = runBlockingTest{ // LOOK HERE
// GIVEN - Add active (incomplete) task to the DB.
val activeTask = Task("Active Task", "AndroidX Rocks", false)
repository.saveTask(activeTask) // LOOK HERE Example of calling a suspend function
// 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.
// Lots of Espresso code...
}
// More tests...
}
要使用 runBlockingTest
,您需要:
- 使用
@ExperimentalCoroutinesApi
注解函数或类。 - 使用
runBlockingTest
包围调用suspend 函数的代码。
使用 kotlinx-coroutines-test
中的任何函数时,请使用 @ExperimentalCoroutinesApi
注解类或函数,因为 kotlinx-coroutines-test
仍处于实验阶段,API 可能会发生变化。如果您不这样做,会收到 lint 警告。
上方的代码中使用 runBlockingTest
是因为您正在调用 repository.saveTask(activeTask)
,这是一个 suspend
函数。
runBlockingTest
可以确定性地运行代码并提供同步机制。runBlockingTest
接受一个代码块,并阻塞测试线程,直到它启动的所有协程都完成。它还会立即运行协程中的代码(跳过对delay
的所有调用),并按照它们被调用的顺序运行,简而言之,它以确定性的顺序运行它们。
runBlockingTest
通过为您提供专门用于测试代码的协程上下文,本质上使您的协程像非协程一样运行。
您在测试中这样做是因为代码每次都以相同的方式运行(同步且确定性)非常重要。
第 2 步:在测试替身中观察 runBlocking
还有另一个函数 runBlocking
,当您需要在测试替身中(而不是在测试类中)使用协程时使用它。使用 runBlocking
看起来与 runBlockingTest
非常相似,您将它包围在代码块周围即可使用。
- 请看 FakeTestRepository.kt 中的这个示例。请注意,由于
runBlocking
不是kotlinx-coroutines-test
库的一部分,因此您不需要使用ExperimentalCoroutinesApi
注解。
FakeTestRepository.kt
class FakeTestRepository : TasksRepository {
var tasksServiceData: LinkedHashMap<String, Task> = LinkedHashMap()
private val observableTasks = MutableLiveData<Result<List<Task>>>()
// More code...
override fun observeTasks(): LiveData<Result<List<Task>>> {
runBlocking { refreshTasks() } // LOOK HERE
return observableTasks
}
override suspend fun refreshTasks() {
observableTasks.value = getTasks()
}
// More code...
}
与 runBlockingTest
类似,此处使用 runBlocking
是因为 refreshTasks
是一个 suspend 函数。
runBlocking 与 runBlockingTest
runBlocking
和 runBlockingTest
都阻塞当前线程,并等待 lambda 中启动的任何关联协程完成。
此外,runBlockingTest
具有以下专用于测试的行为:
- 它会跳过
delay
,因此您的测试运行速度更快。 - 它会在协程结束时添加与测试相关的断言。如果您启动协程并在
runBlocking
lambda 结束后它仍在继续运行(这可能是协程泄露),或者您有未捕获的异常,这些断言将失败。 - 它允许您控制协程执行的时间。
那么为什么要在测试替身(例如 FakeTestRepository
)中使用 runBlocking
呢?有时测试替身需要使用协程,在这种情况下,您确实需要阻塞当前线程。这样,当测试用例中使用您的测试替身时,线程会被阻塞,允许协程在测试完成之前完成。然而,测试替身实际上不是在定义测试用例,因此它们不需要也不应该使用 runBlockingTest
的所有测试特定功能。
总结
- 测试需要确定性行为,这样才不会不稳定。
- “普通”协程是非确定性的,因为它们异步运行代码。
kotlinx-coroutines-test
是runBlockingTest
的 gradle 依赖项。- 编写测试类(即带有
@Test
函数的类)时,使用runBlockingTest
来获得确定性行为。 - 编写测试替身时,使用
runBlocking
。
4. 任务:协程和 ViewModel
在此步骤中,您将学习如何测试使用协程的 view model。
所有协程都需要一个 CoroutineScope
。协程作用域控制协程的生命周期。当您取消一个作用域(或者技术上说,是协程的 Job,您可以在此处了解更多信息)时,该作用域中运行的所有协程都将被取消。
由于您可能会从 view model 中启动长时间运行的工作,因此您会发现自己经常在 view model 内部创建和运行协程。通常,您需要为每个 view model 手动创建和配置一个新的 CoroutineScope
来运行任何协程。这会产生很多样板代码。为了避免这种情况,lifecycle-viewmodel-ktx
提供了一个扩展属性,称为viewModelScope
。
viewModelScope
是与每个 view model 关联的 CoroutineScope
。viewModelScope
配置用于该特定的 ViewModel。具体来说,这意味着:
viewModelScope
与 view model 绑定,以便在清理 view model(即调用onCleared
)时,作用域被取消。这确保了当您的 view model 消失时,与其关联的所有协程工作也随之停止。这避免了不必要的工作和内存泄露。viewModelScope
使用Dispatchers.Main
协程调度程序。一个CoroutineDispatcher
控制协程如何运行,包括协程代码在哪一个线程上运行。Dispatcher.Main
将协程放在界面线程或主线程上。对于ViewModel
协程来说,这作为默认设置是合理的,因为 view model 通常会操作界面。
这在生产代码中运行良好。但对于本地测试(在本地机器上 test
源代码集中运行的测试),使用 Dispatcher.Main
会导致问题:Dispatchers.Main
使用 Android 的Looper.getMainLooper()
。主循环器是真实应用的执行循环。主循环器在本地测试中(默认情况下)不可用,因为您没有运行完整的应用。
要解决此问题,请使用setMain()
方法(来自 kotlinx.coroutines.test
)修改 Dispatchers.Main
以使用TestCoroutineDispatcher
。TestCoroutineDispatcher
是专用于测试的调度程序。
接下来,您将为使用 viewModelScope
的 view model 代码编写测试。
第 1 步:观察 Dispatcher.Main 导致错误
添加一个测试,检查任务完成后,snackbar 是否显示正确的完成消息。
- 打开 test > tasks > TasksViewModelTest。
- 添加以下新测试方法
TasksViewModelTest.kt
@Test
fun completeTask_dataAndSnackbarUpdated() {
// Create an active task and add it to the repository.
val task = Task("Title", "Description")
tasksRepository.addTasks(task)
// Mark the task as complete task.
tasksViewModel.completeTask(task, true)
// Verify the task is completed.
assertThat(tasksRepository.tasksServiceData[task.id]?.isCompleted, `is`(true))
// Assert that the snackbar has been updated with the correct text.
val snackbarText: Event<Int> = tasksViewModel.snackbarText.getOrAwaitValue()
assertThat(snackbarText.getContentIfNotHandled(), `is`(R.string.task_marked_complete))
}
- 运行此测试。观察它因以下错误而失败:
"Exception in thread "main" java.lang.IllegalStateException: Module with the Main dispatcher had failed to initialize. For tests Dispatchers.setMain from kotlinx-coroutines-test module can be used."
此错误表明 Dispatcher.Main
未能初始化。根本原因(错误中未解释)是缺少 Android 的 Looper.getMainLooper()
。错误消息确实告诉您使用 kotlinx-coroutines-test
中的 Dispatcher.setMain
。继续照做吧!
第 2 步:将 Dispatcher.Main 替换为 TestCoroutineDispatcher
TestCoroutineDispatcher
是专用于测试的协程调度程序。它可以立即执行任务,并允许您控制测试中协程执行的时间,例如允许您暂停和重新启动协程执行。
- 在 TasksViewModelTest 中,创建一个
TestCoroutineDispatcher
作为名为testDispatcher
的val
。
使用 testDispatcher
替换默认的 Main
调度程序。
- 创建一个
@Before
方法,在每个测试之前调用Dispatchers.setMain(testDispatcher)
。 - 创建一个
@After
方法,通过调用Dispatchers.resetMain()
和testDispatcher.cleanupTestCoroutines()
来清理每个测试运行后的所有内容。
代码如下所示:
TasksViewModelTest.kt
@ExperimentalCoroutinesApi
val testDispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()
@ExperimentalCoroutinesApi
@Before
fun setupDispatcher() {
Dispatchers.setMain(testDispatcher)
}
@ExperimentalCoroutinesApi
@After
fun tearDownDispatcher() {
Dispatchers.resetMain()
testDispatcher.cleanupTestCoroutines()
}
- 再次运行您的测试。它现在通过了!
第 3 步:添加 MainCoroutineRule
如果您在应用中使用协程,则涉及调用 view model 中代码的任何本地测试极有可能调用使用 viewModelScope
的代码。与其将设置和拆卸 TestCoroutineDispatcher
的代码复制粘贴到每个测试类中,不如创建一个自定义 JUnit 规则来避免此样板代码。
JUnit rules
是类,您可以在其中定义可在测试之前、之后或期间执行的通用测试代码 - 这是一种将原本位于 @Before
和 @After
中的代码放入一个可重用类中的方法。
现在创建一个 JUnit 规则。
- 在测试源代码集的根文件夹中创建一个名为 MainCoroutineRule.kt 的新类
- 将以下代码复制到
MainCoroutineRule.kt
MainCoroutineRule.kt
@ExperimentalCoroutinesApi
class MainCoroutineRule(val dispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()):
TestWatcher(),
TestCoroutineScope by TestCoroutineScope(dispatcher) {
override fun starting(description: Description?) {
super.starting(description)
Dispatchers.setMain(dispatcher)
}
override fun finished(description: Description?) {
super.finished(description)
cleanupTestCoroutines()
Dispatchers.resetMain()
}
}
需要注意的事项:
MainCoroutineRule
扩展了TestWatcher
,后者实现了TestRule
接口。这就是使MainCoroutineRule
成为 JUnit 规则的原因。starting
和finished
方法与您在@Before
和@After
函数中编写的内容相匹配。它们也在每个测试之前和之后运行。MainCoroutineRule
还实现了TestCoroutineScope
,您可以将TestCoroutineDispatcher
传入其中。这赋予MainCoroutineRule
控制协程时间的能力(使用您传入的TestCoroutineDispatcher
)。您将在下一步中看到一个示例。
第 4 步:在测试中使用新的 Junit 规则
- 打开 TasksViewModelTest。
- 将
testDispatcher
以及@Before
和@After
代码替换为新的MainCoroutineRule
JUnit 规则
TasksViewModelTest.kt
// REPLACE@ExperimentalCoroutinesApi
val testDispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()
@ExperimentalCoroutinesApi
@Before
fun setupDispatcher() {
Dispatchers.setMain(testDispatcher)
}
@ExperimentalCoroutinesApi
@After
fun tearDownDispatcher() {
Dispatchers.resetMain()
testDispatcher.cleanupTestCoroutines()
}
// WITH
@ExperimentalCoroutinesApi
@get:Rule
var mainCoroutineRule = MainCoroutineRule()
注意: 要使用 JUnit 规则,您需要实例化该规则并使用 @get:Rule
注解它。
- 运行
completeTask_dataAndSnackbarUpdated
,它应该以完全相同的方式工作!
第 5 步:使用 MainCoroutineRule 进行仓库测试
在上一个 Codelab 中,您学习了依赖注入。这允许您在测试中用测试版本的类替换生产版本的类。具体来说,您使用了构造函数依赖注入。以下是 DefaultTasksRepository
中的一个示例:
DefaultTasksRepository.kt
class DefaultTasksRepository(
private val tasksRemoteDataSource: TasksDataSource,
private val tasksLocalDataSource: TasksDataSource,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO) : TasksRepository { ... }
上面的代码注入了本地和远程数据源,以及一个CoroutineDispatcher
。由于注入了调度程序,您可以在测试中使用 TestCoroutineDispatcher
。与硬编码调度程序不同,注入 CoroutineDispatcher
是使用协程时的好习惯。
让我们在测试中使用注入的 TestCoroutineDispatcher
。
- 打开 test > data > source > DefaultTasksRepositoryTest.kt
- 在
DefaultTasksRepositoryTest
类内部添加MainCoroutineRule
DefaultTasksRepositoryTest.kt
// Set the main coroutines dispatcher for unit testing.
@ExperimentalCoroutinesApi
@get:Rule
var mainCoroutineRule = MainCoroutineRule()
- 在定义待测试的仓库时,使用
Dispatcher.Main
,而不是Dispatcher.Unconfined
。与TestCoroutineDispatcher
类似,Dispatchers.Unconfined
会立即执行任务。但是,它不包含TestCoroutineDispatcher
的所有其他测试优势,例如能够暂停执行
DefaultTasksRepositoryTest.kt
@Before
fun createRepository() {
tasksRemoteDataSource = FakeDataSource(remoteTasks.toMutableList())
tasksLocalDataSource = FakeDataSource(localTasks.toMutableList())
// Get a reference to the class under test.
tasksRepository = DefaultTasksRepository(
// HERE Swap Dispatcher.Unconfined
tasksRemoteDataSource, tasksLocalDataSource, Dispatchers.Main
)
}
在上面的代码中,请记住 MainCoroutineRule
将 Dispatcher.Main
替换为 TestCoroutineDispatcher
。
通常,每个测试只创建一个 TestCoroutineDispatcher
。每当您调用 runBlockingTest
时,如果您未指定,它将创建一个新的 TestCoroutineDispatcher
。MainCoroutineRule
包含一个 TestCoroutineDispatcher
。因此,为确保您不会意外创建多个 TestCoroutineDispatcher
实例,请使用 mainCoroutineRule.runBlockingTest
而不是仅使用 runBlockingTest
。
- 将
runBlockingTest
替换为mainCoroutineRule.runBlockingTest
DefaultTasksRepositoryTest.kt
// REPLACE
fun getTasks_requestsAllTasksFromRemoteDataSource() = runBlockingTest {
// WITH
fun getTasks_requestsAllTasksFromRemoteDataSource() = mainCoroutineRule.runBlockingTest {
- 运行您的
DefaultTasksRepositoryTest
类并确认一切工作正常!
干得好!现在您已经在代码中使用了 TestCoroutineDispatcher
,这是一个更适合测试的调度程序。接下来,您将了解如何使用 TestCoroutineDispatcher
的另一个功能,即控制协程执行的时间。
5. 任务:测试协程计时
在此步骤中,您将使用 TestCouroutineDispatcher
的pauseDispatcher
和resumeDispatcher
方法来控制协程在测试中的执行方式。使用这些方法,您将为 StatisticsViewModel
的加载指示器编写一个测试。
提醒一下,StatisticViewModel
保存所有数据并执行统计信息屏幕的所有计算
第 1 步:准备 StatisticsViewModel 进行测试
首先,您需要确保可以按照上一个 Codelab 中描述的过程将伪造的仓库注入到您的 view model 中。由于这是回顾并且与协程计时无关,请随意复制/粘贴。
- 打开
StatisticsViewModel
。 - 更改
StatisticsViewModel
的构造函数,使其接受TasksRepository
作为参数,而不是在类内部构造它,以便您可以注入伪造的仓库进行测试
StatisticsViewModel.kt
// REPLACE
class StatisticsViewModel(application: Application) : AndroidViewModel(application) {
private val tasksRepository = (application as TodoApplication).taskRepository
// Rest of class
}
// WITH
class StatisticsViewModel(
private val tasksRepository: TasksRepository
) : ViewModel() {
// Rest of class
}
- 在
StatisticsViewModel
文件底部,类外部,添加一个接受普通TasksRepository
的TasksViewModelFactory
StatisticsViewModel.kt
@Suppress("UNCHECKED_CAST")
class StatisticsViewModelFactory (
private val tasksRepository: TasksRepository
) : ViewModelProvider.NewInstanceFactory() {
override fun <T : ViewModel> create(modelClass: Class<T>) =
(StatisticsViewModel(tasksRepository) as T)
}
- 更新
StatisticsFragment
以使用工厂。
StatisticsFragment.kt
// REPLACE
private val viewModel by viewModels<TasksViewModel>()
// WITH
private val viewModel by viewModels<StatisticsViewModel> {
StatisticsViewModelFactory(
(requireContext().applicationContext as TodoApplication).taskRepository
)
}
- 运行您的应用代码并导航到 StatisticsFragment 以确保您的统计信息屏幕与之前一样工作正常。
第 2 步:创建 StatisticsViewModelTest
现在,您已准备好为 StatisticsViewModelTest.kt
创建一个在协程执行中途暂停的测试。
- 打开
StatisticsViewModel.kt
。 - 右键点击
StatisticsViewModel
类名,选择生成,然后选择测试。 - 按照提示在 test 源代码集中创建
StatisticsViewModelTest
。
按照以下步骤设置您的 StatisticsViewModel
测试,如前几课所述。这是对 view model 测试内容的很好回顾:
- 添加
InstantTaskExecutorRule
。这会将 Architecture Components(ViewModel 是其一部分)使用的后台执行程序替换为一个将同步执行每个任务的执行程序。这可确保您的测试是确定性的。 - 添加
MainCoroutineRule
,因为您正在测试协程和 view model。 - 为被测对象(
StatisticsViewModel
)及其依赖项的测试替身(FakeTestRepository
)创建字段。 - 创建一个
@Before
方法,用于设置被测对象和依赖项。
您的测试应该如下所示:
StatisticsViewModelTest.kt
@ExperimentalCoroutinesApi
class StatisticsViewModelTest {
// Executes each task synchronously using Architecture Components.
@get:Rule
var instantExecutorRule = InstantTaskExecutorRule()
// Set the main coroutines dispatcher for unit testing.
@ExperimentalCoroutinesApi
@get:Rule
var mainCoroutineRule = MainCoroutineRule()
// Subject under test
private lateinit var statisticsViewModel: StatisticsViewModel
// Use a fake repository to be injected into the view model.
private lateinit var tasksRepository: FakeTestRepository
@Before
fun setupStatisticsViewModel() {
// Initialise the repository with no tasks.
tasksRepository = FakeTestRepository()
statisticsViewModel = StatisticsViewModel(tasksRepository)
}
}
第 3 步:创建加载指示器测试
加载任务统计信息时,应用会显示一个加载指示器,数据加载和统计信息计算完成后,该指示器会立即消失。您将编写一个测试,确保在加载统计信息时显示加载指示器,并在加载统计信息后消失。
StatisticsViewModel 中的 refresh()
方法控制加载指示器何时显示和消失
StatisticsViewModel.kt
fun refresh() {
_dataLoading.value = true
viewModelScope.launch {
tasksRepository.refreshTasks()
_dataLoading.value = false
}
}
请注意 _dataLoading
如何设置为 true,然后在协程完成刷新任务后设置为 false。您需要检查此代码是否正确更新了加载指示器。
首次尝试编写测试可能如下所示:
StatisticsViewModelTest.kt
@Test
fun loadTasks_loading() {
// Load the task in the view model.
statisticsViewModel.refresh()
// Then progress indicator is shown.
assertThat(statisticsViewModel.dataLoading.getOrAwaitValue(), `is`(true))
// Then progress indicator is hidden.
assertThat(statisticsViewModel.dataLoading.getOrAwaitValue(), `is`(false))
}
- 复制上述代码
- 添加:
import
org.hamcrest.CoreMatchers.
is`` - 运行此测试。此测试失败。
上面的测试实际上没有意义,因为它测试的是 dataLoading
同时为 true
和 false
。
查看错误消息,测试因第一个断言语句而失败。
TestCoroutineDispatcher
立即且完全执行任务,这意味着在执行断言语句之前,statisticsViewModel.refresh()
方法已完全完成。
通常,您确实希望立即执行,这样您的测试运行得快。但在这种情况下,您正试图检查加载指示器在 refresh 执行过程中的状态,如下面的带注解代码所示:
StatisticsViewModel.kt
fun refresh() {
_dataLoading.value = true
// YOU WANT TO CHECK HERE...
viewModelScope.launch {
tasksRepository.refreshTasks()
_dataLoading.value = false
// ...AND CHECK HERE.
}
}
在这种情况下,您可以使用 TestCouroutineDispatcher
的pauseDispatcher
和resumeDispatcher
。mainCoroutineRule.pauseDispatcher()
是暂停 TestCoroutineDispatcher
的简写。当调度程序暂停时,任何新的协程都会被添加到队列中,而不是立即执行。这意味着 refresh
内部的代码执行将就在协程启动之前暂停
StatisticsViewModel.kt
fun refresh() {
_dataLoading.value = true
// PAUSES EXECUTION HERE
viewModelScope.launch {
tasksRepository.refreshTasks()
_dataLoading.value = false
}
}
当您调用 mainCoroutineRule.resumeDispatcher()
时,协程中的所有代码都将执行。
- 更新测试以使用
pauseDispatcher
和resumeDispatcher
,以便您在执行协程之前暂停,检查加载指示器是否显示,然后恢复并检查加载指示器是否隐藏
StatisticsViewModelTest.kt
@Test
fun loadTasks_loading() {
// Pause dispatcher so you can verify initial values.
mainCoroutineRule.pauseDispatcher()
// Load the task in the view model.
statisticsViewModel.refresh()
// Then assert that the progress indicator is shown.
assertThat(statisticsViewModel.dataLoading.getOrAwaitValue(), `is`(true))
// Execute pending coroutines actions.
mainCoroutineRule.resumeDispatcher()
// Then assert that the progress indicator is hidden.
assertThat(statisticsViewModel.dataLoading.getOrAwaitValue(), `is`(false))
}
- 运行测试,看看它现在是否通过。
太棒了——您已经学会了如何编写使用 TestCoroutineDispatcher
暂停和恢复协程执行能力的协程测试。这为您在编写需要精确计时的测试时提供了更多控制。
6. 任务:测试错误处理
在测试中,既要测试代码按预期执行的情况(有时称为正常流程),也要测试应用在遇到错误和边缘情况时会做什么,这非常重要。在此步骤中,您将向 StatisticsViewModelTest 添加一个测试,以确认在任务列表无法加载时(例如,网络中断)的正确行为。
第 1 步:向测试替身添加错误标志
首先,您需要人为地制造错误情况。一种方法是更新您的测试替身,以便您可以使用标志将其“设置”为错误状态。如果标志为 false
,则测试替身正常工作。但如果标志设置为 true
,则测试替身将返回一个真实的错误;例如,它可能返回数据加载失败错误。更新 FakeTestRepository 以包含一个错误标志,当该标志设置为 true
时,会使代码返回一个真实的错误。
- 打开 test > data > source > FakeTestRepository.
- 添加一个名为
shouldReturnError
的布尔标志,并将其初始设置为false
,这意味着默认情况下不返回错误。
FakeTestRepository.kt
private var shouldReturnError = false
- 创建一个
setReturnError
方法,用于更改仓库是否应返回错误
FakeTestRepository.kt
fun setReturnError(value: Boolean) {
shouldReturnError = value
}
- 将
getTask
和getTasks
包裹在if
语句中,以便如果shouldReturnError
为true
,该方法将返回Result.Error
FakeTestRepository.kt
// Avoid import conflicts:
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
...
override suspend fun getTask(taskId: String, forceUpdate: Boolean): Result<Task> {
if (shouldReturnError) {
return Result.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())
}
第 2 步:编写一个针对返回错误的测试
现在您已准备好编写一个测试,以测试您的 StatisticsViewModel 在仓库返回错误时会发生什么。
在 StatisticsViewModel
中,有两个 LiveData
布尔值(error
和 empty
)
StatisticsViewModel.kt
class StatisticsViewModel(
private val tasksRepository: TasksRepository
) : ViewModel() {
private val tasks: LiveData<Result<List<Task>>> = tasksRepository.observeTasks()
// Other variables...
val error: LiveData<Boolean> = tasks.map { it is Error }
val empty: LiveData<Boolean> = tasks.map { (it as? Success)?.data.isNullOrEmpty() }
// Rest of the code...
}
这些表示 tasks
是否正确加载。如果出现错误,error
和 empty
都应该为 true
。
- 打开 StatisticsViewModelTest.
- 创建一个名为
loadStatisticsWhenTasksAreUnavailable_callErrorToDisplay
的新测试 - 在
tasksRepository
上调用setReturnError()
,将其设置为true.
- 检查
statisticsViewModel.empty
和statisticsViewModel.error
是否都为 true。
此测试的完整代码如下所示:
StatisticsViewModelTest.kt
@Test
fun loadStatisticsWhenTasksAreUnavailable_callErrorToDisplay() {
// Make the repository return errors.
tasksRepository.setReturnError(true)
statisticsViewModel.refresh()
// Then empty and error are true (which triggers an error message to be shown).
assertThat(statisticsViewModel.empty.getOrAwaitValue(), `is`(true))
assertThat(statisticsViewModel.error.getOrAwaitValue(), `is`(true))
}
总之,测试错误处理的一般策略是修改您的测试替身,以便您可以将它们“设置”为错误状态(如果存在多种错误状态,则可以设置为各种错误状态)。然后您可以针对这些错误状态编写测试。干得好!
7. 任务:测试 Room
在此步骤中,您将学习如何为 Room 数据库编写测试。您将首先为 Room DAO(数据库访问对象)编写测试,然后为本地数据源类编写测试。
第 1 步:将架构组件测试库添加到 gradle
- 使用
androidTestImplementation
将架构组件测试库添加到您的检测测试中
app/build.gradle
androidTestImplementation "androidx.arch.core:core-testing:$archTestingVersion"
第 2 步:创建 TasksDaoTest 类
Room DAO 实际上是接口,Room 通过注解处理的魔力将其转换为类。通常为接口生成测试类没有意义,因此没有键盘快捷方式,您需要手动创建测试类。
创建一个 TasksDaoTest
类
- 在您的项目面板中,导航到 androidTest > data > source.
- 右键点击 source 软件包并创建一个名为 local 的新软件包。
- 在 local 中,创建一个名为 TasksDaoTest.kt 的 Kotlin 文件和类。
第 3 步:设置 TasksDaoTest 类
- 复制以下代码以开始您的
TasksDaoTest
类
TasksDaoTest.kt
@ExperimentalCoroutinesApi
@RunWith(AndroidJUnit4::class)
@SmallTest
class TasksDaoTest {
// Executes each task synchronously using Architecture Components.
@get:Rule
var instantExecutorRule = InstantTaskExecutorRule()
}
请注意这三个注解:
@ExperimentalCoroutinesApi
—您将使用runBlockingTest
,它是kotlinx-coroutines-test
的一部分,因此您需要此注解。@SmallTest
— 将测试标记为“小运行时”集成测试(相对于@MediumTest
集成测试和@LargeTest
端到端测试)。这有助于您分组和选择要运行的测试大小。DAO 测试被视为单元测试,因为您只测试 DAO,因此可以将其称为小型测试。@RunWith(AndroidJUnit4::class)
— 用于使用 AndroidX Test 的任何类中。这在第一个 Codelab 中已涵盖。
要访问 DAO 的实例,您需要构建数据库的实例。要在测试中执行此操作,请按以下步骤操作:
- 在
TasksDaoTest
中,为数据库创建一个lateinit
字段
TasksDaoTest.kt
private lateinit var database: ToDoDatabase
- 创建一个
@Before
方法,用于初始化数据库。
具体来说,初始化用于测试的数据库时:
- 使用
Room.inMemoryDatabaseBuilder
。 Normal databases are meant to persist. By comparison, an in-memory database will be completely deleted once the process that created it is killed, since it's never actually stored on disk. Always use and in-memory database for your tests." -> "使用Room.inMemoryDatabaseBuilder
创建一个内存数据库。普通数据库用于持久化。相比之下,内存数据库在创建它的进程终止后将被完全删除,因为它从未实际存储在磁盘上。始终在测试中使用内存数据库。 - 使用 AndroidX Test 库的
ApplicationProvider.getApplicationContext()
方法获取应用上下文。
TasksDaoTest.kt
@Before
fun initDb() {
// Using an in-memory database so that the information stored here disappears when the
// process is killed.
database = Room.inMemoryDatabaseBuilder(
getApplicationContext(),
ToDoDatabase::class.java
).build()
}
- 创建一个
@After
方法,用于使用database.close()
清理数据库。
TasksDaoTest.kt
@After
fun closeDb() = database.close()
完成后,您的代码应该如下所示:
TasksDaoTest.kt
@ExperimentalCoroutinesApi
@RunWith(AndroidJUnit4::class)
@SmallTest
class TasksDaoTest {
// Executes each task synchronously using Architecture Components.
@get:Rule
var instantExecutorRule = InstantTaskExecutorRule()
private lateinit var database: ToDoDatabase
@Before
fun initDb() {
// Using an in-memory database so that the information stored here disappears when the
// process is killed.
database = Room.inMemoryDatabaseBuilder(
getApplicationContext(),
ToDoDatabase::class.java
).allowMainThreadQueries().build()
}
@After
fun closeDb() = database.close()
}
第 4 步:编写您的第一个 DAO 测试
您的第一个 DAO 测试将插入一个任务,然后通过其 id 获取该任务。
- 仍在 TasksDaoTest 中,复制以下测试
TasksDaoTest.kt
@Test
fun insertTaskAndGetById() = runBlockingTest {
// GIVEN - Insert a task.
val task = Task("title", "description")
database.taskDao().insertTask(task)
// WHEN - Get the task by id from the database.
val loaded = database.taskDao().getTaskById(task.id)
// THEN - The loaded data contains the expected values.
assertThat<Task>(loaded as Task, notNullValue())
assertThat(loaded.id, `is`(task.id))
assertThat(loaded.title, `is`(task.title))
assertThat(loaded.description, `is`(task.description))
assertThat(loaded.isCompleted, `is`(task.isCompleted))
}
此测试执行以下操作:
- 创建一个任务并将其插入数据库。
- 使用其 id 检索任务。
- 断言该任务已被检索到,并且其所有属性都与插入的任务匹配。
注意
- 您使用
runBlockingTest
运行测试,因为insertTask
和getTaskById
都是 suspend 函数。 - 您像往常一样使用 DAO,从数据库实例访问它。
如果需要,以下是导入语句。
TasksDaoTest.kt
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider.getApplicationContext
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.example.android.architecture.blueprints.todoapp.data.Task
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runBlockingTest
import org.hamcrest.CoreMatchers.`is`
import org.hamcrest.CoreMatchers.notNullValue
import org.hamcrest.MatcherAssert.assertThat
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
- 运行您的测试并确认它通过了。
第 5 步:自己尝试!
现在,尝试自己编写一个 DAO 测试。编写一个测试,该测试插入一个任务,更新它,然后检查它是否具有更新后的值。下方是 updateTaskAndGetById
的起始代码。
- 复制此测试起始代码
TasksDaoTest.kt
@Test
fun updateTaskAndGetById() {
// 1. Insert a task into the DAO.
// 2. Update the task by creating a new task with the same ID but different attributes.
// 3. Check that when you get the task by its ID, it has the updated values.
}
- 完成代码,如有需要,请参考您刚刚添加的
insertTaskAndGetById
测试。 - 运行您的测试并确认它通过了!
完整的测试位于仓库的end_codelab_3 分支此处,以便您进行比较。
第 6 步:为 TasksLocalDataSource 创建集成测试
您刚刚为 TasksDao 创建了单元测试。接下来,您将为 TasksLocalDataSource 创建集成测试。TasksLocalDatasource
是一个类,它接受 DAO 返回的信息并将其转换为仓库类期望的格式(例如,它将返回的值包装为 Success
或 Error
状态)。您将编写一个集成测试,因为您将同时测试真实的 TasksLocalDatasource
代码和真实的 DAO 代码。
创建 TasksLocalDataSourceTest 测试的步骤与创建 DAO 测试的步骤非常相似。
- 打开应用的
TasksLocalDataSource
类。 - 右键点击
TasksLocalDataSource
类名,选择生成,然后选择测试。 - 按照提示在 androidTest 源代码集中创建
TasksLocalDataSourceTest
。 - 复制以下代码
TasksLocalDataSourceTest.kt
@ExperimentalCoroutinesApi
@RunWith(AndroidJUnit4::class)
@MediumTest
class TasksLocalDataSourceTest {
// Executes each task synchronously using Architecture Components.
@get:Rule
var instantExecutorRule = InstantTaskExecutorRule()
}
请注意,这与 DAO 测试代码之间唯一的真正区别在于,TasksLocalDataSource
可以被视为中等“集成”测试(如 @MediumTest
注解所示),因为 TasksLocalDataSourceTest
将同时测试 TasksLocalDataSource
中的代码以及它与 DAO 代码的集成方式。
- 在
TasksLocalDataSourceTest
中,为您要测试的两个组件(TasksLocalDataSource
和您的database
)创建一个lateinit
字段
TasksLocalDataSourceTest.kt
private lateinit var localDataSource: TasksLocalDataSource
private lateinit var database: ToDoDatabase
- 创建一个
@Before
方法,用于初始化您的数据库和数据源。 - 以与 DAO 测试相同的方式创建数据库,使用
inMemoryDatabaseBuilder
和ApplicationProvider.getApplicationContext()
方法。 - 添加
allowMainThreadQueries
。通常,Room 不允许在主线程上运行数据库查询。调用allowMainThreadQueries
会关闭此检查。请勿在生产代码中执行此操作! - 实例化
TasksLocalDataSource
,使用您的数据库和Dispatchers.Main
。这将在主线程上运行您的查询(由于allowMainThreadQueries
,这是允许的)。
TasksLocalDataSourceTest.kt
@Before
fun setup() {
// Using an in-memory database for testing, because it doesn't survive killing the process.
database = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(),
ToDoDatabase::class.java
)
.allowMainThreadQueries()
.build()
localDataSource =
TasksLocalDataSource(
database.taskDao(),
Dispatchers.Main
)
}
- 创建一个
@After
方法,用于使用database.close
清理数据库。
完整的代码应该如下所示:
TasksLocalDataSourceTest.kt
@ExperimentalCoroutinesApi
@RunWith(AndroidJUnit4::class)
@MediumTest
class TasksLocalDataSourceTest {
private lateinit var localDataSource: TasksLocalDataSource
private lateinit var database: ToDoDatabase
// Executes each task synchronously using Architecture Components.
@get:Rule
var instantExecutorRule = InstantTaskExecutorRule()
@Before
fun setup() {
// Using an in-memory database for testing, because it doesn't survive killing the process.
database = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(),
ToDoDatabase::class.java
)
.allowMainThreadQueries()
.build()
localDataSource =
TasksLocalDataSource(
database.taskDao(),
Dispatchers.Main
)
}
@After
fun cleanUp() {
database.close()
}
}
第 7 步:编写您的第一个 TasksLocalDataSourceTest
就像 DAO 测试一样,先复制并运行一个示例测试。
- 复制这些导入语句
import com.example.android.architecture.blueprints.todoapp.data.source.TasksDataSource
import com.example.android.architecture.blueprints.todoapp.data.Result.Success
import com.example.android.architecture.blueprints.todoapp.data.Task
import com.example.android.architecture.blueprints.todoapp.data.succeeded
import kotlinx.coroutines.runBlocking
import org.hamcrest.CoreMatchers.`is`
import org.junit.Assert.assertThat
import org.junit.Test
- 复制此测试
TasksLocalDataSourceTest.kt
// runBlocking is used here because of https://github.com/Kotlin/kotlinx.coroutines/issues/1204
// TODO: Replace with runBlockingTest once issue is resolved
@Test
fun saveTask_retrievesTask() = runBlocking {
// GIVEN - A new task saved in the database.
val newTask = Task("title", "description", false)
localDataSource.saveTask(newTask)
// WHEN - Task retrieved by ID.
val result = localDataSource.getTask(newTask.id)
// THEN - Same task is returned.
assertThat(result.succeeded, `is`(true))
result as Success
assertThat(result.data.title, `is`("title"))
assertThat(result.data.description, `is`("description"))
assertThat(result.data.isCompleted, `is`(false))
}
这与您的 DAO 测试非常相似。与 DAO 测试一样,此测试
- 创建一个任务并将其插入数据库。
- 使用其 id 检索任务。
- 断言该任务已被检索到,并且其所有属性都与插入的任务匹配。
与类似的 DAO 测试唯一的真正区别在于,本地数据源返回密封类 Result
的实例,这是仓库期望的格式。例如,这里的这一行将结果转换为 Success
TasksLocalDataSourceTest.kt
assertThat(result.succeeded, `is`(true))
result as Success
- 运行您的测试!
第 8 步:编写您自己的本地数据源测试
现在轮到您了。
- 复制以下代码
TasksLocalDataSourceTest.kt
@Test
fun completeTask_retrievedTaskIsComplete(){
// 1. Save a new active task in the local data source.
// 2. Mark it as complete.
// 3. Check that the task can be retrieved from the local data source and is complete.
}
- 完成代码,如有需要,请参考您之前添加的
saveTask_retrievesTask
测试。 - 运行您的测试并确认它通过了!
完整的测试位于仓库的end_codelab_3 分支此处,以便您进行比较。
8. 任务:使用数据绑定进行端到端测试
到目前为止,在本系列 Codelab 中,您已经编写了单元测试和集成测试。像 TaskDetailFragmentTest
这样的集成测试仅专注于测试单个 fragment 的功能,而不会移动到任何其他 fragment,甚至不会创建 activity。类似地,TaskLocalDataSourceTest
测试数据层中一起工作的几个类,但实际上不检查界面。
端到端测试 (E2E) 测试多个功能协同工作。它们测试应用的大部分,并模拟实际使用。这些测试大部分是检测测试(位于 androidTest
源代码集中)。
以下是与待办事项应用相关的端到端测试和集成测试之间的一些区别。E2E 测试:
- 从第一个屏幕启动应用。
- 创建实际的 activity 和仓库。
- 测试多个 fragment 协同工作。
编写端到端测试很快就会变得复杂,因此有很多工具和库可以使其更容易。 Espresso 是一个常用的 Android 界面测试库,用于编写端到端测试。您在上一个 Codelab 中学习了 Espresso 的基本用法。
在此步骤中,您将编写一个真正的端到端测试。您将使用Espresso 空闲资源来正确处理涉及长时间运行操作和数据绑定库的 E2E 测试。
您将首先添加一个测试,用于编辑已保存的任务。
第 1 步:关闭动画
对于 Espresso 界面测试,最佳实践是在实现任何其他内容之前关闭动画。
- 在您的测试设备(实体设备或模拟器)上,前往设置 > 开发者选项。
- 停用这三个设置:窗口动画缩放、过渡动画缩放和动画时长缩放。
在 androidTest
中创建一个名为 TasksActivityTest.kt 的文件和类
- 第 2 步:创建 TasksActivityTest
- 使用
@RunWith(AndroidJUnit4::class)
注解该类,因为您使用的是 AndroidX 测试代码。 - 使用
@LargeTest
注解该类,这表示这些是端到端测试,测试的是代码的大部分。
端到端测试模拟了完整应用的运行方式并模拟了实际使用。因此,您将让 ServiceLocator
创建仓库,而不是自己实例化仓库或仓库测试替身
- 创建一个名为 repository 的属性,它是
TasksRepository.
- 创建一个
@Before
方法,并使用ServiceLocator
的provideTasksRepository
方法初始化仓库;使用getApplicationContext
获取应用上下文。 - 在
@Before
方法中,删除仓库中的所有任务,以确保在每次测试运行之前完全清空。 - 创建一个
@After
方法,该方法调用ServiceLocator
的resetRepository()
方法。
完成后,您的代码应该如下所示:
TasksActivityTest.kt
@RunWith(AndroidJUnit4::class)
@LargeTest
class TasksActivityTest {
private lateinit var repository: TasksRepository
@Before
fun init() {
repository = ServiceLocator.provideTasksRepository(getApplicationContext())
runBlocking {
repository.deleteAllTasks()
}
}
@After
fun reset() {
ServiceLocator.resetRepository()
}
}
第 3 步:编写一个端到端 Espresso 测试
是时候编写一个端到端测试来编辑已保存的任务了。
- 打开
TasksActivityTest
。 - 在类内部,添加以下骨架代码
TasksActivityTest.kt
@Test
fun editTask() = runBlocking {
// Set initial state.
repository.saveTask(Task("TITLE1", "DESCRIPTION"))
// Start up Tasks screen.
val activityScenario = ActivityScenario.launch(TasksActivity::class.java)
// Espresso code will go here.
// Make sure the activity is closed before resetting the db:
activityScenario.close()
}
注意
runBlocking
用于等待所有 suspend 函数完成,然后继续执行代码块。请注意,由于存在一个错误,我们使用的是runBlocking
而不是 runBlockingTest。ActivityScenario
是一个AndroidX 测试库类,它封装了一个 activity,使您可以直接控制 activity 的生命周期进行测试。它类似于FragmentScenario
。- 使用
ActivityScenario
时,您使用launch
启动 activity,然后在测试结束时调用close
.
- 您必须在调用
ActivityScenario.launch()
之前设置数据层的初始状态(例如向仓库添加任务)。 - 如果您正在使用数据库(您确实正在使用),您必须在测试结束时关闭数据库。
这是涉及 Activity 的任何测试的基本设置。在您启动 ActivityScenario
和关闭 ActivityScenario
之间,您现在可以编写 Espresso 代码了。
- 添加如下所示的 Espresso 代码
TasksActivityTest.kt
@Test
fun editTask() = runBlocking {
// Set initial state.
repository.saveTask(Task("TITLE1", "DESCRIPTION"))
// Start up Tasks screen.
val activityScenario = ActivityScenario.launch(TasksActivity::class.java)
// Click on the task on the list and verify that all the data is correct.
onView(withText("TITLE1")).perform(click())
onView(withId(R.id.task_detail_title_text)).check(matches(withText("TITLE1")))
onView(withId(R.id.task_detail_description_text)).check(matches(withText("DESCRIPTION")))
onView(withId(R.id.task_detail_complete_checkbox)).check(matches(not(isChecked())))
// Click on the edit button, edit, and save.
onView(withId(R.id.edit_task_fab)).perform(click())
onView(withId(R.id.add_task_title_edit_text)).perform(replaceText("NEW TITLE"))
onView(withId(R.id.add_task_description_edit_text)).perform(replaceText("NEW DESCRIPTION"))
onView(withId(R.id.save_task_fab)).perform(click())
// Verify task is displayed on screen in the task list.
onView(withText("NEW TITLE")).check(matches(isDisplayed()))
// Verify previous task is not displayed.
onView(withText("TITLE1")).check(doesNotExist())
// Make sure the activity is closed before resetting the db.
activityScenario.close()
}
如果需要,以下是导入语句
TasksActivityTest.kt
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider.getApplicationContext
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.replaceText
import androidx.test.espresso.assertion.ViewAssertions.doesNotExist
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.*
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import com.example.android.architecture.blueprints.todoapp.data.Task
import com.example.android.architecture.blueprints.todoapp.data.source.TasksRepository
import com.example.android.architecture.blueprints.todoapp.tasks.TasksActivity
import kotlinx.coroutines.runBlocking
import org.hamcrest.core.IsNot.not
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
- 运行此测试五次。请注意,此测试是不稳定的,这意味着有时它会通过,有时会失败
测试有时失败的原因是计时和测试同步问题。Espresso 会在界面操作和由此产生的界面更改之间同步。例如,假设您让 Espresso 代表您点击一个按钮,然后检查某些视图是否可见
onView(withId(R.id.next_screen_button)).perform(click()) // Step 1
onView(withId(R.id.screen_description)).check(matches(withText("The next screen"))) // Step 2
Espresso 会在您执行步骤 1 中的点击操作后等待新视图显示,然后才检查步骤 2 中是否有文本“下一个屏幕”。
但是,在某些情况下,Espresso 的内置同步机制不会知道需要等待足够长时间才能更新视图。例如,当您需要为视图加载一些数据时,Espresso 不知道数据加载何时完成。Espresso 也不知道数据绑定库何时仍在更新视图。
在 Espresso 无法判断应用是否正在忙于更新界面的情况下,您可以使用空闲资源同步机制。这是一种明确告知 Espresso 应用何时处于空闲状态(意味着 Espresso 应继续与应用交互和检查)或不处于空闲状态(意味着 Espresso 应等待)的方法。
使用空闲资源的一般方法如下:
- 在应用代码中创建一个空闲资源或其子类的单例。
- 在您的应用代码中(不是您的测试代码),添加逻辑来跟踪应用是否处于空闲状态,通过更改
IdlingResource
的状态来表示空闲或非空闲。 - 在每个测试之前调用
IdlingRegistry.getInstance().register
以注册IdlingResource
。通过注册IdlingResource
,Espresso 会等待直到它空闲后才会移动到下一个 Espresso 语句。 - 在每个测试之后调用
IdlingRegistry.getInstance().unregister
以取消注册IdlingResource
。
第 4 步:将空闲资源添加到您的 Gradle 文件
- 打开应用的 build.gradle 文件并添加 Espresso 空闲资源库
app/build.gradle
implementation "androidx.test.espresso:espresso-idling-resource:$espressoVersion"
- 还将以下选项
returnDefaultValues = true
添加到testOptions.unitTests.
app/build.gradle
testOptions.unitTests {
includeAndroidResources = true
returnDefaultValues = true
}
添加空闲资源代码到您的应用代码时,需要使用 returnDefaultValues = true
来保持单元测试的运行。
第 5 步:创建一个空闲资源单例
您将添加两个空闲资源。一个用于处理视图的数据绑定同步,另一个用于处理仓库中的长时间运行操作。
您将从与长时间运行的仓库操作相关的空闲资源开始。
- 在 app > java > main > util 中创建一个名为
EspressoIdlingResource.kt
的新文件
- 复制以下代码
EspressoIdlingResource.kt
object EspressoIdlingResource {
private const val RESOURCE = "GLOBAL"
@JvmField
val countingIdlingResource = CountingIdlingResource(RESOURCE)
fun increment() {
countingIdlingResource.increment()
}
fun decrement() {
if (!countingIdlingResource.isIdleNow) {
countingIdlingResource.decrement()
}
}
}
此代码创建了一个空闲资源单例(使用 Kotlin 的object
关键字),名为 countingIdlingResource
。
您在此处使用的是CountingIdlingResource
类。CountingIdlingResource
允许您递增和递减计数器,从而实现:
- 当计数器大于零时,应用被视为正在工作。
- 当计数器为零时,应用被视为空闲。
基本上,每当应用开始执行某些工作时,就递增计数器。当工作完成时,递减计数器。因此,CountingIdlingResource
仅在没有工作正在进行时计数为零。这是一个单例,以便您可以在应用中任何可能执行长时间运行工作的地方访问此空闲资源。
第 6 步:创建 wrapEspressoIdlingResource
以下是您将如何使用 EspressoIdlingResource
的示例:
EspressoIdlingResource.increment()
try {
doSomethingThatTakesALongTime()
} finally {
EspressoIdlingResource.decrement()
}
您可以通过创建一个名为 wrapEspressoIdlingResource
的内联函数来简化此过程。
- 在 EspressoIdlingResource 文件中,在您刚刚创建的单例下方,添加以下
wrapEspressoIdlingResource
的代码
EspressoIdlingResource.kt
inline fun <T> wrapEspressoIdlingResource(function: () -> T): T {
// Espresso does not work well with coroutines yet. See
// https://github.com/Kotlin/kotlinx.coroutines/issues/982
EspressoIdlingResource.increment() // Set app as busy.
return try {
function()
} finally {
EspressoIdlingResource.decrement() // Set app as idle.
}
}
wrapEspressoIdlingResource
首先递增计数,运行它包围的代码,然后递减计数。以下是您将如何使用 wrapEspressoIdlingResource
的示例:
wrapEspressoIdlingResource {
doWorkThatTakesALongTime()
}
第 7 步:在 DefaultTasksRepository 中使用 wrapEspressoIdlingResource
接下来,使用 wrapEspressoIdlingResource
包围您的长时间运行操作。其中大部分位于您的 DefaultTasksRepository 中。
- 在您的应用代码中,打开 data > source > DefaultTasksRepository。
- 使用
wrapEspressoIdlingResource
包围DefaultTasksRepository
中的所有方法。
以下是包围 getTasks
方法的示例:
DefaultTasksRepository.kt
override suspend fun getTasks(forceUpdate: Boolean): Result<List<Task>> {
wrapEspressoIdlingResource {
if (forceUpdate) {
try {
updateTasksFromRemoteDataSource()
} catch (ex: Exception) {
return Result.Error(ex)
}
}
return tasksLocalDataSource.getTasks()
}
}
包围了所有方法的 DefaultTasksRepository 完整代码可在此处找到。
第 8 步:编写 DataBindingIdlingResource
您已经编写了一个空闲资源,以便 Espresso 等待数据加载。接下来,您将为数据绑定创建一个自定义空闲资源。
您需要这样做是因为 Espresso 不会自动与数据绑定库一起工作。这是因为数据绑定使用不同的机制(Choreographer
类)来同步其视图更新。因此,Espresso 无法判断通过数据绑定更新的视图何时完成更新。
由于此数据绑定空闲资源代码比较复杂,因此提供了代码并进行了解释。
- 在 androidTest 源集下新建一个 util 包。
- 在 androidTest > util 中新建一个类 DataBindingIdlingResource.kt:
- 将以下代码复制到您的新类中
DataBindingIdlingResource.kt
class DataBindingIdlingResource : IdlingResource {
// List of registered callbacks
private val idlingCallbacks = mutableListOf<IdlingResource.ResourceCallback>()
// Give it a unique id to work around an Espresso bug where you cannot register/unregister
// an idling resource with the same name.
private val id = UUID.randomUUID().toString()
// Holds whether isIdle was called and the result was false. We track this to avoid calling
// onTransitionToIdle callbacks if Espresso never thought we were idle in the first place.
private var wasNotIdle = false
lateinit var activity: FragmentActivity
override fun getName() = "DataBinding $id"
override fun isIdleNow(): Boolean {
val idle = !getBindings().any { it.hasPendingBindings() }
@Suppress("LiftReturnOrAssignment")
if (idle) {
if (wasNotIdle) {
// Notify observers to avoid Espresso race detector.
idlingCallbacks.forEach { it.onTransitionToIdle() }
}
wasNotIdle = false
} else {
wasNotIdle = true
// Check next frame.
activity.findViewById<View>(android.R.id.content).postDelayed({
isIdleNow
}, 16)
}
return idle
}
override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback) {
idlingCallbacks.add(callback)
}
/**
* Find all binding classes in all currently available fragments.
*/
private fun getBindings(): List<ViewDataBinding> {
val fragments = (activity as? FragmentActivity)
?.supportFragmentManager
?.fragments
val bindings =
fragments?.mapNotNull {
it.view?.getBinding()
} ?: emptyList()
val childrenBindings = fragments?.flatMap { it.childFragmentManager.fragments }
?.mapNotNull { it.view?.getBinding() } ?: emptyList()
return bindings + childrenBindings
}
}
private fun View.getBinding(): ViewDataBinding? = DataBindingUtil.getBinding(this)
/**
* Sets the activity from an [ActivityScenario] to be used from [DataBindingIdlingResource].
*/
fun DataBindingIdlingResource.monitorActivity(
activityScenario: ActivityScenario<out FragmentActivity>
) {
activityScenario.onActivity {
this.activity = it
}
}
/**
* Sets the fragment from a [FragmentScenario] to be used from [DataBindingIdlingResource].
*/
fun DataBindingIdlingResource.monitorFragment(fragmentScenario: FragmentScenario<out Fragment>) {
fragmentScenario.onFragment {
this.activity = it.requireActivity()
}
}
这里有很多内容,但总体思路是,每当您使用数据绑定布局时,都会生成 ViewDataBinding
。 ViewDataBinding
的 hasPendingBindings
方法会报告数据绑定库是否需要更新 UI 以反映数据的更改。
此空闲资源仅在没有任何 ViewDataBinding
存在 待处理 绑定时才被视为空闲。
最后,扩展函数 DataBindingIdlingResource.monitorFragment
和 DataBindingIdlingResource.monitorActivity
分别接受 FragmentScenario
和 ActivityScenario
。然后,它们会找到底层 Activity 并将其与 DataBindingIdlingResource
关联,这样您就可以跟踪布局状态了。您必须在测试中调用这两个方法中的一个,否则 DataBindingIdlingResource
将无法了解您的布局。
第 9 步:在测试中使用空闲资源
您已经创建了两个空闲资源,并确保它们被正确设置为忙碌或空闲状态。Espresso 只会在空闲资源注册后等待它们。因此,现在您的测试需要注册和注销空闲资源。您将在 TasksActivityTest
中执行此操作。
- 打开 TasksActivityTest.kt。
- 实例化一个
private DataBindingIdlingResource
。
TasksActivityTest.kt
// An idling resource that waits for Data Binding to have no pending bindings.
private val dataBindingIdlingResource = DataBindingIdlingResource()
- 创建
@Before
和@After
方法来注册和注销EspressoIdlingResource.countingIdlingResource
和dataBindingIdlingResource
TasksActivityTest.kt
/**
* Idling resources tell Espresso that the app is idle or busy. This is needed when operations
* are not scheduled in the main Looper (for example when executed on a different thread).
*/
@Before
fun registerIdlingResource() {
IdlingRegistry.getInstance().register(EspressoIdlingResource.countingIdlingResource)
IdlingRegistry.getInstance().register(dataBindingIdlingResource)
}
/**
* Unregister your Idling Resource so it can be garbage collected and does not leak any memory.
*/
@After
fun unregisterIdlingResource() {
IdlingRegistry.getInstance().unregister(EspressoIdlingResource.countingIdlingResource)
IdlingRegistry.getInstance().unregister(dataBindingIdlingResource)
}
请记住,countingIdlingResource
和 dataBindingIdlingResource
都在监控您的 应用 代码,查看其是否空闲。通过在 测试 中注册这些资源,当任何资源处于 忙碌 状态时,Espresso 将等待它们变为空闲后再执行下一条命令。这意味着,如果您的 countingIdlingResource
的计数大于零,或者存在待处理的数据绑定布局,Espresso 将会等待。
- 更新
editTask()
测试,以便在启动 Activity 场景后,使用monitorActivity
将该 Activity 与dataBindingIdlingResource
相关联。
TasksActivityTest.kt
@Test
fun editTask() = runBlocking {
repository.saveTask(Task("TITLE1", "DESCRIPTION"))
// Start up Tasks screen.
val activityScenario = ActivityScenario.launch(TasksActivity::class.java)
dataBindingIdlingResource.monitorActivity(activityScenario) // LOOK HERE
// Rest of test...
}
- 运行测试五次。您应该会发现测试不再不稳定。
完整的 TasksActivityTest
应该如下所示:
TasksActivityTest.kt
@RunWith(AndroidJUnit4::class)
@LargeTest
class TasksActivityTest {
private lateinit var repository: TasksRepository
// An idling resource that waits for Data Binding to have no pending bindings.
private val dataBindingIdlingResource = DataBindingIdlingResource()
@Before
fun init() {
repository =
ServiceLocator.provideTasksRepository(
getApplicationContext()
)
runBlocking {
repository.deleteAllTasks()
}
}
@After
fun reset() {
ServiceLocator.resetRepository()
}
/**
* Idling resources tell Espresso that the app is idle or busy. This is needed when operations
* are not scheduled in the main Looper (for example when executed on a different thread).
*/
@Before
fun registerIdlingResource() {
IdlingRegistry.getInstance().register(EspressoIdlingResource.countingIdlingResource)
IdlingRegistry.getInstance().register(dataBindingIdlingResource)
}
/**
* Unregister your Idling Resource so it can be garbage collected and does not leak any memory.
*/
@After
fun unregisterIdlingResource() {
IdlingRegistry.getInstance().unregister(EspressoIdlingResource.countingIdlingResource)
IdlingRegistry.getInstance().unregister(dataBindingIdlingResource)
}
@Test
fun editTask() = runBlocking {
// Set initial state.
repository.saveTask(Task("TITLE1", "DESCRIPTION"))
// Start up Tasks screen.
val activityScenario = ActivityScenario.launch(TasksActivity::class.java)
dataBindingIdlingResource.monitorActivity(activityScenario)
// Click on the task on the list and verify that all the data is correct.
onView(withText("TITLE1")).perform(click())
onView(withId(R.id.task_detail_title_text)).check(matches(withText("TITLE1")))
onView(withId(R.id.task_detail_description_text)).check(matches(withText("DESCRIPTION")))
onView(withId(R.id.task_detail_complete_checkbox)).check(matches(not(isChecked())))
// Click on the edit button, edit, and save.
onView(withId(R.id.edit_task_fab)).perform(click())
onView(withId(R.id.add_task_title_edit_text)).perform(replaceText("NEW TITLE"))
onView(withId(R.id.add_task_description_edit_text)).perform(replaceText("NEW DESCRIPTION"))
onView(withId(R.id.save_task_fab)).perform(click())
// Verify task is displayed on screen in the task list.
onView(withText("NEW TITLE")).check(matches(isDisplayed()))
// Verify previous task is not displayed.
onView(withText("TITLE1")).check(doesNotExist())
// Make sure the activity is closed before resetting the db.
activityScenario.close()
}
}
第 10 步:使用空闲资源编写自己的测试
现在轮到您了。
- 复制以下代码
TasksActivityTest.kt
@Test
fun createOneTask_deleteTask() {
// 1. Start TasksActivity.
// 2. Add an active task by clicking on the FAB and saving a new task.
// 3. Open the new task in a details view.
// 4. Click delete task in menu.
// 5. Verify it was deleted.
// 6. Make sure the activity is closed.
}
- 完成代码,参考您之前添加的
editTask
测试。 - 运行您的测试并确认它通过了!
完整的测试代码 在此处,您可以进行比较。
9. 任务:端到端应用导航测试
您可以进行的最后一个测试是测试应用级别的导航。例如,测试:
- 导航抽屉
- 应用工具栏
- 向上按钮
- 返回按钮
现在开始吧!
第 1 步:创建 AppNavigationTest
- 在
androidTest
中创建名为 AppNavigationTest.kt 的文件和类。
按照设置 TasksActivityTest 的方式来设置您的测试。
- 为使用 AndroidX Test 库并作为端到端测试的类添加适当的注解。
- 设置您的
taskRepository
。 - 注册和注销正确的空闲资源。
完成后,AppNavigationTest 应该如下所示:
AppNavigationTest.kt
@RunWith(AndroidJUnit4::class)
@LargeTest
class AppNavigationTest {
private lateinit var tasksRepository: TasksRepository
// An Idling Resource that waits for Data Binding to have no pending bindings.
private val dataBindingIdlingResource = DataBindingIdlingResource()
@Before
fun init() {
tasksRepository = ServiceLocator.provideTasksRepository(getApplicationContext())
}
@After
fun reset() {
ServiceLocator.resetRepository()
}
/**
* Idling resources tell Espresso that the app is idle or busy. This is needed when operations
* are not scheduled in the main Looper (for example when executed on a different thread).
*/
@Before
fun registerIdlingResource() {
IdlingRegistry.getInstance().register(EspressoIdlingResource.countingIdlingResource)
IdlingRegistry.getInstance().register(dataBindingIdlingResource)
}
/**
* Unregister your idling resource so it can be garbage collected and does not leak any memory.
*/
@After
fun unregisterIdlingResource() {
IdlingRegistry.getInstance().unregister(EspressoIdlingResource.countingIdlingResource)
IdlingRegistry.getInstance().unregister(dataBindingIdlingResource)
}
}
第 2 步:设置您的导航测试
下面的起始代码概述了三个测试,并描述了它们应该做什么。每个测试都设置如下:
- 为测试配置仓库。
- 创建一个
ActivityScenario
。 - 正确设置您的
DataBindingIdlingResource
。
现在添加代码。
- 将此代码复制到
AppNavigationTest
类中。
AppNavigationTest.kt
@Test
fun tasksScreen_clickOnDrawerIcon_OpensNavigation() {
// Start the Tasks screen.
val activityScenario = ActivityScenario.launch(TasksActivity::class.java)
dataBindingIdlingResource.monitorActivity(activityScenario)
// 1. Check that left drawer is closed at startup.
// 2. Open drawer by clicking drawer icon.
// 3. Check if drawer is open.
// When using ActivityScenario.launch(), always call close()
activityScenario.close()
}
@Test
fun taskDetailScreen_doubleUpButton() = runBlocking {
val task = Task("Up button", "Description")
tasksRepository.saveTask(task)
// Start the Tasks screen.
val activityScenario = ActivityScenario.launch(TasksActivity::class.java)
dataBindingIdlingResource.monitorActivity(activityScenario)
// 1. Click on the task on the list.
// 2. Click on the edit task button.
// 3. Confirm that if we click Up button once, we end up back at the task details page.
// 4. Confirm that if we click Up button a second time, we end up back at the home screen.
// When using ActivityScenario.launch(), always call close().
activityScenario.close()
}
@Test
fun taskDetailScreen_doubleBackButton() = runBlocking {
val task = Task("Back button", "Description")
tasksRepository.saveTask(task)
// Start Tasks screen.
val activityScenario = ActivityScenario.launch(TasksActivity::class.java)
dataBindingIdlingResource.monitorActivity(activityScenario)
// 1. Click on the task on the list.
// 2. Click on the Edit task button.
// 3. Confirm that if we click Back once, we end up back at the task details page.
// 4. Confirm that if we click Back a second time, we end up back at the home screen.
// When using ActivityScenario.launch(), always call close()
activityScenario.close()
}
第 3 步:编写您的导航测试
为了完成您添加的测试,您需要访问、点击并为全局导航视图编写 Espresso 断言。
全局导航中您需要访问的一部分是显示在工具栏开头的 导航按钮。在待办事项应用中,这可以是导航抽屉图标或 向上按钮。
以下扩展函数使用工具栏的 getNavigationContentDescription
方法来获取此图标的内容描述。
- 复制
getToolbarNavigationContentDescription
扩展函数并将其添加到 AppNavigationTest.kt 文件的末尾(类之外)。
AppNavigationTest.kt
fun <T : Activity> ActivityScenario<T>.getToolbarNavigationContentDescription()
: String {
var description = ""
onActivity {
description =
it.findViewById<Toolbar>(R.id.toolbar).navigationContentDescription as String
}
return description
}
下面的代码片段应该可以帮助您完成测试。
这是使用 getToolbarNavigationContentDescription
扩展函数点击导航按钮的示例:
点击工具栏导航按钮
onView(
withContentDescription(
activityScenario
.getToolbarNavigationContentDescription()
)
).perform(click())
这段代码检查导航抽屉本身是打开还是关闭:
检查导航抽屉是否打开
onView(withId(R.id.drawer_layout))
.check(matches(isOpen(Gravity.START))) // Left drawer is open.
onView(withId(R.id.drawer_layout))
.check(matches(isClosed(Gravity.START))) // Left Drawer is closed.
这是点击系统返回按钮的示例:
点击系统返回按钮
pressBack()
// You'll need to import androidx.test.espresso.Espresso.pressBack.
- 使用上面的示例和您对 Espresso 的了解,完成
tasksScreen_clickOnAndroidHomeIcon_OpensNavigation
、taskDetailScreen_doubleUpButton()
和taskDetailScreen_doubleBackButton
。 - 运行您的测试,并确认一切正常!
恭喜您编写了端到端测试并完成了此 Codelab!您刚刚编写的这三个测试的解决方案代码 在此处。
10. 解决方案代码
整个三个 Codelab 的最终代码 在此处。
这是 Architecture Blueprints 示例 中测试的简化版本。如果您想查看更多测试或了解更高级的测试技术(例如使用 sharedTest 文件夹),请查看该示例。
11. 总结
此 Codelab 涵盖了:
- 回顾了前面课程中协程测试的内容,包括涵盖了
runBlocking
与runBlockingTest
的用法。 - 如何使用
TestCoroutineDispatcher
测试使用viewModelScope
的协程。 TestCoroutineDispatcher
控制协程执行的能力,通过pauseDispatcher
和resumeDispatcher
。- 通过更新模拟对象来测试错误处理。
- 测试您的数据层,包括您的 DAO 和本地数据源。
- 使用
IdlingResource
(以及CountingIldingResource
子类)来编写包含长时间运行代码并与 数据绑定库 协同工作的端到端测试。 - 在端到端测试中测试全局应用导航。
12. 了解更多
示例
- 官方测试示例 - 这是官方测试示例,基于此处使用的相同待办事项笔记应用。此示例中的概念超出了这三个测试 Codelab 中涵盖的内容。
- Sunflower 演示 - 这是主要的 Android Jetpack 示例,也使用了 Android 测试库。
- Espresso 测试示例
Udacity 课程
Android 开发者文档
- 测试您的 Activity
- Espresso
- Android 上的依赖注入
- 测试导航
- 测试您的 workers –- WorkManager 测试指南
视频
- 构建可测试的 Android 应用 (Google I/O'19)
- 在 Android 上测试协程 (Android Dev Summit ‘19)
- Fragments:过去、现在和未来 (Android Dev Summit ‘19) –- 测试和 Fragments 部分
- Android 依赖注入的观点指南 (Android Dev Summit ‘19)
其他
13. 下一个 Codelab
有关本课程中其他 Codelab 的链接,请参阅 Advanced Android in Kotlin codelabs 登陆页面。