為什麼 Main 執行緒(Main Thread)需要特別處理#

在 Android 開發中,許多 ViewModel、UI 邏輯會直接或間接使用 Dispatchers.Main,例如:

viewModelScope.launch {
    // 預設跑在 Dispatchers.Main
    val data = repository.load()
    _state.value = data
}

但是當我們在 JVM 單元測試(不是 instrumented test)執行這段程式時,Dispatchers.Main 會無法初始化,因為它依賴 Android Looper,導致拋出例外:

Module with the Main dispatcher is missing

為了解決這個問題,kotlinx-coroutines-test 提供了兩個 API:

  • Dispatchers.setMain(dispatcher)
  • Dispatchers.resetMain()

setMain / resetMain 的用法#

基本流程:

  • 在每一個測試開始前(@Before),用 Dispatchers.setMain(...) 把 Main 替換成 TestDispatcher。
  • 在每一個測試結束後(@After),呼叫 Dispatchers.resetMain() 還原。
@OptIn(ExperimentalCoroutinesApi::class)
class MyViewModelTest {

    private val dispatcher = StandardTestDispatcher()

    @Before
    fun setUp() {
        Dispatchers.setMain(dispatcher)
    }

    @After
    fun tearDown() {
        Dispatchers.resetMain()
    }

    @Test
    fun loadTest() = runTest(dispatcher) {
        val viewModel = MyViewModel()
        viewModel.load()
        advanceUntilIdle()
        assertEquals("loaded", viewModel.state)
    }
}

要點:

  • setMain 接收的 dispatcher 通常是 StandardTestDispatcher 或 UnconfinedTestDispatcher。
  • runTest(dispatcher) 把 runTest 的 scope 綁到同一個 dispatcher,避免內外時鐘不同步。
  • 若用 JUnit 5,可改用 @BeforeEach / @AfterEach

抽出 JUnit Rule 重複利用#

每個測試類別都寫一次 setUp / tearDown 太繁瑣,可以抽成 JUnit 4 的 TestRule:

@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherRule(
    val dispatcher: TestDispatcher = UnconfinedTestDispatcher()
) : TestWatcher() {
    override fun starting(description: Description) {
        Dispatchers.setMain(dispatcher)
    }

    override fun finished(description: Description) {
        Dispatchers.resetMain()
    }
}

使用方式:

class MyViewModelTest {

    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    @Test
    fun loadTest() = runTest {
        val viewModel = MyViewModel()
        viewModel.load()
        advanceUntilIdle()
        assertEquals("loaded", viewModel.state)
    }
}

Google 在 Android 官方範例專案(例如 architecture-samples、nowinandroid)中也使用類似的 MainDispatcherRule 模式,已是事實上的標準寫法。

在 ViewModel 中保持可測試性#

若 ViewModel 內部直接寫死 Dispatchers.IODispatchers.Default,外部測試就難以替換。建議:

  • 透過建構式注入 dispatcher(或 CoroutineDispatcher provider)。
  • 預設值用 production 用的 dispatcher,測試時注入 TestDispatcher。
class MyViewModel(
    private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
    fun load() {
        viewModelScope.launch(ioDispatcher) {
            // ...
        }
    }
}

測試時:

@Test
fun loadTest() = runTest {
    val viewModel = MyViewModel(ioDispatcher = StandardTestDispatcher(testScheduler))
    viewModel.load()
    advanceUntilIdle()
    // ...
}

常見坑#

  • 忘記呼叫 Dispatchers.resetMain(),導致下一個測試的 Main 仍指向已關閉的 dispatcher。
  • 在多個測試間共用同一個 TestDispatcher 卻沒有重置 scheduler,可能導致時間累加錯亂。
  • setMain 之後又使用 Dispatchers.Main.immediate,行為會與 Dispatchers.Main 一致(皆指向被替換後的 TestDispatcher),這是預期行為。

小結#

  • JVM 單元測試中無法直接使用 Dispatchers.Main,必須用 Dispatchers.setMain 替換。
  • 測試結束後務必呼叫 Dispatchers.resetMain 還原。
  • 用 @Before / @After 或自訂 JUnit Rule 來自動化處理。
  • 將 dispatcher 設計成可注入,是維持 ViewModel 可測試性的最佳實踐。

原文出處#

  • 原文/iThome:https://ithelp.ithome.com.tw/articles/10276586