はじめに
Coroutine を使った Android 開発において、次のようなコードを書いていませんか?
class MyViewModel : ViewModel() {
fun fetchData() {
viewModelScope.launch(Dispatchers.IO) {
// ネットワーク処理など
}
}
}
一見問題なさそうに見えますが、この書き方には テストが困難になる という大きな落とし穴があります。
本記事では、Hilt を使って CoroutineDispatcher を DI(依存性注入)で管理し、テスタブルな設計にする方法を紹介します。
なぜ Dispatchers.IO を直書きするとテストが困難なのか
Dispatchers.IO をコード内に直接ハードコードすると、Unit テスト時に以下の問題が生じます。
- テスト中でも本番用のスレッドプール(IO スレッドなど)が使われてしまう
-
TestDispatcher(UnconfinedTestDispatcher/StandardTestDispatcher)に差し替えられない -
runTestの時間制御やadvanceUntilIdle()などのテスト制御 API が効かない
// ❌ テストしにくいコード
class MyViewModel : ViewModel() {
fun loadUser() {
viewModelScope.launch(Dispatchers.IO) { // ← 直書き。テストで差し替え不可
val user = repository.getUser()
_uiState.value = user
}
}
}
テストで Dispatcher を制御するには、外部から注入できる形 にする必要があります。
Hilt で CoroutineDispatcher を管理する
1. Qualifier の定義と Hilt Module を同じファイルにまとめる
@Qualifier アノテーションと @Module を 1 つのファイル CoroutinesModule.kt にまとめて定義します。
// di/CoroutinesModule.kt
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import javax.inject.Qualifier
import javax.inject.Singleton
// --- Qualifier アノテーション ---
@Retention(AnnotationRetention.BINARY)
@Qualifier
annotation class IoDispatcher
@Retention(AnnotationRetention.BINARY)
@Qualifier
annotation class MainDispatcher
@Retention(AnnotationRetention.BINARY)
@Qualifier
annotation class DefaultDispatcher
// --- Hilt Module ---
@Module
@InstallIn(SingletonComponent::class)
object CoroutinesModule {
@IoDispatcher
@Singleton
@Provides
fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
@MainDispatcher
@Singleton
@Provides
fun provideMainDispatcher(): CoroutineDispatcher = Dispatchers.Main
@DefaultDispatcher
@Singleton
@Provides
fun provideDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
}
@Qualifier と @Module を同じファイルに置くことで、Dispatcher に関する定義が 1 箇所に集約され、見通しが良くなります。
| アノテーション | 提供する Dispatcher | 主な用途 |
|---|---|---|
@IoDispatcher |
Dispatchers.IO |
ネットワーク・DB・ファイルI/O |
@MainDispatcher |
Dispatchers.Main |
UI 更新 |
@DefaultDispatcher |
Dispatchers.Default |
CPU負荷の高い演算 |
参考: Hilt 公式ドキュメント — Qualifier / Android デベロッパー — Hilt を使用した依存関係の注入
3. ViewModel で注入して使う
@HiltViewModel と @Inject constructor で Dispatcher を受け取ります。
// ui/MyViewModel.kt
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class MyViewModel @Inject constructor(
private val repository: UserRepository,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher
) : ViewModel() {
fun loadUser() {
viewModelScope.launch(ioDispatcher) { // ✅ 外部から注入。テストで差し替え可能
val user = repository.getUser()
_uiState.value = user
}
}
}
4. UseCase / Repository でも同様に注入する
ViewModel に限らず、UseCase や Repository でも同じパターンを適用できます。
// domain/GetUserUseCase.kt
class GetUserUseCase @Inject constructor(
private val repository: UserRepository,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher
) {
suspend operator fun invoke(userId: String) = withContext(ioDispatcher) {
repository.getUser(userId)
}
}
テストで Dispatcher を差し替える
Hilt で注入する形にしたことで、テスト時に TestDispatcher を渡すだけで制御できるようになります。
// test/MyViewModelTest.kt
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class MyViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var viewModel: MyViewModel
@Before
fun setup() {
viewModel = MyViewModel(
repository = FakeUserRepository(),
ioDispatcher = testDispatcher // ✅ テスト用 Dispatcher を注入
)
}
@Test
fun `loadUser が正常に UI を更新する`() = runTest(testDispatcher) {
viewModel.loadUser()
testDispatcher.scheduler.advanceUntilIdle()
// assert ...
}
}
参考: Android デベロッパー — Kotlin Coroutines のテスト / kotlinx-coroutines-test
ディレクトリ構成まとめ
app/
└── src/
├── main/
│ └── java/com/example/
│ ├── di/
│ │ └── CoroutinesModule.kt ← @Qualifier 定義 / @Module / @Provides
│ ├── ui/
│ │ └── MyViewModel.kt ← @IoDispatcher で注入
│ └── domain/
│ └── GetUserUseCase.kt ← @IoDispatcher で注入
└── test/
└── java/com/example/
└── ui/
└── MyViewModelTest.kt ← TestDispatcher を注入
まとめ
| Dispatcher 直書き | Hilt で DI | |
|---|---|---|
| テストの容易さ | ❌ テストで差し替えできない | ✅ TestDispatcher を注入できる |
| 依存の明示性 | ❌ 暗黙的 | ✅ コンストラクタで明示 |
| 保守性 | ❌ 変更箇所が散在する | ✅ Module 1 箇所を変えるだけ |
Dispatchers.IO をハードコードするのはお手軽ですが、テスタビリティと保守性のために Hilt で一元管理する パターンに切り替えることを強くおすすめします。