在 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)
}

一般來說,每項測試都應包含一種 runTest 叫用,建議使用運算式主體

如要測試基本的暫停函式,可以將測試的程式碼納入 runTest,這麼做可自動略過協同程式中任何延遲情形,使上述測試只需不到一秒即可完成。

不過,視測試中程式碼的情況而定,還需要考量其他事項:

  • 當程式碼建立由 runTest 所建立的頂層測試協同程式以外的新協同程式時,您必須透過選擇適當的 TestDispatcher 來控制這些新協同程式的時程安排。
  • 如果程式碼將協同程式執行作業移動至其他調度工具 (例如透過使用 withContext),runTest 一般來說仍可正常運作,但不會再略過延遲,且由於程式碼可在多個執行緒上執行,測試的可預測性會降低。基於上述原因,在測試中應該插入測試調度工具,以取代真正的調度工具。

TestDispatchers

TestDispatchersCoroutineDispatcher 用於測試的實作。如果在測試期間建立新協同程式以利新協同程式的執行作業可供預測,則必須使用 TestDispatchers

TestDispatcher 提供兩種實作方式:StandardTestDispatcherUnconfinedTestDispatcher,可分別對新啟動的協同程式執行不同的排程。兩者都使用 TestCoroutineScheduler 來控制虛擬時間,以及在測試中管理執行中的協同程式。

測試中只能使用一個排程器執行個體,由所有 TestDispatchers 共用。如要瞭解共用排程器,請參閱「插入 TestDispatcher」。

如要啟動頂層測試協同程式,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 中的頂層測試協同程式而非使用預設選項,請建立執行個體,並將其做為參數傳遞。如此一來,由於會沿用 TestScope 的調度工具,在 runTest 中建立的新協同程式就可以快速執行。

@Test
fun unconfinedTest() = runTest(UnconfinedTestDispatcher()) {
    val userRepo = UserRepository()

    launch { userRepo.register("Alice") }
    launch { userRepo.register("Bob") }

    assertEquals(listOf("Alice", "Bob"), userRepo.getAllUsers()) // ✅ Passes
}

在此範例中,啟動呼叫會導致在 UnconfinedTestDispatcher 上快速啟動新的協同程式,這表示要啟動的每個呼叫都只會在註冊完成後傳回。

請記住,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 的例項。這麼做有幾個好處:

  • 程式碼會在單一測試執行緒上執行,使測試更具確定性
  • 可以控制新協同程式的排定和執行方式
  • TestDispatcher 會使用排程器安排虛擬時間,可自動略過延遲,讓您可以手動將時間提前

使用依附元件插入功能為類別提供調度工具,即可更輕鬆在測試中取代真正的調度工具。在這些範例中,我們會插入 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"
    }
}

在測試中可以插入 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 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!"
        }
    }
}

如要在所有情況下將 Main 調度工具取代為 TestDispatcher,請使用 Dispatchers.setMainDispatchers.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 建立新的 TestDispatcherMain 調度工具設定為 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...
    }
}

請注意,在測試中建立的 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...
    }
}

在此範例中,第一位調度工具的排程器會傳送至 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」。

其他資源