為什麼 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.IO 或 Dispatchers.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