对使用 协程 的代码进行单元测试需要格外注意,因为它们的执行可能是异步的,且可能跨多个线程发生。本指南将介绍如何测试挂起函数、您需要熟悉的测试结构,以及如何使使用协程的代码更易于测试。
本指南中使用的 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 有两种可用的实现:StandardTestDispatcher 和 UnconfinedTestDispatcher,它们对新启动的协程执行不同的调度。这两者都使用 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 中,您可以访问 TestScope 的 testScheduler 属性,并将其传递给任何新创建的 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 中启动的新协程能够完成。
fetchData 在 TestDispatcher 上运行也会获益,因为它将在测试线程上运行,并在测试期间跳过其包含的延迟。
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.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() } } }
如果 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 对象时传入来自 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。