使用 协程 的代码的单元测试需要格外注意,因为它们的执行可能是异步的,并且可能跨多个线程发生。本指南介绍了如何测试挂起函数、需要熟悉的测试构造以及如何使使用协程的代码可测试。
本指南中使用的 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
中将适用于测试基本的挂起函数,并且它会自动跳过协程中的任何延迟,从而使上述测试完成速度比 1 秒快得多。
但是,根据被测代码中的内容,还需要考虑其他因素。
- 当您的代码创建除
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 }
有几种方法可以使测试协程让步以让排队的协程运行。所有这些调用都允许其他协程在返回之前在测试线程上运行
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
调度程序。
这是一个使用 viewModelScope
启动加载数据的协程的 ViewModel
实现示例
class HomeViewModel : ViewModel() { private val _message = MutableStateFlow("") val message: StateFlow<String> get() = _message fun loadMessage() { viewModelScope.launch { _message.value = "Greetings!" } } }
要始终用 TestDispatcher
替换 Main
调度程序,请使用 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。
其他资源
- 在 Android 上测试 Kotlin 流
- kotlinx.coroutines.test 在 GitHub 上