在 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 共用。如要瞭解共用排程器,請參閱插入 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 中的頂層測試協同程式而非使用預設選項,請建立執行個體,並將其做為參數傳遞。如此一來,由於會沿用 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 的執行個體。這麼做有幾個好處:

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

使用依附元件插入功能為類別提供調度工具,即可讓在測試中取代真正的調度工具變得更簡單。

根據預設,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 {
        // ...
    }
}

其他資源