对使用 协程 的代码进行单元测试需要格外注意,因为它们的执行可能是异步的,并且会跨多个线程发生。本指南介绍了如何测试挂起函数、您需要熟悉的测试结构以及如何使使用协程的代码可测试。
本指南中使用的 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
上启动新协程时,它们会在底层调度器上排队,以便在测试线程空闲时运行。要让这些新协程运行,您需要让出测试线程(释放它以供其他协程使用)。这种排队行为让您可以精确控制新协程在测试期间的运行方式,并且它类似于生产代码中协程的调度。
如果在顶层测试协程执行期间从未让出测试线程,则任何新协程只会在测试协程完成后(但在 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
类型不同的 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。