在 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)
}<div>SuspendingFunctionTests.kt
</div>

一般情况下,每个测试都应包含一个 runTest 调用,建议使用表达式主体

如果要测试基本挂起函数,可以将测试代码封装在 runTest 中,这样可自动跳过协程中的任何延迟,从而使上述测试只需不到一秒种的时间即可完成。

不过,根据被测试代码的具体情况,您还需考虑其他事项:

  • runTest 创建的顶级测试协程之外,如果您的代码还创建了新的协程,则您需要 选择适当的 TestDispatcher ,以控制这些新协程的调度方式。
  • 如果您的代码将协程执行移至其他调度程序(例如,通过使用 withContext ),runTest 通常仍可正常运行,但不会再跳过延迟,并且由于代码会在多个线程上运行,测试的可预测性将降低。出于这些原因,您应在测试中注入测试调度程序 ,以替换实际调度程序。

TestDispatchers

TestDispatchers 是用于测试的 CoroutineDispatcher 实现。如果要在测试期间创建新的协程,您需要使用 TestDispatchers ,以使新协程的执行可预测。

TestDispatcher 有两种可用的实现: StandardTestDispatcher UnconfinedTestDispatcher ,可分别对新启动的协程执行不同的调度。两者都使用 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
}<div>StandardTestDispatcherTest.kt
</div>

您可以通过多种方式让出测试协程,从而使排队的协程运行。所有以下调用都可在返回之前让其他协程在测试线程上运行:

  • 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
}<div>StandardTestDispatcherTest.kt
</div>

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
}<div>UnconfinedTestDispatcherTest.kt
</div>

在此示例中,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
}<div>UnconfinedTestDispatcherTest.kt
</div>

注入测试调度程序

被测试代码可能会使用调度程序来切换线程(使用 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"
    }
}<div>Repository.kt
</div>

在测试中,您可以注入 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)
    }
}<div>RepositoryTest.kt
</div>

TestDispatcher 上启动的新协程可以手动提前,如上方的 initialize 所示。但请注意,这种做法在正式版代码中不可行或不可取。相反,此方法应重新设计为挂起(用于依序执行)或返回 Deferred 值(用于并发执行)。

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

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

    fun initialize() = scope.async {
        // ...
    }
}<div>Repository.kt
</div>

这样您就可以安全地在测试代码和正式版代码中 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())
    // ...
}<div>RepositoryTest.kt
</div>

如果协程位于与其共用一个调度器的 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!"
        }
    }
}<div>HomeViewModel.kt
</div>

如需在所有情况下都将 Main 调度程序替换为 TestDispatcher ,请使用 Dispatchers.setMain Dispatchers.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()
        }
    }
}<div>HomeViewModelTest.kt
</div>

如果 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)
    }
}<div>HomeViewModelTestUsingRule.kt
</div>

此规则实现默认使用 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())
    }
}<div>DispatcherTypesTest.kt
</div>

在测试之外创建调度器

在某些情况下,您可能需要在测试方法之外使用 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...
        // ...
    }
}<div>DispatchersOutsideTests.kt
</div>

如果您要替换上一部分中显示的 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...
    }
}<div>DispatchersOutsideTests.kt
</div>

请注意,在测试中创建的 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...
    }
}<div>DispatchersOutsideTests.kt
</div>

在此示例中,第一个调度程序的调度器将传递给 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 {
        // ...
    }
}<div>CreatingYourOwn.kt
</div>

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

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

    @Test
    fun someTest() = testScope.runTest {
        // ...
    }
}<div>CreatingYourOwn.kt
</div>

注入作用域

如果有类创建需要您在测试期间控制的协程,则可以将协程作用域注入到该类中,并在测试中将其替换为 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() }
        }
    }
}<div>UserState.kt
</div>

如需测试此类,您可以在创建 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)
    }
}<div>UserStateTest.kt
</div>

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

其他资源

本页面上的内容和代码示例受内容许可 部分所述许可的限制。Java 和 OpenJDK 是 Oracle 和/或其关联公司的注册商标。

最后更新时间 (UTC):2024-08-29。