Kotlin 高级 Android 开发 05.3:测试协程和 Jetpack 集成

1. 欢迎

简介

第三个测试 Codelab 概述了其他测试主题,包括:

  • 协程,包括视图模型作用域协程
  • Room
  • 数据绑定
  • 端到端测试

您应该已经掌握的知识

您应该熟悉以下内容:

您将学习的内容

  • 如何测试协程,包括视图模型作用域协程。
  • 如何测试简单的错误边界用例。
  • 如何测试 Room。
  • 如何使用 Espresso 测试数据绑定。
  • 如何编写端到端测试。
  • 如何测试全局应用导航。

您将使用

您将执行的操作

  • 编写 ViewModel 集成测试,以测试使用 viewModelScope 的代码。
  • 暂停和恢复协程执行以进行测试。
  • 修改伪造存储库以支持错误测试。
  • 编写 DAO 单元测试。
  • 编写本地数据源集成测试。
  • 编写包含协程和数据绑定代码的端到端测试。
  • 编写全局应用导航测试。

2. 应用概览

在本系列 Codelab 中,您将使用 TO-DO Notes 应用。该应用允许您记下要完成的任务并将其显示在列表中。然后,您可以将它们标记为已完成或未完成,对其进行过滤或删除。

441dc71d6f7d5807.gif

此应用使用 Kotlin 编写,包含几个屏幕,使用 Jetpack 组件,并遵循 应用架构指南 中的架构。学习如何测试此应用将使您能够测试使用相同库和架构的应用。

下载代码

要开始使用,请下载代码

或者,您可以克隆代码的 Github 存储库

$ git clone https://github.com/google-developer-training/advanced-android-testing.git
$ cd android-testing
$ git checkout end_codelab_2

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

步骤 1:运行示例应用

下载 TO-DO 应用后,在 Android Studio 中打开并运行它。它应该可以编译。通过执行以下操作来探索该应用

  • 使用加号浮动操作按钮创建新任务。首先输入标题,然后输入有关该任务的其他信息。使用绿色复选标记 FAB 保存它。
  • 在任务列表中,点击您刚刚完成的任务的标题,然后查看该任务的详细信息屏幕以查看其余描述。
  • 在列表或详细信息屏幕上,选中该任务的复选框以将其状态设置为**已完成**。
  • 返回任务屏幕,打开过滤器菜单,然后按**活动**和**已完成**状态过滤任务。
  • 打开导航抽屉并点击**统计信息**。
  • 返回概览屏幕,然后从导航抽屉菜单中选择**清除已完成的任务**以删除所有状态为**已完成**的任务

483916536f10c42a.png

步骤 2:浏览示例应用代码

TO-DO 应用基于 Architecture Blueprints 测试和架构示例。该应用遵循 应用架构指南 中的架构。它使用带有 Fragment 的 ViewModel、存储库和 Room。如果您熟悉以下任何示例,则此应用具有类似的架构

了解应用的总体架构比深入了解任何一层逻辑更为重要。

f2e425a052f7caf7.png

以下是您将找到的软件包摘要

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

.addedittask

**添加或编辑任务屏幕:**用于添加或编辑任务的 UI 层代码。

.data

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

.statistics

**统计信息屏幕:**用于统计信息屏幕的 UI 层代码。

.taskdetail

**任务详细信息屏幕:**用于单个任务的 UI 层代码。

.tasks

**任务屏幕:**用于所有任务列表的 UI 层代码。

.util

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

数据层 (.data)

此应用包含一个模拟网络层(位于 **remote** 软件包中)和一个数据库层(位于 **local** 软件包中)。为简单起见,在此项目中,网络层仅使用带有延迟的 HashMap 进行模拟,而不是发出真正的网络请求。

**DefaultTasksRepository** 协调或调节网络层和数据库层之间的数据,并将数据返回到 UI 层。

UI 层(.addedittask、.statistics、.taskdetail、.tasks)

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

导航

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

3. 任务:协程测试简介和回顾

代码以**同步**或**异步**方式执行。

  • 当代码同步运行时,一个任务会在执行移至下一个任务之前完全完成。
  • 当代码异步运行时,任务会并行运行。

The scheme displays synchronous code.

The scheme displays asynchronous code.

异步代码几乎总是用于长时间运行的任务,例如网络或数据库调用。它也可能难以测试。这有两个常见原因

  • 异步代码往往是**非确定性的**。这意味着如果测试多次并行运行操作 A 和 B,有时 A 会先完成,有时 B 会先完成。这可能会导致**不稳定**的测试(结果不一致的测试)。

4a3a1e86d86365ad.png

  • 在测试时,您通常需要为异步代码确保某种同步机制。测试在测试线程上运行。当您的测试在不同的线程上运行代码或创建新的协程时,这项工作将异步启动,与测试线程分开。同时,测试协程将继续并行执行指令。在任何一个已启动的任务完成之前,测试可能会完成。 9f5df9c30b2b48f4.png同步机制是告诉测试执行“等待”异步工作完成的方法。

810231c26c4c77b2.png

在 Kotlin 中,运行异步代码的常见机制是协程。在测试异步代码时,您需要使代码具有确定性并提供同步机制。以下类和方法可以帮助您做到这一点

  • 使用 runBlockingTestrunBlocking.
  • 使用 TestCoroutineDispatcher 进行本地测试。
  • 暂停协程执行以测试代码在某个确切时间点的状态。

您将首先探索 runBlockingTestrunBlocking 之间的区别。

步骤 1:观察如何在测试中运行基本协程

要测试包含 suspend 函数的代码,您需要执行以下操作

  1. kotlinx-coroutines-test 测试依赖项添加到您的应用的 build.gradle 文件中。
  2. 使用 @ExperimentalCoroutinesApi 注释测试类或测试函数。
  3. runBlockingTest 括起代码,以便您的测试等待协程完成。

让我们来看一个例子。

  1. 打开您应用的 build.gradle 文件。
  2. 找到 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。通常,这是当您需要从测试中调用挂起函数时。

  1. 看一下 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 包装调用 挂起函数 的代码。

当您使用 kotlinx-coroutines-test 中的任何函数时,请使用 @ExperimentalCoroutinesApi 注释类或函数,因为 kotlinx-coroutines-test 仍处于实验阶段,API 可能会发生变化。如果您不这样做,您将收到 lint 警告。

runBlockingTest 在上面的代码中使用,因为您正在调用 repository.saveTask(activeTask),这是一个 suspend 函数。

runBlockingTest 既可以确定性地运行代码,又可以提供同步机制。 runBlockingTest 接收一个代码块并阻塞测试线程,直到它启动的所有协程都完成。它还会立即在协程中运行代码(跳过对 delay 的任何调用),并按照它们被调用的顺序运行——简而言之,它以确定性的顺序运行它们。

runBlockingTest 本质上是通过为您提供专门用于测试代码的协程上下文,使您的协程像非协程一样运行。

您在测试中这样做是因为代码每次都以相同的方式运行(同步和确定性)非常重要。

步骤 2:观察测试替身中的 runBlocking

还有另一个函数 runBlocking,当您需要在测试替身中使用协程而不是在测试类中使用协程时使用。使用 runBlocking 看起来与 runBlockingTest 非常相似,因为您将其包装在代码块周围以使用它。

  1. 看一下 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 是一个挂起函数。

runBlocking 与 runBlockingTest

runBlockingrunBlockingTest 都会阻塞当前线程并等待 lambda 中启动的任何关联协程完成。

此外,runBlockingTest 具有以下针对测试的行为

  1. 它跳过 delay,因此您的测试运行速度更快。
  2. 它将与测试相关的断言添加到协程的末尾。如果您启动一个协程并且它在 runBlocking lambda 结束后继续运行(这可能是协程泄漏),或者如果您遇到未捕获的异常,则这些断言将失败。
  3. 它使您可以控制协程执行的时间。

那么为什么要在您的测试替身(例如 FakeTestRepository)中使用 runBlocking 呢?有时您需要一个用于测试替身的协程,在这种情况下,您确实需要阻塞当前线程。这样做是为了当您的测试替身在测试用例中使用时,线程会阻塞并允许协程在测试完成之前完成。但是,测试替身实际上并没有定义测试用例,因此它们不需要也不应该使用 runBlockingTest 的所有测试特定功能。

总结

  • 测试需要确定性的行为,这样它们才不会不稳定。
  • “普通”协程是非确定性的,因为它们异步运行代码。
  • kotlinx-coroutines-testrunBlockingTest 的 gradle 依赖项。
  • 编写测试类(即带有 @Test 函数的类)使用 runBlockingTest 来获得确定性的行为。
  • 编写测试替身,使用 runBlocking

4. 任务:协程和 ViewModel

在此步骤中,您将学习如何测试使用协程的视图模型。

所有协程都需要 CoroutineScope。协程作用域控制协程的生命周期。当您取消作用域(或者从技术上讲,是协程的 Job,您可以在此处了解更多信息)时,作用域中运行的所有协程都会被取消。

由于您可能会从视图模型开始长时间运行的工作,因此您经常会发现自己在视图模型中创建和运行协程。通常,您需要为每个视图模型手动创建和配置一个新的 CoroutineScope 才能运行任何协程。这是很多样板代码。为了避免这种情况,lifecycle-viewmodel-ktx 提供了一个名为 viewModelScope 的扩展属性。

viewModelScope 是与每个视图模型关联的 CoroutineScopeviewModelScope 配置为在该特定 ViewModel 中使用。这具体意味着

  • viewModelScope 与视图模型绑定,以便在清理视图模型时(即调用 onCleared),作用域将被取消。这可确保当您的视图模型消失时,与其关联的所有协程工作也会消失。这避免了浪费工作和内存泄漏。
  • viewModelScope 使用 Dispatchers.Main 协程调度程序。 CoroutineDispatcher 控制协程的运行方式,包括协程代码在其上运行的线程。 Dispatcher.Main 将协程放在 UI 或主线程上。这对于 ViewModel 协程来说是一个合理的默认设置,因为视图模型通常会操作 UI。

这在生产代码中运行良好。但是对于本地测试(在 test 源集中在您的本地机器上运行的测试),使用 Dispatcher.Main 会导致一个问题:Dispatchers.Main 使用 Android 的 Looper.getMainLooper()。主循环器是真实应用程序的执行循环。主循环器在本地测试中不可用(默认情况下),因为您没有运行完整的应用程序。

要解决此问题,请使用 setMain() 方法(来自 kotlinx.coroutines.test)将 Dispatchers.Main 修改为使用 TestCoroutineDispatcherTestCoroutineDispatcher 是专为测试设计的调度程序。

接下来,您将为使用 viewModelScope 的视图模型代码编写测试。

步骤 1:观察 Dispatcher.Main 导致错误

添加一个测试,检查任务完成后,snackbar 是否显示正确的完成消息。

  1. 打开 test > tasks > TasksViewModelTest

6818c266d2e6853e.png

  1. 添加此新测试方法

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))
}
  1. 运行此测试。观察它失败并显示以下错误

“线程“main”中的异常 java.lang.IllegalStateException:具有 Main 调度程序的模块初始化失败。对于测试,可以使用 kotlinx-coroutines-test 模块中的 Dispatchers.setMain。”

此错误表明 Dispatcher.Main 初始化失败。根本原因(错误中未解释)是缺少 Android 的 Looper.getMainLooper()。错误消息确实告诉您使用 kotlinx-coroutines-test 中的 Dispatcher.setMain。继续这样做吧!

步骤 2:将 Dispatcher.Main 替换为 TestCoroutineDispatcher

TestCoroutineDispatcher 是一个用于测试的协程调度器。它会立即执行任务,并让你能够控制测试中协程执行的时机,例如允许你暂停和重新启动协程执行。

  1. TasksViewModelTest 中,创建一个 TestCoroutineDispatcher 作为名为 testDispatcherval

使用 testDispatcher 代替默认的 Main 调度器。

  1. 创建一个 @Before 方法,在每次测试之前调用 Dispatchers.setMain(testDispatcher)
  2. 创建一个 @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()
}
  1. 再次运行测试。现在它通过了!

步骤 3:添加 MainCoroutineRule

如果你在应用中使用协程,任何涉及在视图模型中调用代码的本地测试都很可能会调用使用 viewModelScope 的代码。与其将设置和拆卸 TestCoroutineDispatcher 的代码复制粘贴到每个测试类中,不如创建一个自定义 JUnit 规则来避免这些样板代码。

JUnit 规则 是可以定义在测试之前、之后或期间执行的通用测试代码的类 - 这是一种将原本位于 @Before@After 中的代码提取到一个可以重用的类中的方法。

现在创建一个 JUnit 规则。

  1. 在测试源集的根文件夹中创建一个名为 MainCoroutineRule.kt 的新类

72c1d0a7ae5c2c04.png

  1. 将以下代码复制到 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 规则的原因。
  • startingfinished 方法与你在 @Before@After 函数中编写的代码相匹配。它们也分别在每个测试之前和之后运行。
  • MainCoroutineRule 还实现了 TestCoroutineScope,你将 TestCoroutineDispatcher 传递给它。这使 MainCoroutineRule 能够控制协程计时(使用你传入的 TestCoroutineDispatcher)。你将在下一步中看到一个示例。

步骤 4:在测试中使用新的 Junit 规则

  1. 打开 TasksViewModelTest
  2. 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 对其进行注释。

  1. 运行 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

  1. 打开 test > data > source > DefaultTasksRepositoryTest.kt

d95afb8e34702b79.png

  1. MainCoroutineRule 添加到 DefaultTasksRepositoryTest 类中

DefaultTasksRepositoryTest.kt

// Set the main coroutines dispatcher for unit testing.
@ExperimentalCoroutinesApi
@get:Rule
var mainCoroutineRule = MainCoroutineRule()
  1. 在定义测试中的存储库时,使用 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 时,如果未指定,它将创建一个新的 TestCoroutineDispatcherMainCoroutineRule 包含一个 TestCoroutineDispatcher。因此,为了确保你不会意外创建多个 TestCoroutineDispatcher 实例,请使用 mainCoroutineRule.runBlockingTest 而不是仅仅使用 runBlockingTest

  1. runBlockingTest 替换为 mainCoroutineRule.runBlockingTest

DefaultTasksRepositoryTest.kt

// REPLACE
fun getTasks_requestsAllTasksFromRemoteDataSource() = runBlockingTest {

// WITH
fun getTasks_requestsAllTasksFromRemoteDataSource() = mainCoroutineRule.runBlockingTest {
  1. 运行你的 DefaultTasksRepositoryTest 类,并确认一切都能像以前一样工作!

做得好!现在你正在代码中使用 TestCoroutineDispatcher,它是更适合测试的调度器。接下来,你将了解如何使用 TestCoroutineDispatcher 的附加功能,即控制协程执行计时。

5. 任务:测试协程计时

在此步骤中,你将使用 TestCouroutineDispatcherpauseDispatcherresumeDispatcher 方法来控制协程在测试中的执行方式。使用这些方法,你将为 StatisticsViewModel 的加载指示器编写一个测试。

提醒一下,StatisticViewModel 保存所有数据并执行“统计信息”屏幕的所有计算

7abfbf08efb1b623.png

步骤 1:准备 StatisticsViewModel 以进行测试

首先,你需要确保可以将你的虚假存储库注入到你的视图模型中,请按照 上一个 Codelab 中描述的过程进行操作。由于这是复习且与协程计时无关,因此你可以随意复制/粘贴

  1. 打开 StatisticsViewModel
  2. 更改 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 
}
  1. StatisticsViewModel 文件的底部,在类外部,添加一个接收普通 TasksRepositoryTasksViewModelFactory

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)
}
  1. 更新 StatisticsFragment 以使用工厂。

StatisticsFragment.kt

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

// WITH

private val viewModel by viewModels<StatisticsViewModel> {
    StatisticsViewModelFactory(
        (requireContext().applicationContext as TodoApplication).taskRepository
    )
}
  1. 运行你的应用程序代码并导航到 StatisticsFragment 以确保你的统计信息屏幕像以前一样工作。

步骤 2:创建 StatisticsViewModelTest

现在,你准备创建一个测试,该测试将在 StatisticsViewModelTest.kt 的协程执行过程中暂停。

  1. 打开 StatisticsViewModel.kt
  2. 右键单击 StatisticsViewModel 类名,然后选择“生成”,然后选择“测试”。
  3. 按照提示在 test 源集中创建 StatisticsViewModelTest

按照以下步骤设置你的 StatisticsViewModel 测试,如上一课中所述。这是对视图模型测试内容的良好回顾

  1. 添加 InstantTaskExecutorRule。这会将 Architecture Components(ViewModels 是其一部分)使用的后台执行器替换为将同步执行每个任务的执行器。这确保你的测试是确定性的。
  2. 添加 MainCoroutineRule,因为你正在测试协程和视图模型。
  3. 为测试中的主题 (StatisticsViewModel) 及其依赖项 (FakeTestRepository) 的测试替身创建字段。
  4. 创建一个 @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))
}
  1. 复制上面的代码
  2. 添加:import org.hamcrest.CoreMatchers.is``
  3. 运行测试。此测试失败

上面的测试实际上没有意义,因为它测试的是 dataLoading 同时为 truefalse

查看错误消息,该测试因第一个断言语句而失败。

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.
   }
}

在类似情况下,您可以使用 TestCouroutineDispatcherpauseDispatcherresumeDispatchermainCoroutineRule. pauseDispatcher() 是暂停 TestCoroutineDispatcher 的简写。当调度程序暂停时,任何新的协程都会被添加到队列中,而不是立即执行。这意味着 refresh 内部的代码执行将在协程启动之前暂停

StatisticsViewModel.kt

fun refresh() {
   _dataLoading.value = true
   // PAUSES EXECUTION HERE
   viewModelScope.launch {
       tasksRepository.refreshTasks()
       _dataLoading.value = false
   }
}

当您调用 mainCoroutineRule.resumeDispatcher() 时,协程中的所有代码都将被执行。

  1. 更新测试以使用 pauseDispatcherresumeDispatcher,以便在执行协程之前暂停,检查是否显示了加载指示器,然后恢复并检查加载指示器是否隐藏

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))
}
  1. 运行测试并查看它现在是否通过。

太棒了——您已经学会了如何编写使用 TestCoroutineDispatcher 暂停和恢复协程执行能力的协程测试。这使您在编写需要精确计时的测试时拥有更多控制权。

6. 任务:测试错误处理

在测试中,测试代码按预期执行(有时称为快乐路径)以及您的应用在遇到错误和边缘情况时的行为非常重要。在此步骤中,您将向 StatisticsViewModelTest 添加一个测试,以确认在无法加载任务列表时(例如,如果网络中断)的正确行为。

步骤 1:向测试替身添加错误标志

首先,您需要人为地造成错误情况。一种方法是更新您的测试替身,以便您可以使用标志将其“设置”为错误状态。如果标志为 false,则测试替身将正常运行。但是,如果标志设置为 true,则测试替身将返回一个真实的错误;例如,它可能会返回加载数据失败错误。更新 FakeTestRepository 以包含一个错误标志,当该标志设置为 true 时,会导致代码返回一个真实的错误。

  1. 打开 test > data > source > FakeTestRepository。
  2. 添加一个名为 shouldReturnError 的布尔标志,并将其初始设置为 false,这意味着默认情况下不会返回错误。

FakeTestRepository.kt

private var shouldReturnError = false
  1. 创建一个 setReturnError 方法来更改存储库是否应该返回错误

FakeTestRepository.kt

fun setReturnError(value: Boolean) {
    shouldReturnError = value
}
  1. getTaskgetTasks 包装在 if 语句中,以便如果 shouldReturnErrortrue,则该方法返回 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 布尔值(errorempty

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 是否正确加载。如果出现错误,errorempty 都应为 true

  1. 打开 StatisticsViewModelTest。
  2. 创建一个名为 loadStatisticsWhenTasksAreUnavailable_callErrorToDisplay 的新测试
  3. tasksRepository 上调用 setReturnError(),将其设置为 true。
  4. 检查 statisticsViewModel.emptystatisticsViewModel.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

  1. 使用 androidTestImplementation 将架构组件测试库添加到您的插桩测试中

app/build.gradle

    androidTestImplementation "androidx.arch.core:core-testing:$archTestingVersion"

步骤 2:创建 TasksDaoTest 类

Room DAO 实际上是 Room 通过注释处理的魔力变成类的接口。为接口生成测试类通常没有意义,因此没有键盘快捷键,您需要手动创建测试类。

创建一个 TasksDaoTest

  1. 在您的项目窗格中,导航到 androidTest > data > source。
  2. 右键单击源包并创建一个名为 local 的新包
  3. local 中,创建一个名为 TasksDaoTest.kt 的 Kotlin 文件和类

3a4269e803fa8f88.png

步骤 3:设置 TasksDaoTest 类

  1. 复制以下代码以启动您的 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 的类中。这在 第一个代码实验室 中已经介绍过。

要访问 DAO 的实例,您需要构建数据库的实例。要在您的测试中执行此操作,请执行以下操作

  1. TasksDaoTest 中,为您的数据库创建一个 lateinit 字段

TasksDaoTest.kt

private lateinit var database: ToDoDatabase
  1. 创建一个 @Before 方法来初始化您的数据库。

具体来说,在初始化用于测试的数据库时

  • 使用 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()
}
  1. 创建一个 @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 获取该任务。

  1. 仍在 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 运行测试,因为 insertTaskgetTaskById 都是挂起函数。
  • 您可以像往常一样使用 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
  1. 运行您的测试并确认它通过。

步骤 5:自己动手试试!

现在,尝试自己编写 DAO 测试。编写一个测试,该测试插入一个任务,更新它,然后检查它是否具有更新后的值。以下是 updateTaskAndGetById 的入门代码。

  1. 复制此测试入门代码

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.
}
  1. 完成代码,参考您刚刚添加的 insertTaskAndGetById 测试。
  2. 运行您的测试并确认它通过!

完整的测试位于存储库的 end_codelab_3 分支的 此处,以便您可以进行比较。

步骤 6:为 TasksLocalDataSource 创建集成测试

您刚刚为 TasksDao 创建了单元测试。接下来,您将为 TasksLocalDataSource 创建集成测试。 TasksLocalDatasource 是一个类,它获取 DAO 返回的信息并将其转换为您的存储库类期望的格式(例如,它将返回值包装在 SuccessError 状态中)。您将编写一个集成测试,因为您将测试真实的 TasksLocalDatasource 代码和真实的 DAO 代码。

TasksLocalDataSourceTest 创建测试的步骤与创建 DAO 测试的步骤非常相似。

  1. 打开您的应用程序的 TasksLocalDataSource 类。
  2. 右键单击 TasksLocalDataSource 类名并选择生成,然后选择测试。
  3. 按照提示在 androidTest 源集中创建 TasksLocalDataSourceTest
  4. 复制以下代码

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 代码集成。

  1. TasksLocalDataSourceTest 中,为要测试的两个组件创建一个 lateinit 字段—— TasksLocalDataSource 和您的 database

TasksLocalDataSourceTest.kt

private lateinit var localDataSource: TasksLocalDataSource
private lateinit var database: ToDoDatabase
  1. 创建一个 @Before 方法来初始化您的数据库和数据源。
  2. 使用 inMemoryDatabaseBuilderApplicationProvider.getApplicationContext() 方法,以与 DAO 测试相同的方式创建数据库。
  3. 添加 allowMainThreadQueries。通常,Room 不允许在主线程上运行数据库查询。调用 allowMainThreadQueries 会关闭此检查。不要在生产代码中这样做!
  4. 使用您的数据库和 Dispatchers.Main 实例化 TasksLocalDataSource。这将在主线程上运行您的查询(由于 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
        )
}
  1. 创建一个 @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 测试一样,先复制并运行一个示例测试。

  1. 复制这些导入语句
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
  1. 复制此测试

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
  1. 运行您的测试!

步骤 8:编写您自己的本地数据源测试

现在轮到你了。

  1. 复制以下代码

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.

}
  1. 完成代码,根据需要参考您之前添加的 saveTask_retrievesTask 测试。
  2. 运行您的测试并确认它通过!

完整的测试位于存储库的 end_codelab_3 分支中 此处,以便您进行比较。

8. 任务:使用数据绑定进行端到端测试

到目前为止,在本系列 codelab 中,您已经编写了单元测试和集成测试。诸如 TaskDetailFragmentTest 之类的集成测试仅专注于测试单个片段的功能,而无需移动到任何其他片段甚至创建活动。类似地,TaskLocalDataSourceTest 测试数据层中一起工作的几个类,但实际上并不检查 UI。

**端到端测试 (E2E)** 测试协同工作的功能组合。它们测试应用程序的大部分内容并模拟实际使用情况。总的来说,这些测试是仪器测试(在 androidTest 源集中)。

以下是与 Todo 应用程序相关的端到端测试和集成测试之间的一些区别。端到端测试

  • 从第一个屏幕启动应用程序。
  • 创建一个实际的活动和存储库。
  • 测试多个片段协同工作。

编写端到端测试很快就会变得很复杂,因此有很多工具和库可以使它更容易。Espresso 是一个 Android UI 测试库,通常用于编写端到端测试。您在上一个 codelab 中学习了使用 Espresso 的基础知识

在此步骤中,您将编写一个真正的端到端测试。您将使用 Espresso 空闲资源 来正确处理编写涉及长时间运行的操作和 数据绑定 库的 E2E 测试。

您将首先添加一个编辑已保存任务的测试。

步骤 1:关闭动画

对于 Espresso UI 测试,在实施任何其他操作之前,关闭动画是一种最佳做法

  1. 在您的测试设备(物理或模拟)上,转到**设置 > 开发者选项**。
  2. 禁用这三个设置:**窗口动画缩放**、**过渡动画缩放**和**动画时长缩放**。

192483c9a6e83a0.png

步骤 2:创建 TasksActivityTest

  1. androidTest 中创建一个名为 **TasksActivityTest.kt** 的文件和类

3183c517b46765f7.png

  1. 使用 @RunWith(AndroidJUnit4::class) 注释该类,因为您正在使用 AndroidX 测试代码。
  2. 使用 @LargeTest 注释该类,这表示这些是端到端测试,测试代码的*大部分*。

端到端测试模拟完整应用程序的运行方式并模拟实际使用情况。因此,您将让 ServiceLocator 创建存储库,而不是自己实例化存储库或存储库测试替身

  1. 创建一个名为 repository 的属性,它是 TasksRepository。
  2. 创建一个 @Before 方法并使用 ServiceLocatorprovideTasksRepository 方法初始化存储库;使用 getApplicationContext 获取应用程序上下文。
  3. @Before 方法中,删除存储库中的所有任务,以确保在每次测试运行之前将其完全清除。
  4. 创建一个 @After 方法,该方法调用 ServiceLocatorresetRepository() 方法。

完成后,您的代码应如下所示

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 测试

是时候编写一个用于编辑已保存任务的端到端测试了。

  1. 打开 TasksActivityTest
  2. 在类内部,添加以下框架代码

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 用于等待所有挂起函数完成后再继续执行块中的操作。请注意,由于存在 错误,我们使用的是 runBlocking 而不是 runBlockingTest。
  • ActivityScenario 是一个 AndroidX 测试库 类,它包装了一个活动并为您提供对活动生命周期的直接控制以进行测试。它类似于 FragmentScenario
  • 使用 ActivityScenario 时,您使用 launch 启动活动,然后在测试结束时调用 close.
  • 您必须在调用 ActivityScenario.launch() **之前**设置数据层的初始状态(例如将任务添加到存储库)。
  • 如果您正在使用数据库(您正在使用),则**必须**在测试结束时关闭数据库。

这是涉及 Activity 的任何测试的基本设置。在启动 ActivityScenario 和关闭 ActivityScenario 之间,您现在可以编写 Espresso 代码。

  1. 添加 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
  1. 运行此测试**五**次。请注意,该测试是**不稳定的**,这意味着有时它会通过,有时会失败

751ec988bbcdf97f.png

1dbf0ae691a617e4.png

测试有时失败的原因是时间和测试同步问题。Espresso 在 UI 操作和 UI 中产生的变化之间进行 同步。例如,假设您告诉 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

在您在步骤 1 中执行单击后,Espresso 将等待新视图显示,然后再在步骤 2 中检查是否有文本“下一个屏幕”。

但是,在某些情况下,Espresso 的内置同步机制不知道要等待足够长的时间才能更新视图。例如,当您需要为视图加载一些数据时,Espresso 不知道何时完成数据加载。Espresso 也不知道 数据绑定 库何时仍在更新视图。

在 Espresso 无法判断应用程序是否正在忙于更新 UI 的情况下,您可以使用 空闲资源 同步机制。这是一种明确告诉 Espresso 应用程序何时空闲(意味着 Espresso 应该继续与应用程序交互并检查应用程序)或不空闲(意味着 Espresso 应该等待)的方法。

您使用空闲资源的一般方法如下

  1. 在您的应用程序代码中创建一个空闲资源或其子类作为单例。
  2. 在您的**应用程序**代码(*不是*您的测试代码)中,通过将 IdlingResource 的状态更改为空闲或非空闲来添加跟踪应用程序是否空闲的逻辑。
  3. 在每次测试之前调用 IdlingRegistry.getInstance().register 以注册 IdlingResource。通过注册 IdlingResource,Espresso 将*等待*直到它空闲后再移至下一个 Espresso 语句。
  4. 在每次测试后调用 IdlingRegistry.getInstance().unregister 以取消注册 IdlingResource

步骤 4:将 Idling Resource 添加到您的 Gradle 文件

  1. 打开您的应用程序的 build.gradle 文件并添加 Espresso 空闲资源库

app/build.gradle

implementation "androidx.test.espresso:espresso-idling-resource:$espressoVersion"
  1. 还要将以下选项 returnDefaultValues = true 添加到 testOptions.unitTests.

app/build.gradle

    testOptions.unitTests {
        includeAndroidResources = true
        returnDefaultValues = true
    }

当您将空闲资源代码添加到应用程序代码时,需要 returnDefaultValues = true 以保持单元测试运行。

步骤 5:创建一个 Idling Resource 单例

您将添加两个空闲资源。一个用于处理视图的数据绑定同步,另一个用于处理存储库中的长时间运行操作。

您将从与长时间运行的存储库操作相关的空闲资源开始。

  1. app > java > main > util 中创建一个名为 EspressoIdlingResource.kt 的新文件

e2ea99d19353df80.png

  1. 复制以下代码

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()
        }
    }
}

此代码创建一个名为 countingIdlingResource 的单例空闲资源(使用 Kotlin 的 object 关键字)。

您在这里使用 CountingIdlingResource 类。CountingIdlingResource 允许您增加和减少计数器,以便

  • 当计数器大于零时,应用程序被认为正在工作。
  • 当计数器为零时,应用程序被认为是空闲的。

基本上,每当应用程序开始执行某些工作时,计数器都会递增。当工作完成时,计数器递减。因此,只有在没有工作正在进行时,CountingIdlingResource 的“计数”才会为零。这是一个单例,因此您可以在应用程序中任何可能进行长时间运行工作的任何位置访问此空闲资源。

步骤 6:创建 wrapEspressoIdlingResource

以下是如何使用 EspressoIdlingResource 的示例

EspressoIdlingResource.increment()
try {
     doSomethingThatTakesALongTime()
} finally {
    EspressoIdlingResource.decrement()
}

您可以通过创建一个名为 wrapEspressoIdlingResource 的内联函数来简化此操作。

  1. 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 中。

  1. 在您的应用程序代码中,打开 data > source > DefaultTasksRepository
  2. 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 无法判断通过数据绑定更新的视图何时完成更新。

由于此数据绑定空闲资源代码很复杂,因此提供了代码并进行了解释。

  1. androidTest 源集中创建一个新的 util 包。
  2. androidTest > util: 中创建一个新类 DataBindingIdlingResource.kt

550ea3f54c5e2421.png

  1. 将以下代码复制到您的新类中

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()
    }
}

这里有很多事情正在发生,但总体思路是,每当您使用数据绑定布局时,都会生成 ViewDataBindingViewDataBindinghasPendingBindings 方法报告数据绑定库是否需要更新 UI 以反映数据更改。

仅当任何 ViewDataBinding 都没有待处理的绑定时,此空闲资源才被视为空闲。

最后,扩展函数 DataBindingIdlingResource.monitorFragmentDataBindingIdlingResource.monitorActivity 分别接收 FragmentScenarioActivityScenario。然后他们找到底层活动并将其与 DataBindingIdlingResource 关联,以便您可以跟踪布局状态。您必须从测试中调用这两个方法之一,否则 DataBindingIdlingResource 将不知道您的布局的任何信息。

步骤 9:在测试中使用 Idling Resources

您已经创建了两个空闲资源,并确保它们被正确设置为繁忙或空闲。Espresso 仅在注册空闲资源时等待它们。所以现在您的测试需要注册和取消注册您的空闲资源。您将在 TasksActivityTest 中执行此操作。

  1. 打开 TasksActivityTest.kt。
  2. 实例化一个 private DataBindingIdlingResource

TasksActivityTest.kt

    // An idling resource that waits for Data Binding to have no pending bindings.
    private val dataBindingIdlingResource = DataBindingIdlingResource()
  1. 创建 @Before@After 方法来注册和取消注册 EspressoIdlingResource.countingIdlingResourcedataBindingIdlingResource

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)
    }

请记住,countingIdlingResourcedataBindingIdlingResource 都正在监控您的应用程序代码,观察它是否空闲。通过在您的测试中注册这些资源,当任一资源繁忙时,Espresso 将等待它们空闲后再移动到下一个命令。这意味着如果您的 countingIdlingResource 的计数大于零,或者存在挂起的数据绑定布局,Espresso 将会等待。

  1. 更新 editTask() 测试,以便在启动活动场景后,使用 monitorActivity 将活动与 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...
    }
  1. 运行您的测试五次。您应该会发现测试不再不稳定。

整个 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:使用空闲资源编写您自己的测试

现在轮到你了。

  1. 复制以下代码

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.
        
    }
  1. 完成代码,参考您之前添加的 editTask 测试。
  2. 运行您的测试并确认它通过!

完成的测试 在这里 ,以便您进行比较。

9. 任务:端到端应用程序导航测试

您可以进行的最后一项测试是测试应用程序级导航。例如测试

  • 导航抽屉
  • 应用程序工具栏
  • 向上按钮
  • 后退按钮

现在让我们开始吧!

步骤 1:创建 AppNavigationTest

  1. androidTest 中创建一个名为 AppNavigationTest.kt 的文件和类

66e6b12c8d9bd550.png

类似于设置 TasksActivityTest 的方式设置您的测试。

  1. 为使用 AndroidX 测试库且是端到端测试的类添加适当的注释。
  2. 设置您的 taskRepository
  3. 注册并取消注册正确的空闲资源。

完成后,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:设置您的导航测试

下面的入门代码概述了三个测试并描述了它们应该做什么。每个测试都设置为

  1. 为测试配置存储库。
  2. 创建一个 ActivityScenario
  3. 正确设置您的 DataBindingIdingResource

现在添加代码。

  1. 将此代码复制到 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 断言。

您需要访问的全局导航的一部分是显示在工具栏开头的导航按钮。在 Todo 应用程序中,这是导航抽屉图标或 向上按钮

725edafc8d16a117.png

以下扩展函数使用工具栏的 getNavigationContentDescription 获取此图标的内容描述。

  1. 复制 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.      
  1. 使用上面的示例和您对 Espresso 的了解,完成 tasksScreen_clickOnAndroidHomeIcon_OpensNavigation, taskDetailScreen_doubleUpButton()taskDetailScreen_doubleBackButton。
  2. 运行您的测试,并确认一切正常!

恭喜您编写了端到端测试并完成了此代码实验室!您刚刚编写的三个测试的解决方案代码 在这里

10. 解决方案代码

整个三个代码实验室的最终代码 在这里

这是在 架构蓝图示例 中找到的测试的简化版本。如果您想查看更多测试,或了解更高级的测试技术(例如使用 sharedTest 文件夹),请查看该示例。

11. 总结

此代码实验室涵盖了

12. 了解更多

示例

  • 官方测试示例 - 这是官方测试示例,它基于此处使用的相同的待办事项应用程序。此示例中的概念超出了三个测试代码实验室中涵盖的内容。
  • Sunflower 演示 - 这是主要的 Android Jetpack 示例,它也使用了 Android 测试库
  • Espresso 测试示例

Udacity 课程

Android 开发者文档

视频

其他

13. 下一个代码实验室

要获取此课程中其他代码实验室的链接,请参阅 Kotlin 高级 Android 代码实验室登录页面