結論
今回の焦点は非同期テストを速く・安定させる設計です。
前の記事で書いたように、
- Dispatcherは直書きせず外から渡す(本番は Dispatchers.IO、テストは StandardTestDispatcher)
- Repositoryは“main-safe”(呼び出し側はスレッドを気にせず呼べる)
この二つを徹底すれば、テストは仮想時間で処理の終わりを正確にコントロールできます。その結果、非同期テストは速く・安定して・読みやすいものになります。
この記事で解決すること
よく考えずに実装すると、RepositoryやViewModelの中に withContext(Dispatchers.IO)を直接書いてしまいがちです。その結果、次のような困りごとが起こります。
-
テストが遅い・不安定
- 実際に待ち時間やI/Oが走り、テストがマシンや環境に左右される
-
非同期の終わりが読めない
- 処理が終わる前に検証を始めてしまい、テストが通ったり落ちたりする
-
設計がごちゃつく
- UIやUseCaseにI/Oの責務がにじみ出て、境界が不明瞭になる
1) RepositoryにDispatchers.IOベタ書き
BAD
class UserRepository(private val api: UserApi) {
suspend fun fetchUser(id: String): User = withContext(Dispatchers.IO) {
delay(1000) // 実時間で待つ → テストが遅い/揺れる
api.getUser(id) // 実I/O直結
}
}
GOOD(Dispatcherを引数で注入・デフォルトは本番用)
class UserRepository(
private val api: UserApi,
private val io: CoroutineDispatcher = Dispatchers.IO
) {
suspend fun fetchUser(id: String): User = withContext(io) {
api.getUser(id)
}
}
Repositoryのテスト(仮想時間で確定的に動かす)
class FakeUserApi : UserApi { override suspend fun getUser(id: String) = User(id, "momo") }
@Test
fun repository_is_controllable_by_virtual_time() = runTest {
val repository = UserRepository(FakeUserApi(), StandardTestDispatcher(testScheduler))
var result: User? = null
launch { result = repository.fetchUser("42") }
// まだ未完了
assert(result == null)
// 仮想時間で収束
advanceUntilIdle()
assertEquals("42", result?.id)
}
ポイント
- Dispatcherを直書きせず依存注入することで、本番ではIO、テストでは仮想時間のDispatcherを切り替えて利用することができます
- これにより、実際のI/Oや実時間の待ちを避け、Repositoryの処理を確実かつ高速にテストすることができます
2) ViewModelにDispatcherを漏らしてしまう
BAD
class UserViewModel(private val repository: UserRepository) : ViewModel() {
private val _uiState = MutableStateFlow<UserUiState>(UserUiState.Loading)
val uiState = _uiState.asStateFlow()
fun load(id: String) {
viewModelScope.launch(Dispatchers.IO) { // UI層にI/Oが漏れている
val user = repository.fetchUser(id)
_uiState.value = UserUiState.Success(user) // スレッド境界が曖昧
}
}
}
GOOD(UIは“main-safe”に呼ぶだけ)
class UserViewModel(private val repository: UserRepository) : ViewModel() {
private val _uiState = MutableStateFlow<UserUiState>(UserUiState.Loading)
val uiState = _uiState.asStateFlow()
fun load(id: String) {
viewModelScope.launch {
_uiState.value = UserUiState.Loading
runCatching { repository.fetchUser(id) } // main-safe(I/O切替はRepository側)
.onSuccess { _uiState.value = UserUiState.Success(it) }
.onFailure { _uiState.value = UserUiState.Error(it) }
}
}
}
ViewModelのテスト(Mainを差し替え、仮想時間で収束させる)
@OptIn(ExperimentalCoroutinesApi::class)
class UserViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule() // Dispatchers.Main を TestDispatcher に差し替え
@Test
fun viewmodel_updates_state_without_sleep() = runTest(mainDispatcherRule.dispatcher.scheduler) {
// Main も IO も同一の TestDispatcher に統一して、仮想時間を一元管理
val repository = UserRepository(FakeUserApi(), mainDispatcherRule.dispatcher)
val viewModel = UserViewModel(repository)
viewModel.load("42")
advanceUntilIdle() // 仮想時間で全タスクを完了させる
val state = viewModel.uiState.value
require(state is UserUiState.Success)
assertEquals("42", state.user.id)
}
}
// Main をテスト用に差し替える Rule(最小実装)
@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherRule(
val dispatcher: TestDispatcher = StandardTestDispatcher()
) : TestWatcher() {
override fun starting(description: Description) { Dispatchers.setMain(dispatcher) }
override fun finished(description: Description) { Dispatchers.resetMain() }
}
// Fake(非I/O・仮想時間で進められる)
class FakeUserApi : UserApi {
override suspend fun getUser(id: String): User {
delay(1000) // 仮想時間なので advanceUntilIdle で即時収束
return User(id, "name")
}
}
ポイント
- UI層にDispatcherを漏らさない
- I/Oの切り替えはRepositoryに閉じ込め、ViewModelはmain-safeに呼ぶだけ
- Mainをテスト用に差し替える
- viewModelScopeが利用するDispatchers.Mainを、MainDispatcherRuleでTestDispatcherに置き換える
- 仮想時間で制御する
- runTestとadvanceUntilIdle()により、Main・IOの処理をまとめて収束させる
3) テストで Thread.sleep(...) に頼ってしまう
BAD
@Test
fun load_user_bad() = runBlocking {
val repository = UserRepository(realApi /* 本物 */, Dispatchers.IO)
val viewModel = UserViewModel(repository)
viewModel.load("42")
Thread.sleep(1200) // 勘で寝かす → 通ったり落ちたり
assertEquals("42", (viewModel.uiState.value as UserUiState.Success).user.id)
}
GOOD(runTest + StandardTestDispatcherで時間を握る)
@Test
fun load_user_good() = runTest {
val repository = UserRepository(FakeUserApi(), StandardTestDispatcher(testScheduler))
val viewModel = UserViewModel(repository)
viewModel.load("42")
advanceUntilIdle() // これで十分
val uiState = viewModel.uiState.value
require(uiState is UserUiState.Success)
assertEquals("42", uiState.user.id)
}
class FakeUserApi : UserApi {
override suspend fun getUser(id: String): User {
delay(1000) // 仮想時間なので即進められる
return User(id, "momo")
}
}
ポイント
- FakeApiとStandardTestDispatcherを使うことで、実際の通信や実時間の待ちに縛られず、仮想時間を制御しながらViewModelのロジックだけに集中できます
[FYI] Dispatcher Providerという選択肢
ここまでの例ではRepositoryのコンストラクタに直接CoroutineDispatcherを注入しましたが、プロジェクトが大きくなるとDispatcherをまとめたProviderを一枚挟む設計が有効です。
interface DispatcherProvider {
val io: CoroutineDispatcher
val default: CoroutineDispatcher
val main: CoroutineDispatcher
}
本番は Dispatchers.IO / Default / Main を束ねた実装を、
テストでは StandardTestDispatcher をまとめて差し替えた実装を使う、という形です。
ポイント
- 小規模 → Dispatcherを直接注入で十分
- 中〜大規模 → Dispatcher Providerで集約すると依存が整理され、テストの差し替えも一括で行える
(詳細な設計パターンは別の記事で掘り下げます)
まとめ
非同期テストを安定させるコツはシンプルです。
-
I/OはRepositoryの中に閉じ込める
- UIやUseCaseはスレッドを気にせず、ただ呼ぶだけでよい
-
Dispatcherは外から注入する
- 本番はIO、テストは仮想時間のDispatcherを差し替えて使う
-
テストではFakeを使う
- 実際の通信やDBに触れず、仮想時間で速く・安定して再現できる
関連する記事
- runTestやTestDispatcherを使ったコルーチンのテスト手法を解説した公式ドキュメント
- Androidでのコルーチン利用におけるベストプラクティス集
Dispatcherの注入やmain-safe設計など、本記事の設計指針と重なる内容を含む
- テストで利用するDispatcherの選び方や仮想時間による制御の利点について詳しく考察したブログ記事