在 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 上启动的,则这些协程会在底层调度器上排队,以便在测试线程可供使用时运行。若要让这些新协程运行,您需要“让出”测试线程(将其释放出来,以供其他协程使用)。这种排队行为可让您精确控制测试期间新协程的运行方式,类似于正式版代码中的协程调度。

如果在顶级测试协程执行期间未让出测试线程,那么所有新协程都只会在测试协程完成后(但在 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 中启动的新协程运行完成,然后再继续后续操作。

TestDispatcher 上运行对 fetchData 也有好处,因为它将在测试线程上运行,测试期间还能跳过它所包含的延迟。

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

如果协程位于与其共用一个调度器的 TestDispatcher 上,那么 runTest 会等待待处理的协程完成后再返回。它还会等待顶级测试协程的子级协程,即使这些协程位于其他调度程序上也是如此(最长可等待 dispatchTimeoutMs 参数指定的超时时长,默认为 60 秒)。

设置主调度程序

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

在某些情况下,您可以像注入其他调度程序一样注入 Main 调度程序(如上一部分中所述),从而让您可以在测试中将其替换为 TestDispatcher。不过,有些 API(如 viewModelScope)会在后台使用硬编码的 Main 调度程序。

下面的示例展示了使用 viewModelScope 启动用于加载数据的协程的 ViewModel 实现:

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 Repository(private val ioDispatcher: CoroutineDispatcher) { /* ... */ }

class RepositoryTestWithRule {
    private val repository = Repository(/* 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 = Repository(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...
    }
}

请注意,在测试中创建的 runTestTestDispatchers 仍将自动共用 Main 调度程序的调度器。

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

class RepositoryTest {
    // Creates the single test scheduler
    private val testDispatcher = UnconfinedTestDispatcher()
    private val repository = Repository(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 对象时从 runTest 传入 TestScope

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

其他资源