0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Android】HiltでCoroutineDispatcherを一元管理してテスタブルな設計にする

0
Posted at

はじめに

Coroutine を使った Android 開発において、次のようなコードを書いていませんか?

class MyViewModel : ViewModel() {
    fun fetchData() {
        viewModelScope.launch(Dispatchers.IO) {
            // ネットワーク処理など
        }
    }
}

一見問題なさそうに見えますが、この書き方には テストが困難になる という大きな落とし穴があります。

本記事では、Hilt を使って CoroutineDispatcher を DI(依存性注入)で管理し、テスタブルな設計にする方法を紹介します。


なぜ Dispatchers.IO を直書きするとテストが困難なのか

Dispatchers.IO をコード内に直接ハードコードすると、Unit テスト時に以下の問題が生じます。

  • テスト中でも本番用のスレッドプール(IO スレッドなど)が使われてしまう
  • TestDispatcherUnconfinedTestDispatcher / 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 アノテーションと @Module1 つのファイル 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 で一元管理する パターンに切り替えることを強くおすすめします。


参考資料

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?