0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Kotlin】Coroutineテストと仮想時間の徹底攻略

Posted at

はじめに

非同期処理(Coroutine)は便利ですが、テストになると突然難しくなります。

  • delay() を含む処理が遅くて CI が重い
  • タイミング依存でテストが不安定
  • ViewModelviewModelScope が動かない
  • 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()
    }
}
  • LoadUseCaseDispatcher を注入し、テストでは 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検証 withTimeoutdelay の境界を 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 のテストは 速く・安定し・再現性が高い ものになります。

0
0
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
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?