在 Android 上测试 Kotlin 协程

对使用 协程 的代码进行单元测试需要格外注意,因为它们的执行可能是异步的,且可能跨多个线程发生。本指南将介绍如何测试挂起函数、您需要熟悉的测试结构,以及如何使使用协程的代码更易于测试。

本指南中使用的 API 属于 kotlinx.coroutines.test 库。请确保将该构件作为测试依赖项 添加到您的项目中,以便使用这些 API。

dependencies {
    testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version"
}

在测试中调用挂起函数

若要在测试中调用挂起函数,您需要处于协程中。由于 JUnit 测试函数本身不是挂起函数,因此您需要在测试中调用协程构建器来启动一个新的协程。

runTest 是专为测试设计的协程构建器。请使用它来封装任何包含协程的测试。请注意,协程不仅可以直接在测试主体中启动,还可以由测试中使用的对象启动。

suspend fun fetchData(): String {
    delay(1000L)
    return "Hello world"
}

@Test
fun dataShouldBeHelloWorld() = runTest {
    val data = fetchData()
    assertEquals("Hello world", data)
}

通常,每个测试应调用一次 runTest,建议使用 表达式函数体

将测试代码包装在 runTest 中适用于测试基本的挂起函数,它会自动跳过协程中的任何延迟,使上述测试的完成速度比一秒钟快得多。

但是,根据被测代码中发生的情况,还需要考虑其他因素。

  • 当您的代码创建了除 runTest 创建的顶级测试协程之外的新协程时,您需要通过 选择合适的 TestDispatcher 来控制这些新协程的调度方式。
  • 如果您的代码将协程执行移动到其他调度程序(例如,通过使用 withContext),runTest 通常仍然有效,但延迟将不再被跳过,且由于代码在多个线程上运行,测试的可预测性会降低。因此,在测试中,您应该 注入测试调度程序 来替换真实调度程序。

TestDispatchers

TestDispatchers 是用于测试目的的 CoroutineDispatcher 实现。如果在测试期间创建了新协程,则需要使用 TestDispatchers 来使新协程的执行具有可预测性。

TestDispatcher 有两种可用的实现:StandardTestDispatcherUnconfinedTestDispatcher,它们对新启动的协程执行不同的调度。这两者都使用 TestCoroutineScheduler 来控制虚拟时间并管理测试中运行的协程。

一个测试中只能使用一个调度程序实例,由所有 TestDispatchers 共享。请参阅 注入 TestDispatchers 以了解共享调度程序的相关信息。

为了启动顶级测试协程,runTest 会创建一个 TestScope,这是 CoroutineScope 的一个实现,它将始终使用 TestDispatcher。如果未指定,TestScope 默认会创建一个 StandardTestDispatcher,并使用它来运行顶级测试协程。

runTest 会跟踪在其 TestScope 的调度程序上排队的协程,并且只要该调度程序上有待处理的工作,就不会返回。

StandardTestDispatcher

当您在 StandardTestDispatcher 上启动新协程时,它们会被排入底层调度程序,并在测试线程空闲时随时运行。要让这些新协程运行,您需要 让出(yield) 测试线程(将其释放供其他协程使用)。这种排队行为使您可以精确控制新协程在测试期间的运行方式,它类似于生产代码中协程的调度。

如果在顶级测试协程执行期间从未让出测试线程,则任何新协程仅会在测试协程完成后(但在 runTest 返回之前)运行。

@Test
fun standardTest() = runTest {
    val userRepo = UserRepository()

    launch { userRepo.register("Alice") }
    launch { userRepo.register("Bob") }

    assertEquals(listOf("Alice", "Bob"), userRepo.getAllUsers()) // ❌ Fails
}

有多种方法可以让出测试协程以允许排队的协程运行。所有这些调用在返回之前都会允许其他协程在测试线程上运行:

  • advanceUntilIdle:在调度程序上运行所有其他协程,直到队列中没有任何剩余内容。这是允许所有待处理协程运行的一个很好的默认选择,适用于大多数测试场景。
  • advanceTimeBy:将虚拟时间推进给定的量,并运行在该虚拟时间点之前预定运行的任何协程。
  • runCurrent:运行预定在当前虚拟时间运行的协程。

要修复之前的测试,可以使用 advanceUntilIdle 让两个待处理的协程在继续进行断言之前执行它们的工作。

@Test
fun standardTest() = runTest {
    val userRepo = UserRepository()

    launch { userRepo.register("Alice") }
    launch { userRepo.register("Bob") }
    advanceUntilIdle() // Yields to perform the registrations

    assertEquals(listOf("Alice", "Bob"), userRepo.getAllUsers()) // ✅ Passes
}

UnconfinedTestDispatcher

当在 UnconfinedTestDispatcher 上启动新协程时,它们会立即在当前线程上启动。这意味着它们将立即开始运行,而无需等待其协程构建器返回。在许多情况下,这种调度行为会产生更简单的测试代码,因为您无需手动让出测试线程来让新协程运行。

但是,这种行为与您在生产环境中使用非测试调度程序时所见的不同。如果您的测试关注并发性,请优先使用 StandardTestDispatcher

要使用此调度程序代替 runTest 中的默认调度程序来执行顶级测试协程,请创建一个实例并将其作为参数传递。这将使 runTest 内创建的新协程立即执行,因为它们继承了来自 TestScope 的调度程序。

@Test
fun unconfinedTest() = runTest(UnconfinedTestDispatcher()) {
    val userRepo = UserRepository()

    launch { userRepo.register("Alice") }
    launch { userRepo.register("Bob") }

    assertEquals(listOf("Alice", "Bob"), userRepo.getAllUsers()) // ✅ Passes
}

在此示例中,launch 调用将在 UnconfinedTestDispatcher 上立即启动它们的新协程,这意味着每个 launch 调用仅在注册完成后才会返回。

请记住,UnconfinedTestDispatcher 会立即启动新协程,但这并不意味着它也会立即将它们运行完毕。如果新协程挂起,其他协程将恢复执行。

例如,此测试中启动的新协程将注册 Alice,但随后在调用 delay 时挂起。这使得顶级协程可以继续进行断言,并且由于 Bob 尚未注册,测试失败。

@Test
fun yieldingTest() = runTest(UnconfinedTestDispatcher()) {
    val userRepo = UserRepository()

    launch {
        userRepo.register("Alice")
        delay(10L)
        userRepo.register("Bob")
    }

    assertEquals(listOf("Alice", "Bob"), userRepo.getAllUsers()) // ❌ Fails
}

注入测试调度程序

被测代码可能会使用调度程序来切换线程(使用 withContext)或启动新协程。当代码在多个线程上并行执行时,测试可能会变得不稳定。如果您无法控制正在运行的后台线程,那么在正确的时间执行断言或等待任务完成可能会很困难。

在测试中,请使用 TestDispatchers 的实例替换这些调度程序。这样做有几个好处:

  • 代码将在单个测试线程上运行,使测试更具确定性。
  • 您可以控制新协程的调度和执行方式。
  • TestDispatchers 使用调度程序来控制虚拟时间,这会自动跳过延迟,并允许您手动推进时间。

使用 依赖项注入 为您的类提供调度程序,可以轻松地在测试中替换真实调度程序。在这些示例中,我们将注入一个 CoroutineDispatcher,但您也可以注入更广泛的 CoroutineContext 类型,这在测试期间提供了更大的灵活性。

对于启动协程的类,您还可以注入一个 CoroutineScope 来代替调度程序,详见 注入作用域 部分。

TestDispatchers 默认会在实例化时创建一个新的调度程序。在 runTest 中,您可以访问 TestScopetestScheduler 属性,并将其传递给任何新创建的 TestDispatchers。这将共享它们对虚拟时间的理解,并且诸如 advanceUntilIdle 之类的方法将在所有测试调度程序上运行协程直到完成。

在以下示例中,您可以看到一个 Repository 类,它在其 initialize 方法中使用 IO 调度程序创建了一个新协程,并在其 fetchData 方法中将调用者切换到 IO 调度程序。

// Example class demonstrating dispatcher use cases
class Repository(private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO) {
    private val scope = CoroutineScope(ioDispatcher)
    val initialized = AtomicBoolean(false)

    // A function that starts a new coroutine on the IO dispatcher
    fun initialize() {
        scope.launch {
            initialized.set(true)
        }
    }

    // A suspending function that switches to the IO dispatcher
    suspend fun fetchData(): String = withContext(ioDispatcher) {
        require(initialized.get()) { "Repository should be initialized first" }
        delay(500L)
        "Hello world"
    }
}

在测试中,您可以注入一个 TestDispatcher 实现来替换 IO 调度程序。

在下面的示例中,我们将一个 StandardTestDispatcher 注入到存储库中,并使用 advanceUntilIdle 来确保在继续之前,在 initialize 中启动的新协程能够完成。

fetchDataTestDispatcher 上运行也会获益,因为它将在测试线程上运行,并在测试期间跳过其包含的延迟。

class RepositoryTest {
    @Test
    fun repoInitWorksAndDataIsHelloWorld() = runTest {
        val dispatcher = StandardTestDispatcher(testScheduler)
        val repository = Repository(dispatcher)

        repository.initialize()
        advanceUntilIdle() // Runs the new coroutine
        assertEquals(true, repository.initialized.get())

        val data = repository.fetchData() // No thread switch, delay is skipped
        assertEquals("Hello world", data)
    }
}

TestDispatcher 上启动的新协程可以如上面 initialize 的示例所示进行手动推进。但请注意,这在生产代码中既不可能也不可取。相反,此方法应重新设计为挂起函数(用于顺序执行),或者返回一个 Deferred 值(用于并发执行)。

例如,您可以使用 async 启动一个新协程并创建一个 Deferred

class BetterRepository(private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO) {
    private val scope = CoroutineScope(ioDispatcher)

    fun initialize() = scope.async {
        // ...
    }
}

这使您可以在测试和生产代码中安全地 await 此代码的完成。

@Test
fun repoInitWorks() = runTest {
    val dispatcher = StandardTestDispatcher(testScheduler)
    val repository = BetterRepository(dispatcher)

    repository.initialize().await() // Suspends until the new coroutine is done
    assertEquals(true, repository.initialized.get())
    // ...
}

runTest 在返回之前会等待待处理的协程完成(如果这些协程位于与它共享调度程序的 TestDispatcher 上)。它还会等待顶级测试协程的子协程,即使它们在其他调度程序上(直到 dispatchTimeoutMs 参数指定的超时时间,该参数默认为 60 秒)。

设置 Main 调度程序

本地单元测试 中,包装 Android UI 线程的 Main 调度程序将不可用,因为这些测试是在本地 JVM 上执行的,而不是在 Android 设备上。如果您的被测代码引用了主线程,它会在单元测试期间抛出异常。

在某些情况下,您可以像 上一节 中描述的那样,以相同的方式注入 Main 调度程序,从而允许您在测试中将其替换为 TestDispatcher。然而,某些 API(例如 viewModelScope)在底层使用了硬编码的 Main 调度程序。

以下是一个 ViewModel 实现示例,它使用 viewModelScope 来启动一个加载数据的协程。

class HomeViewModel : ViewModel() {
    private val _message = MutableStateFlow("")
    val message: StateFlow<String> get() = _message

    fun loadMessage() {
        viewModelScope.launch {
            _message.value = "Greetings!"
        }
    }
}

若要全面将 Main 调度程序替换为 TestDispatcher,请使用 Dispatchers.setMainDispatchers.resetMain 函数。

class HomeViewModelTest {
    @Test
    fun settingMainDispatcher() = runTest {
        val testDispatcher = UnconfinedTestDispatcher(testScheduler)
        Dispatchers.setMain(testDispatcher)

        try {
            val viewModel = HomeViewModel()
            viewModel.loadMessage() // Uses testDispatcher, runs its coroutine eagerly
            assertEquals("Greetings!", viewModel.message.value)
        } finally {
            Dispatchers.resetMain()
        }
    }
}

如果 Main 调度程序已被替换为 TestDispatcher,则任何新创建的 TestDispatchers 将自动使用 Main 调度程序中的调度程序,包括如果未向其传递其他调度程序时由 runTest 创建的 StandardTestDispatcher

这使得更容易确保测试期间仅使用单个调度程序。为了使其生效,请确保在调用 Dispatchers.setMain 之后 创建所有其他 TestDispatcher 实例。

为避免在每个测试中重复替换 Main 调度程序的代码,一种常见的模式是将其提取到 JUnit 测试规则 中。

// Reusable JUnit4 TestRule to override the Main dispatcher
class MainDispatcherRule(
    val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
    override fun starting(description: Description) {
        Dispatchers.setMain(testDispatcher)
    }

    override fun finished(description: Description) {
        Dispatchers.resetMain()
    }
}

class HomeViewModelTestUsingRule {
    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    @Test
    fun settingMainDispatcher() = runTest { // Uses Main’s scheduler
        val viewModel = HomeViewModel()
        viewModel.loadMessage()
        assertEquals("Greetings!", viewModel.message.value)
    }
}

此规则实现默认使用 UnconfinedTestDispatcher,但如果 Main 调度程序在特定的测试类中不应立即执行,则可以将 StandardTestDispatcher 作为参数传入。

当您在测试主体中需要 TestDispatcher 实例时,只要它是所需的类型,就可以重用该规则中的 testDispatcher。如果您想显式指定测试中使用的 TestDispatcher 类型,或者如果需要与用于 Main 的类型不同的 TestDispatcher,则可以在 runTest 内创建一个新的 TestDispatcher。由于 Main 调度程序已设置为 TestDispatcher,任何新创建的 TestDispatchers 将自动共享其调度程序。

class DispatcherTypesTest {
    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    @Test
    fun injectingTestDispatchers() = runTest { // Uses Main’s scheduler
        // Use the UnconfinedTestDispatcher from the Main dispatcher
        val unconfinedRepo = Repository(mainDispatcherRule.testDispatcher)

        // Create a new StandardTestDispatcher (uses Main’s scheduler)
        val standardRepo = Repository(StandardTestDispatcher())
    }
}

在测试外部创建调度程序

在某些情况下,您可能需要在测试方法之外使用 TestDispatcher。例如,在测试类中初始化属性期间。

class ExampleRepository(private val ioDispatcher: CoroutineDispatcher) { /* ... */ }

class RepositoryTestWithRule {
    private val repository = ExampleRepository(/* What TestDispatcher? */)

    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    @Test
    fun someRepositoryTest() = runTest {
        // Test the repository...
        // ...
    }
}

如果您如上一节所述替换了 Main 调度程序,则在 Main 调度程序被替换 之后 创建的 TestDispatchers 将自动共享其调度程序。

但是,对于作为测试类属性创建的 TestDispatchers,或者在测试类中的属性初始化期间创建的 TestDispatchers 并非如此。它们在 Main 调度程序被替换之前初始化。因此,它们会创建新的调度程序。

为了确保您的测试中只有一个调度程序,请先创建 MainDispatcherRule 属性。然后根据需要,在其他类级属性的初始化程序中重用其调度程序(或者,如果您需要不同类型的 TestDispatcher,则重用其调度程序实例)。

class RepositoryTestWithRule {
    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    private val repository = ExampleRepository(mainDispatcherRule.testDispatcher)

    @Test
    fun someRepositoryTest() = runTest { // Takes scheduler from Main
        // Any TestDispatcher created here also takes the scheduler from Main
        val newTestDispatcher = StandardTestDispatcher()

        // Test the repository...
    }
}

请注意,runTest 和在测试内创建的 TestDispatchers 仍然会自动共享 Main 调度程序的调度程序。

如果您没有替换 Main 调度程序,请将您的第一个 TestDispatcher(它会创建一个新的调度程序)创建为该类的属性。然后,手动将该调度程序传递给每个 runTest 调用以及每个新创建的 TestDispatcher(无论是作为属性还是在测试内部)。

class RepositoryTest {
    // Creates the single test scheduler
    private val testDispatcher = UnconfinedTestDispatcher()
    private val repository = ExampleRepository(testDispatcher)

    @Test
    fun someRepositoryTest() = runTest(testDispatcher.scheduler) {
        // Take the scheduler from the TestScope
        val newTestDispatcher = UnconfinedTestDispatcher(this.testScheduler)
        // Or take the scheduler from the first dispatcher, they’re the same
        val anotherTestDispatcher = UnconfinedTestDispatcher(testDispatcher.scheduler)

        // Test the repository...
    }
}

在此示例中,来自第一个调度程序的调度程序被传递给 runTest。这将为使用该调度程序的 TestScope 创建一个新的 StandardTestDispatcher。您也可以将调度程序直接传递给 runTest,以在该调度程序上运行测试协程。

创建您自己的 TestScope

TestDispatchers 一样,您可能需要在测试主体之外访问 TestScope。虽然 runTest 会在底层自动创建一个 TestScope,但您也可以创建自己的 TestScope 以与 runTest 一起使用。

执行此操作时,请确保在您创建的 TestScope 上调用 runTest

class SimpleExampleTest {
    val testScope = TestScope() // Creates a StandardTestDispatcher

    @Test
    fun someTest() = testScope.runTest {
        // ...
    }
}

上面的代码隐式地为 TestScope 创建了一个 StandardTestDispatcher,以及一个新的调度程序。这些对象也可以显式创建。如果您需要将其与依赖项注入设置集成,这将非常有用。

class ExampleTest {
    val testScheduler = TestCoroutineScheduler()
    val testDispatcher = StandardTestDispatcher(testScheduler)
    val testScope = TestScope(testDispatcher)

    @Test
    fun someTest() = testScope.runTest {
        // ...
    }
}

注入作用域

如果您有一个类创建了您需要在测试期间控制的协程,您可以向该类注入一个协程作用域,并在测试中将其替换为 TestScope

在以下示例中,UserState 类依赖于 UserRepository 来注册新用户并获取已注册用户列表。由于这些对 UserRepository 的调用是挂起函数调用,UserState 使用注入的 CoroutineScope 在其 registerUser 函数内启动一个新协程。

class UserState(
    private val userRepository: UserRepository,
    private val scope: CoroutineScope,
) {
    private val _users = MutableStateFlow(emptyList<String>())
    val users: StateFlow<List<String>> = _users.asStateFlow()

    fun registerUser(name: String) {
        scope.launch {
            userRepository.register(name)
            _users.update { userRepository.getAllUsers() }
        }
    }
}

要测试此类,您可以在创建 UserState 对象时传入来自 runTestTestScope

class UserStateTest {
    @Test
    fun addUserTest() = runTest { // this: TestScope
        val repository = FakeUserRepository()
        val userState = UserState(repository, scope = this)

        userState.registerUser("Mona")
        advanceUntilIdle() // Let the coroutine complete and changes propagate

        assertEquals(listOf("Mona"), userState.users.value)
    }
}

若要在测试函数外部注入作用域(例如注入到作为测试类属性创建的被测对象中),请参阅 创建您自己的 TestScope

其他资源