はじめに
非同期処理(Coroutine)は便利ですが、テストになると突然難しくなります。
-
delay()を含む処理が遅くて CI が重い - タイミング依存でテストが不安定
-
ViewModelのviewModelScopeが動かない - Flow のテストでイベント順序が崩れる
こうした問題を解決するのが 仮想時間(virtual time)テスト。
kotlinx-coroutines-test を使えば、
1秒かかる delay も、0秒で再現できる ― つまり「時間を支配」できます。
1. テスト専用モジュールの導入
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:<version>")
testImplementation("app.cash.turbine:turbine:<version>") // Flow検証用
2. runTest の基本構造
runTest {} ブロックは、仮想時間空間を持つ特別なスコープです。
@OptIn(ExperimentalCoroutinesApi::class)
@Test fun example() = runTest {
delay(1_000) // 実時間ではなく仮想時間!
println("即時実行される")
}
delay()もwithTimeout()も、仮想クロックによって管理されるため、
実際には待たないのがポイントです。
3. 仮想時間の進め方
| メソッド | 役割 | 主な用途 |
|---|---|---|
runCurrent() |
現在キューのタスクをすべて実行 | 「次の一手」を確定させたいとき |
advanceTimeBy(ms) |
仮想時間を指定ミリ秒進める | delay / timeout を解放したいとき |
advanceUntilIdle() |
全てのタスク完了まで進める | 終端状態を作りたいとき |
4. TestDispatcher の種類と特徴
| Dispatcher | 特徴 | 主な用途 |
|---|---|---|
| StandardTestDispatcher | 手動で進行(決定的) | 複雑なテストに最適 |
| UnconfinedTestDispatcher | 即実行される | 単純な同期系で便利 |
おすすめ:実務テストでは StandardTestDispatcher 一択。
advance… 系で時間を明示的に動かす方が安全・安定です。
5. MainDispatcherRule(お約束)
UI 層(viewModelScope など)をテストする場合、
MainDispatcher を差し替えます。
@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()
}
@OptIn(ExperimentalCoroutinesApi::class)
@get:Rule val mainRule = MainDispatcherRule()
6. タイムアウト動作のテスト
suspend fun loadData(): String = withTimeout(2_000) {
delay(5_000)
"OK"
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test fun `timeout cancels job`() = runTest {
val deferred = async { runCatching { loadData() }.isSuccess }
advanceTimeBy(2_000) // 2秒進める → timeout発動
runCurrent()
assertFalse(deferred.await()) // 失敗確認
}
ポイント:
advanceTimeBy() は「仮想時計」を進めるだけ。
実際には 0 秒も経過しません。
7. Flow + debounce のテスト
fun Flow<String>.searchFlow(
search: suspend (String) -> List<String>
): Flow<List<String>> =
debounce(300)
.distinctUntilChanged()
.mapLatest { query -> search(query) }
@OptIn(ExperimentalCoroutinesApi::class)
@Test fun `debounce prevents burst search`() = runTest {
val input = MutableSharedFlow<String>()
var count = 0
val flow = input.searchFlow {
count++
listOf("Result:$it")
}
val job = launch { flow.test { } }
input.emit("a")
advanceTimeBy(100) // まだ300ms未満
input.emit("ab")
advanceTimeBy(100)
input.emit("abc")
advanceTimeBy(300) // 最後の入力確定
runCurrent()
assertEquals(1, count)
job.cancel()
}
debounce(300)の挙動を実時間なしで完全再現できます。
UI検索の自動テストなどで非常に有効です。
8. ViewModel × UseCase の統合テスト例
UseCase
class LoadUseCase(private val io: CoroutineDispatcher = Dispatchers.IO) {
suspend fun execute(): String = withContext(io) {
delay(500)
"OK"
}
}
ViewModel
class SampleViewModel(private val useCase: LoadUseCase) : ViewModel() {
private val _state = MutableStateFlow("Idle")
val state = _state.asStateFlow()
fun load() = viewModelScope.launch {
_state.value = "Loading"
_state.value = useCase.execute()
}
}
Test
@OptIn(ExperimentalCoroutinesApi::class)
class SampleViewModelTest {
@get:Rule val mainRule = MainDispatcherRule()
@Test fun `emits Loading then OK`() = runTest {
val uc = LoadUseCase(io = StandardTestDispatcher(testScheduler))
val vm = SampleViewModel(uc)
val job = launch { vm.state.test {
vm.load()
assertEquals("Loading", awaitItem())
advanceTimeBy(500)
assertEquals("OK", awaitItem())
cancelAndIgnoreRemainingEvents()
}}
job.cancel()
}
}
LoadUseCaseのDispatcherを注入し、テストではStandardTestDispatcherに置換advanceTimeBy(500)で delay を瞬時に解放viewModelScopeは MainDispatcherRule によって制御
9. Flow × Turbine の検証パターン
Turbine は Flow をテストするための強力なライブラリ。
awaitItem(), expectNoEvents(), cancelAndIgnoreRemainingEvents() を使います。
@OptIn(ExperimentalCoroutinesApi::class)
@Test fun `Flow emits Loading then Data`() = runTest {
val flow = flow {
emit("Loading")
delay(300)
emit("Data")
}
flow.test {
assertEquals("Loading", awaitItem())
advanceTimeBy(300)
assertEquals("Data", awaitItem())
cancelAndIgnoreRemainingEvents()
}
}
10. 実戦Tips
| 状況 | ベストプラクティス |
|---|---|
| 非同期関数のテスト |
runTest 内で delay を advance で制御 |
| Flow検証 | Turbine + 仮想時間 |
| ViewModelテスト |
Dispatchers.setMain() をRuleで統一 |
| 複数Coroutineの同期 |
advanceUntilIdle() でまとめ実行 |
| retry / timeout検証 |
withTimeout や delay の境界を advanceBy で進める |
11. アンチパターンと回避法
| アンチパターン | 問題点 | 修正方法 |
|---|---|---|
Thread.sleep() |
実時間で遅い・不安定 |
advanceTimeBy() を使う |
GlobalScope.launch |
終了しないタスク |
runTest 内に束縛 |
| Dispatcher直書き | テスト困難 | DI(注入) で差し替え |
StandardTestDispatcher 未使用 |
delayが進まない | 必ず明示的に進行させる |
| Flow初期値を無視 |
StateFlow の最初の値が awaitItem() になる |
最初に消費する or skip |
12. 仮想時間を支配するためのリズム
// 最も安定するテスト手順
val deferred = async { useCase.run() }
advanceTimeBy(500) // delay解放
runCurrent() // キュー処理
assertEquals("OK", deferred.await())
「advance → runCurrent → assert」の3拍子で安定。
13. テスト構造のベストプラクティス
すべての非同期要素(delay, Flow, launch)は
runTestの支配下で同期的に再現される。
まとめ
仮想時間テスト =「delay」「debounce」「timeout」などの非同期要素を、完全に制御する技術。
runTest+StandardTestDispatcher+advanceTimeBy+Turbineの4点セットをマスターすれば、
Kotlin Coroutine のテストは 速く・安定し・再現性が高い ものになります。