(已弃用) Kotlin 版高级 Android 开发 05.3:测试协程和 Jetpack 集成

1. 欢迎

简介

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

  • 协程,包括 view model 作用域的协程
  • Room
  • 数据绑定
  • 端到端测试

前提条件

您应该熟悉以下内容:

您将学到什么

  • 如何测试协程,包括 view model 作用域的协程。
  • 如何测试简单的错误边缘情况。
  • 如何测试 Room。
  • 如何使用 Espresso 测试数据绑定。
  • 如何编写端到端测试。
  • 如何测试全局应用导航。

您将使用

您将执行的操作

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

2. 应用概览

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

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 步:运行示例应用

下载待办事项应用后,在 Android Studio 中打开并运行它。它应该能够编译。请按照以下步骤探索应用:

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

483916536f10c42a.png

第 2 步:探索示例应用代码

待办事项应用基于架构蓝图测试和架构示例。该应用遵循应用架构指南中的架构。它使用带有 Fragment 的 ViewModel、一个仓库和 Room。如果您熟悉以下任何示例,此应用也具有类似的架构:

重要的是您理解应用的整体架构,而不是深入理解任何一层的逻辑。

f2e425a052f7caf7.png

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

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

.addedittask

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

.data

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

.statistics

统计信息屏幕: 用于统计信息屏幕的界面层代码。

.taskdetail

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

.tasks

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

.util

实用工具类: 应用各部分使用的共享类,例如用于多个屏幕上的下拉刷新布局。

数据层 (.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. 任务:协程测试简介和回顾

代码执行可以是同步的,也可以是异步的。

  • 当代码同步运行时,一个任务完全完成后才会执行下一个任务。
  • 当代码异步运行时,任务并行运行。

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。通常,这是当您需要从测试中调用 suspend 函数时。

  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 包围调用suspend 函数的代码。

使用 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 是一个 suspend 函数。

runBlocking 与 runBlockingTest

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

此外,runBlockingTest 具有以下专用于测试的行为:

  1. 它会跳过 delay,因此您的测试运行速度更快。
  2. 它会在协程结束时添加与测试相关的断言。如果您启动协程并在 runBlocking lambda 结束后它仍在继续运行(这可能是协程泄露),或者您有未捕获的异常,这些断言将失败。
  3. 它允许您控制协程执行的时间。

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

总结

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

4. 任务:协程和 ViewModel

在此步骤中,您将学习如何测试使用协程的 view model。

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

由于您可能会从 view model 中启动长时间运行的工作,因此您会发现自己经常在 view model 内部创建和运行协程。通常,您需要为每个 view model 手动创建和配置一个新的 CoroutineScope 来运行任何协程。这会产生很多样板代码。为了避免这种情况,lifecycle-viewmodel-ktx 提供了一个扩展属性,称为viewModelScope

viewModelScope 是与每个 view model 关联的 CoroutineScopeviewModelScope 配置用于该特定的 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 以使用TestCoroutineDispatcherTestCoroutineDispatcher 是专用于测试的调度程序。

接下来,您将为使用 viewModelScope 的 view model 代码编写测试。

第 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. 运行此测试。观察它因以下错误而失败

"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 是专用于测试的协程调度程序。它可以立即执行任务,并允许您控制测试中协程执行的时间,例如允许您暂停和重新启动协程执行。

  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

如果您在应用中使用协程,则涉及调用 view model 中代码的任何本地测试极有可能调用使用 viewModelScope 的代码。与其将设置和拆卸 TestCoroutineDispatcher 的代码复制粘贴到每个测试类中,不如创建一个自定义 JUnit 规则来避免此样板代码。

JUnit rules 是类,您可以在其中定义可在测试之前、之后或期间执行的通用测试代码 - 这是一种将原本位于 @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. DefaultTasksRepositoryTest 类内部添加 MainCoroutineRule

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

在上面的代码中,请记住 MainCoroutineRuleDispatcher.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 中描述的过程将伪造的仓库注入到您的 view model 中。由于这是回顾并且与协程计时无关,请随意复制/粘贴。

  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 测试,如前几课所述。这是对 view model 测试内容的很好回顾:

  1. 添加InstantTaskExecutorRule。这会将 Architecture Components(ViewModel 是其一部分)使用的后台执行程序替换为一个将同步执行每个任务的执行程序。这可确保您的测试是确定性的。
  2. 添加 MainCoroutineRule,因为您正在测试协程和 view model。
  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. 右键点击 source 软件包并创建一个名为 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 的任何类中。这在第一个 Codelab 中已涵盖。

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

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

TasksDaoTest.kt

private lateinit var database: ToDoDatabase
  1. 创建一个 @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()
}
  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 都是 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
  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 中,为您要测试的两个组件(TasksLocalDataSource 和您的 database)创建一个 lateinit 字段

TasksLocalDataSourceTest.kt

private lateinit var localDataSource: TasksLocalDataSource
private lateinit var database: ToDoDatabase
  1. 创建一个 @Before 方法,用于初始化您的数据库和数据源。
  2. 以与 DAO 测试相同的方式创建数据库,使用 inMemoryDatabaseBuilderApplicationProvider.getApplicationContext() 方法。
  3. 添加allowMainThreadQueries。通常,Room 不允许在主线程上运行数据库查询。调用 allowMainThreadQueries 会关闭此检查。请勿在生产代码中执行此操作!
  4. 实例化 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
        )
}
  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 这样的集成测试仅专注于测试单个 fragment 的功能,而不会移动到任何其他 fragment,甚至不会创建 activity。类似地,TaskLocalDataSourceTest 测试数据层中一起工作的几个类,但实际上不检查界面。

端到端测试 (E2E) 测试多个功能协同工作。它们测试应用的大部分,并模拟实际使用。这些测试大部分是检测测试(位于 androidTest 源代码集中)。

以下是与待办事项应用相关的端到端测试和集成测试之间的一些区别。E2E 测试:

  • 从第一个屏幕启动应用。
  • 创建实际的 activity 和仓库。
  • 测试多个 fragment 协同工作。

编写端到端测试很快就会变得复杂,因此有很多工具和库可以使其更容易。 Espresso 是一个常用的 Android 界面测试库,用于编写端到端测试。您在上一个 Codelab 中学习了 Espresso 的基本用法

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

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

第 1 步:关闭动画

对于 Espresso 界面测试,最佳实践是在实现任何其他内容之前关闭动画

  1. 在您的测试设备(实体设备或模拟器)上,前往设置 > 开发者选项
  2. 停用这三个设置:窗口动画缩放过渡动画缩放动画时长缩放

192483c9a6e83a0.png

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

  1. 第 2 步:创建 TasksActivityTest

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 用于等待所有 suspend 函数完成,然后继续执行代码块。请注意,由于存在一个错误,我们使用的是 runBlocking 而不是 runBlockingTest。
  • ActivityScenario 是一个AndroidX 测试库类,它封装了一个 activity,使您可以直接控制 activity 的生命周期进行测试。它类似于FragmentScenario
  • 使用 ActivityScenario 时,您使用launch 启动 activity,然后在测试结束时调用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 会在界面操作和由此产生的界面更改之间同步。例如,假设您让 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 应等待)的方法。

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

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

第 4 步:将空闲资源添加到您的 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 步:创建一个空闲资源单例

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

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

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

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

您在此处使用的是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。然后,它们会找到底层 Activity 并将其与 DataBindingIdlingResource 关联,这样您就可以跟踪布局状态了。您必须在测试中调用这两个方法中的一个,否则 DataBindingIdlingResource 将无法了解您的布局。

第 9 步:在测试中使用空闲资源

您已经创建了两个空闲资源,并确保它们被正确设置为忙碌或空闲状态。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() 测试,以便在启动 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...
    }
  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 Test 库并作为端到端测试的类添加适当的注解。
  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. 正确设置您的 DataBindingIdlingResource

现在添加代码。

  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 断言。

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

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_OpensNavigationtaskDetailScreen_doubleUpButton()taskDetailScreen_doubleBackButton
  2. 运行您的测试,并确认一切正常!

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

10. 解决方案代码

整个三个 Codelab 的最终代码 在此处

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

11. 总结

此 Codelab 涵盖了:

12. 了解更多

示例

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

Udacity 课程

Android 开发者文档

视频

其他

13. 下一个 Codelab

有关本课程中其他 Codelab 的链接,请参阅 Advanced Android in Kotlin codelabs 登陆页面