需要特别留意使用协程的单元测试代码,因为其执行可能是异步的,并且可能发生在多个线程中。本指南将介绍如何测试挂起函数、您需要熟悉的测试结构,以及如何让使用协程的代码可测试。
本指南中所用的 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
中启动的新协程运行完成,然后再继续后续操作。
在 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.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 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... } }
请注意,在测试中创建的 runTest
和 TestDispatchers
仍将自动共用 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。