mcpField
T
testing-coroutines-with-runtest

testing-coroutines-with-runtest

github.com/skydovesテスト・品質33980 回インストール22 週間前に更新
リポジトリ

testing-coroutines-with-runtest について

kotlinx.coroutines.test の runTest を使い、suspend 関数や ViewModel のコルーチンをテストする Kotlin 開発者向けのスキルです。仮想時間、テストディスパッチャー、Main の設定、runBlockingTest からの移行を説明します。

主な機能

  1. 01runTest による suspend 関数のテスト
  2. 02仮想時計によるコルーチン時間の制御
  3. 03Standard と Unconfined のテストディスパッチャー設定
  4. 04ViewModel テスト用の Main 設定
  5. 05runBlockingTest からの移行

活用シーン

  1. 01遅延、リトライ、デバウンス処理のテスト
  2. 02ViewModel の中間状態の検証
  3. 03ホットフロー収集によるハングの回避
  4. 04Kotlin Multiplatform テストでの TestResult の返却

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/skydoves/android-testing-skills --skill testing-coroutines-with-runtest

インストール後、Claude Code で/testing-coroutines-with-runtestを入力して読み込まれたか確認します。

詳細

runTest によるコルーチンのテスト — 実時間を使わない仮想時間

JVM 上で suspend 関数や viewModelScope を使うコードをテストする場合、正しいエントリーポイントは runTest だけです。このスキルでは、TestScope の契約、2 種類の TestDispatcher、共有される TestCoroutineScheduler の仮想クロック、そして Dispatchers.setMain がテスト間に漏れないようにする MainDispatcherRule の仕組みを明確にします。Flow 固有のアサーションについては、../testing-flows-with-turbine/SKILL.md を参照してください。

このスキルを使用する場面

  • テスト対象のクラスが suspend fun を公開している、または viewModelScope/lifecycleScope でコルーチンを起動している。
  • 開発者が runBlocking { … } を使おうとしてテストが停止する、または 10 分間の delay によってテストが遅くなる。
  • ViewModel がコルーチンから Loading -> Success を出力し、テストで各中間状態をアサートする必要がある。
  • ビルドで非推奨の警告 runBlockingTest is deprecated. Use runTest、または runTest(... dispatchTimeoutMs = ...) のエラーが出る。
  • viewModelScope が Dispatchers.Main 経由で処理を実行したために、テストが IllegalStateException: Module with the Main dispatcher is missing で失敗する。
  • タイムアウト/再試行/デバウンスを発生させるために、仮想時間を進める必要がある(advanceTimeBy(5.seconds)、advanceUntilIdle())。

このスキルを使用しない場面

  • テストで時間経過に伴う Flow の出力をアサートする。../testing-flows-with-turbine/SKILL.md を使用してください(Turbine がキャンセル、ホットとコールドの違い、awaitItem/awaitComplete を処理します)。
  • kotlinx.coroutines.test ではなく、Compose の MainTestClock をテストする。../../../compose/synchronization/controlling-the-test-clock/SKILL.md を使用してください。
  • AndroidJUnit4 を使ってエミュレーター/デバイス上でテストを実行する。JUnit4 自体のセットアップについては ../../runner/configuring-junit4-on-android/SKILL.md を参照してください。

前提条件

  • Gradle:testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.x")(docs/CORPUS.md §G.1)。必ず testImplementation のみにしてください。implementation は使用しないでください。このライブラリは、製品版に含めると安全でない Dispatchers.setMain の仕組みをインストールします。
  • テストファイルに @file:OptIn(ExperimentalCoroutinesApi::class)(または呼び出しごとの指定)を付けます。currentTime、advanceTimeBy、advanceUntilIdle、runCurrent、ディスパッチャーのコンストラクターは、1.10.x でも引き続き @ExperimentalCoroutinesApi です(R5)。
  • ViewModel のテストでは、注入された CoroutineDispatcher(「DispatcherProvider」)を使用し、SUT がテスト本体で進めるものと同じ TestCoroutineScheduler 上で処理を実行できるようにします。ハードコードされた Dispatchers.IO/Dispatchers.Default では、仮想時間を認識できません。
  • JS/wasm にコンパイルする KMP ライブラリでは、プラットフォームの TestResult が返されるよう、単一式形式の fun foo() = runTest { … } を使用します(R5。「TestResult の契約」を参照)。

runTest のシグネチャ

public fun runTest(
    context: CoroutineContext = EmptyCoroutineContext,
    timeout: Duration = 60.seconds,
    testBody: suspend TestScope.() -> Unit
): TestResult

(docs/CORPUS.md §G.4。)次の 3 点を理解しておいてください。

  1. timeout はテスト全体の期限であり、ディスパッチが静止するまでのタイムアウトではありません。非推奨の dispatchTimeoutMs オーバーロードは意味が異なります。下記の移行に関する説明を参照してください。
  2. testBody が戻った後、runTest は TestScope 上で起動されたすべての子コルーチンが完了するまで待機します。一方、TestScope.backgroundScope 上で起動された子コルーチンは、テスト終了時に自動的にキャンセルされます。ホットフローのコレクターには backgroundScope を使用してください。
  3. 子コルーチン内で捕捉されなかった例外は集約され、テストの最後に単一の失敗として再スローされます。

TestScope のメンバー

sealed interface TestScope : CoroutineScope {
    val testScheduler: TestCoroutineScheduler   // shared virtual clock
    val backgroundScope: CoroutineScope         // children auto-cancelled at test end
    val currentTime: Long                       // virtual ms elapsed
    val testTimeSource: TimeSource.WithComparableMarks
}

さらに、TestScope のライブラリー拡張として、advanceTimeBy(delay)、advanceTimeBy(durationMillis)、advanceUntilIdle()、runCurrent() があります。(R5)

TestDispatcher の真理値表

Dispatcher動作選ぶ場面
StandardTestDispatcherスケジューラーに継続処理をキューイングします。runCurrent()/advanceTimeBy(...)/advanceUntilIdle()を呼び出すまで、何も実行されません。runTestのデフォルトです。中間状態(Loading -> Success)の検証、仮想時間の精密な制御、競合状態を再現するとき。
UnconfinedTestDispatcher即時実行されます。launch { }は、実際にサスペンドする最初の箇所まで同期的に実行されます。runCurrent()を明示的に呼び出さずに最初のエミッションを受け取れるよう、ホットなStateFlow/SharedFlowのコレクターをセットアップするとき。

(docs/CORPUS.md §G.5。)

デフォルトではStandardTestDispatcherを使ってください。 UnconfinedTestDispatcherは、コレクターの即時実行が本当に重要で、その理由を説明できる場合に限って使ってください。本番コードがDispatchers.Defaultのもとで失敗するような順序のバグを隠してしまいます。

TestCoroutineScheduler — 仮想クロック

TestCoroutineSchedulerは、テストに参加するすべてのTestDispatcherが共有する仮想時間の唯一のソースです。

API効果
runCurrent()currentTimeの時点ですでに実行可能なタスクをすべて処理します。クロックは進めません。
advanceTimeBy(delta)currentTimeをdelta分進め、期限に達したタスクをすべて実行します。
advanceUntilIdle()キューが空になるまで、runCurrentと時間の進行を繰り返します。「すべてを完了まで実行」します。
currentTime経過した仮想ミリ秒数(読み取り専用)です。

(R5。)

重要:Dispatchers.IO、Dispatchers.Default、および任意のnewSingleThreadContext(...)は実際のスレッドプールであり、仮想クロックには参加しません。withContext(Dispatchers.IO)内のdelay()は、実際の経過時間を使います。解決策は依存性注入です。CoroutineDispatcherを渡し、テストではmainRule.dispatcher(またはStandardTestDispatcher(testScheduler))に差し替えてください。

Dispatchers.setMain / resetMain — ViewModel で必須

AndroidのDispatchers.Mainは、JVM単体テストには存在しないメインLooper上のHandlerContextです。Dispatchers.setMainを使わないと、viewModelScope、lifecycleScope、またはflowOn(Dispatchers.Main)を使って起動したものは、IllegalStateException: Module with the Main dispatcher is missingをスローします。

標準のMainDispatcherRule(androidx)

androidx/testutils/testutils-ktx/src/jvmMain/kotlin/androidx/testutils/MainDispatcherRule.jvm.ktからのそのままの引用(docs/CORPUS.md §G.3):

class MainDispatcherRule(
    private val dispatcher: CoroutineDispatcher,
) : TestRule {
    @OptIn(ExperimentalCoroutinesApi::class)
    override fun apply(base: Statement?, description: Description?) =
        object : Statement() {
            override fun evaluate() {
                Dispatchers.setMain(dispatcher)
                try { base!!.evaluate() } finally { Dispatchers.resetMain() }
            }
        }
}
ディスパッチャーを公開する必要がある場合のTestWatcher版
@ExperimentalCoroutinesApi
class MainCoroutineRule(
    val dispatcher: TestDispatcher = StandardTestDispatcher(),
) : TestWatcher() {
    override fun starting(description: Description?) {
        super.starting(description); Dispatchers.setMain(dispatcher)
    }
    override fun finished(description: Description?) {
        super.finished(description); Dispatchers.resetMain()
    }
}
使用方法
@OptIn(ExperimentalCoroutinesApi::class)
class MyVmTest {
    @get:Rule val mainRule = MainCoroutineRule()

    @Test fun loadsItems() = runTest(mainRule.dispatcher) {
        val vm = MyViewModel(repo, mainRule.dispatcher)
        vm.load()
        runCurrent()                              // see Loading state
        assertEquals(Loading, vm.state.value)
        advanceUntilIdle()                        // run network/db
        assertEquals(Success(items), vm.state.value)
    }
}

TestResultの仕様 — JVMとJSの違い

public expect class TestResult
プラットフォーム実際の型呼び出し元の要件
JVMUnitどの方法でも問題ありません。
NativeUnit同様です。
Kotlin/JS、wasmPromise<Unit>テスト関数は必ずreturn runTest { … }を使う必要があります。

(R5。)

Android専用のプロジェクトでは、TestResultがUnitに変わるため、@Test fun foo() { runTest { … } }と書けます。KMPモジュールでは、これだとPromise<Unit>が破棄され、JSフレームワークがテストの完了を待てません。毎回、単一式形式の@Test fun foo() = runTest { … }と書いてください。推奨: モジュールが将来KMPターゲットを出荷する場合に備え、常に単一式形式を使ってください。

runBlockingTestからの移行

1.5.x以前1.7+
runBlockingTest { … }runTest { … }
TestCoroutineScopeTestScope
TestCoroutineDispatcherStandardTestDispatcher/UnconfinedTestDispatcher
pauseDispatcher { } / resumeDispatcher削除済み — 明示的にrunCurrent/advanceTimeByを使います
cleanupTestCoroutines()不要 — runTestがクリーンアップを処理します
runTest(... dispatchTimeoutMs = X) { … }runTest(... timeout = X.milliseconds) { … }(セマンティクスが変更されています)

runBlockingTestはWARNING付きで非推奨です。dispatchTimeoutMsオーバーロードはERROR付きで非推奨です(R5、docs/CORPUS.md §G.2)。移行時の置き換え自体は機械的に行えますが、タイムアウトのセマンティクスは同じではありません。以前のdispatchTimeoutMsはディスパッチごとの静止状態に対するものでしたが、新しいtimeoutはテスト全体の期限です。

パターン

パターン:誤り — suspend 関数のテストに runBlocking を使用する
// WRONG
@Test fun loadsItems() = runBlocking {
    val vm = MyViewModel(repo)
    vm.load()
    Thread.sleep(2_000)                          // wait for the launch
    assertEquals(Success(items), vm.state.value)
}
// WRONG because: runBlocking has no virtual time and no exception aggregation. The
// delay in load() runs in real time; the launch may not have completed; uncaught
// exceptions in children are silently swallowed.
// RIGHT
@Test fun loadsItems() = runTest(mainRule.dispatcher) {
    val vm = MyViewModel(repo, mainRule.dispatcher)
    vm.load()
    advanceUntilIdle()                           // virtual time flushes everything
    assertEquals(Success(items), vm.state.value)
}
パターン:誤り — 時間を進める前にアサートする
// WRONG
@Test fun loadsItems() = runTest(mainRule.dispatcher) {
    val vm = MyViewModel(repo, mainRule.dispatcher)
    vm.load()
    assertEquals(emptyList(), vm.state.value.items)   // "passes" trivially
}
// WRONG because: with StandardTestDispatcher (the default), nothing runs until you
// advance. The launch inside vm.load() is queued but has not executed. The test
// passes for the wrong reason — the items list is empty because load() never ran.
// RIGHT
@Test fun loadsItems() = runTest(mainRule.dispatcher) {
    val vm = MyViewModel(repo, mainRule.dispatcher)
    vm.load()
    advanceUntilIdle()
    assertEquals(items, vm.state.value.items)
}
パターン:誤り — runTest の下で withContext(Dispatchers.IO) を使用する
// WRONG: production code
class Repo {
    suspend fun fetch() = withContext(Dispatchers.IO) {
        delay(2_000)                              // real wall-clock time
        api.fetch()
    }
}
// WRONG because: Dispatchers.IO is a real thread pool. delay(2_000) takes 2 seconds
// of real time. advanceTimeBy(2_000) does NOT skip it. The whole test runs in real
// time and may exceed the 60s runTest timeout.
// RIGHT: production code
class Repo(private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO) {
    suspend fun fetch() = withContext(ioDispatcher) {
        delay(2_000)
        api.fetch()
    }
}
// RIGHT: test
@Test fun fetchesAfterTwoSeconds() = runTest(mainRule.dispatcher) {
    val repo = Repo(ioDispatcher = mainRule.dispatcher)   // shares scheduler
    val deferred = async { repo.fetch() }
    advanceTimeBy(2_001)                                  // virtual; instant in real time
    assertEquals(expected, deferred.await())
}
パターン:誤り — TestScope で collect する(テストがハングする)
// WRONG
@Test fun observes() = runTest {
    val seen = mutableListOf<Int>()
    vm.uiState.collect { seen += it }            // never returns
    assertEquals(listOf(0, 1), seen)             // unreachable
}
// WRONG because: vm.uiState is a hot StateFlow. collect on the test's TestScope
// never completes; runTest waits for child completion and hangs until the 60s
// timeout fires. Use backgroundScope (auto-cancelled at end of test) — or Turbine.
// RIGHT — backgroundScope
@Test fun observes() = runTest(mainRule.dispatcher) {
    val seen = mutableListOf<Int>()
    vm.uiState
        .onEach { seen += it }
        .launchIn(backgroundScope)
    advanceUntilIdle()
    vm.refresh()
    advanceUntilIdle()
    assertEquals(listOf(0, 1), seen)
}

Flow のアサーションには、Turbine を使用してください。詳しくは ../testing-flows-with-turbine/SKILL.md を参照してください。

パターン:「2 つのスケジューラー」の落とし穴
// WRONG
@get:Rule val mainRule = MainCoroutineRule()       // scheduler A

@Test fun broken() = runTest {                      // scheduler B (auto-created)
    val vm = MyViewModel(repo, mainRule.dispatcher) // posts to A
    vm.load()
    advanceUntilIdle()                              // flushes B only — A's work pending
    assertEquals(Success(items), vm.state.value)    // FAIL: still Loading
}
// WRONG because: by default runTest creates its own StandardTestDispatcher with a
// fresh TestCoroutineScheduler. mainRule's dispatcher has a different scheduler;
// advanceUntilIdle() only flushes the test body's scheduler. (Recent kotlinx-
// coroutines-test versions do try to reuse Main's scheduler when Main was replaced
// with a TestDispatcher BEFORE runTest starts, but the defensive contract — pass
// the dispatcher explicitly — never breaks across versions.)
// RIGHT — share the scheduler explicitly
@Test fun ok() = runTest(mainRule.dispatcher) {
    val vm = MyViewModel(repo, mainRule.dispatcher)
    vm.load()
    advanceUntilIdle()
    assertEquals(Success(items), vm.state.value)
}

(R5 の落とし穴 #4。)

必須ルール

  • 必須: runBlocking { … } や非推奨の runBlockingTest { … } ではなく、runTest { … } を使用してください。
  • 必須: kotlinx-coroutines-test は testImplementation のみで依存関係に追加してください。必須: implementation に追加してはいけません。
  • 必須: currentTime、advanceTimeBy、advanceUntilIdle、runCurrent、StandardTestDispatcher(...)、または UnconfinedTestDispatcher(...) を使用する前に、@file:OptIn(ExperimentalCoroutinesApi::class)(または呼び出しごとの指定)を適用してください。
  • 必須: ViewModel/viewModelScope のテストでは、Dispatchers.setMain(testDispatcher)(MainDispatcherRule/MainCoroutineRule 経由)を設定してください。ルールの finally/@After でリセットしてください。
  • 必須: runTest(...) に mainRule.dispatcher を渡し、TestScope と Main が同じスケジューラーを共有するようにしてください。そうしないと、advanceUntilIdle() は片方しか処理しません。
  • 必須: 本番コードが IO/Default 処理に使用する CoroutineDispatcher は、すべて注入してください。ハードコードされた Dispatchers.IO は、runTest の下でも実時間で実行されます。
  • 必須: ホットフローのコレクターは TestScope.backgroundScope で起動してください(または Turbine を使用してください)。TestScope 上で直接 collect してはいけません。テストは 60 秒のタイムアウトまでハングします。
  • 必須: 先に advanceUntilIdle()(または「今すぐ」実行するマイクロタスクキューには runCurrent())を呼び出さずに状態をアサートしてはいけません。StandardTestDispatcher では、キューに入った処理はまだ実行されていません。
  • 禁止: runTest(... dispatchTimeoutMs = X) に依存してはいけません。エラーを伴う非推奨機能です。timeout = X.milliseconds を使用してください。意味が変わっている点(ディスパッチの静止状態ごとではなく、テスト全体の期限)に注意してください。
  • 推奨: KMP の JS/wasm での正しさを保つため、@Test fun foo() = runTest { … } の単一式形式を使用してください。JS/wasm では TestResult が Promise<Unit> になります。
  • 推奨: デフォルトでは StandardTestDispatcher を使用してください。積極的な実行が目的となるホットフローのコレクター設定に限り、UnconfinedTestDispatcher を使用してください。
  • 推奨: テストクラスパスに kotlinx-coroutines-debug を追加し、runTest のタイムアウト時にコルーチンのスタックダンプが含まれるようにしてください。

検証

  • ./gradlew :module:test が Module with the Main dispatcher is missing 例外なしで成功する。
  • テストソースに runBlocking { … } または runBlockingTest { … } の呼び出しが残っていない(grep -rn 'runBlocking[^T]' src/test、grep -rn 'runBlockingTest' src/test)。
  • dispatchTimeoutMs パラメーターが残っていない(grep -rn 'dispatchTimeoutMs' src/test)。
  • すべての ViewModel/viewModelScope テストクラスで @get:Rule MainCoroutineRule(または同等のルール)を宣言し、その dispatcher を runTest(...) に渡している。
  • テスト内のすべてのホットフローコレクターで backgroundScope または flow.test { … }(Turbine)を使用し、TestScope 上で直接 collect していない。
  • Dispatchers.IO/Dispatchers.Default を使用する本番クラスで CoroutineDispatcher を注入でき、テストで mainRule.dispatcher に置き換えている。
  • advanceTimeBy/advanceUntilIdle/runCurrent/currentTime を使用するすべてのテストファイルに @file:OptIn(ExperimentalCoroutinesApi::class) がある。
  • KMP モジュール(commonTest)で fun foo() = runTest { … } の単一式形式を使用している。

参考資料

  • JetBrains — kotlinx.coroutines.test:https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-test/
  • JetBrains — runTest:https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-test/kotlinx.coroutines.test/run-test.html
  • JetBrains — TestDispatcher:https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-test/kotlinx.coroutines.test/-test-dispatcher/
  • Android Developers — Android で Kotlin コルーチンをテストする:https://developer.android.com/kotlin/coroutines/test
  • Android Developers のブログ — Android のコルーチン、第 III 部(テスト):https://medium.com/androiddevelopers/coroutines-on-android-part-iii-real-work-2ba8a2ec2f45
  • androidx/testutils/testutils-ktx/src/jvmMain/kotlin/androidx/testutils/MainDispatcherRule.jvm.kt — 標準的な MainDispatcherRule(docs/CORPUS.md §G.3)。
  • androidx/pdf/.../MainCoroutineRule.kt — ディスパッチャーを公開する TestWatcher のバリエーション。
  • androidx/navigationevent/...NavigationEventDispatcherTest.kt — runTest(UnconfinedTestDispatcher()) + launchIn(backgroundScope) パターン。
  • androidx/room3/.../InvalidationTrackerTest.kt — TestScope().coroutineContext を SUT に注入する方法。
  • 関連資料:../testing-flows-with-turbine/SKILL.md — Flow のアサーション、awaitItem/awaitComplete、ホット/コールドのキャンセル。
  • 関連資料:../../mocking/mocking-with-mockito/SKILL.md — mockito-kotlin を使った suspend 関数のモック。
  • 関連資料:../../mocking/mocking-with-mockk/SKILL.md — suspend 関数に対する coEvery/coVerify。
  • 関連資料:../../runner/configuring-junit4-on-android/SKILL.md — runTest を取り巻く JUnit4 の設定。
  • 関連資料:../../robolectric/using-robolectric-correctly/SKILL.md — SUT が JVM 上で Android フレームワークを必要とする場合。
  • 関連資料:../../../fundamentals/strategies/applying-testing-strategies/SKILL.md — 小規模/中規模/大規模なテストの規模設定。
  • 関連資料:../../../fundamentals/strategies/organizing-test-source-sets/SKILL.md — コルーチンテストを配置する場所。
  • 関連資料:../../../compose/synchronization/controlling-the-test-clock/SKILL.md — Compose の MainTestClock(TestCoroutineScheduler とは別)。
  • 関連資料:../../../instrumentation/runner/running-instrumented-tests-with-androidjunit4/SKILL.md — 同じ SUT をデバイス上で実行する場合。
  • 調査資料:tasks/research/R5-coroutines-test-turbine.md。

コメント