testing-coroutines-with-runtest について
kotlinx.coroutines.test の runTest を使い、suspend 関数や ViewModel のコルーチンをテストする Kotlin 開発者向けのスキルです。仮想時間、テストディスパッチャー、Main の設定、runBlockingTest からの移行を説明します。
主な機能
- 01runTest による suspend 関数のテスト
- 02仮想時計によるコルーチン時間の制御
- 03Standard と Unconfined のテストディスパッチャー設定
- 04ViewModel テスト用の Main 設定
- 05runBlockingTest からの移行
活用シーン
- 01遅延、リトライ、デバウンス処理のテスト
- 02ViewModel の中間状態の検証
- 03ホットフロー収集によるハングの回避
- 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 点を理解しておいてください。
timeoutはテスト全体の期限であり、ディスパッチが静止するまでのタイムアウトではありません。非推奨のdispatchTimeoutMsオーバーロードは意味が異なります。下記の移行に関する説明を参照してください。testBodyが戻った後、runTestはTestScope上で起動されたすべての子コルーチンが完了するまで待機します。一方、TestScope.backgroundScope上で起動された子コルーチンは、テスト終了時に自動的にキャンセルされます。ホットフローのコレクターにはbackgroundScopeを使用してください。- 子コルーチン内で捕捉されなかった例外は集約され、テストの最後に単一の失敗として再スローされます。
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
| プラットフォーム | 実際の型 | 呼び出し元の要件 |
|---|---|---|
| JVM | Unit | どの方法でも問題ありません。 |
| Native | Unit | 同様です。 |
| Kotlin/JS、wasm | Promise<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 { … } |
TestCoroutineScope | TestScope |
TestCoroutineDispatcher | StandardTestDispatcher/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。