Claude CodeでKotlinのコルーチンをテストする — runTestで仮想時間を進める
Claude CodeでKotlinのコルーチンとFlowのテストを書かせる手順です。runTestで待ち時間を飛ばす方法、Dispatchersの注入、Turbineの使い方、CLAUDE.mdに置く規約をまとめます。
Kotlinのコルーチンをテストするとき、delay(5_000) を含む関数を普通に呼ぶと、テストは本当に5秒待ちます。Claude Codeにテストを任せても同じです。runBlocking で書かれたテストは実時間を使い切り、CIの時間とテストの不安定さを積み上げます。
解決の中心は kotlinx-coroutines-test の runTest です。delay を仮想時間で飛ばし、実行順序だけを保ちます。Flowの検証にはTurbineを足します。この記事では、この2つをClaude Codeに使わせる手順を、CLAUDE.mdの規約・権限設定・生成結果のチェックまで順に説明します。KMPの構成そのものはClaude CodeでKotlin Multiplatformを使う手順にあります。ここではコルーチンのテストに絞ります。
依存を足してCLAUDE.mdに規約を置く
kotlinx-coroutines-test は、テスト用の依存として追加します。READMEの記載は次のとおりです。
dependencies {
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0")
testImplementation("app.cash.turbine:turbine:1.2.1")
}版番号はそれぞれのREADMEに載っている値です。READMEは、メインのソースにこの依存を入れないよう明記しています。すべてテスト専用に作られたユーティリティだからです。Turbineは UnconfinedTestDispatcher という不安定なAPIに依存しており、コルーチンライブラリの更新で挙動が変わる可能性があると、READMEが注意書きを置いています。Claude Codeに依存を足させるときも、版は自分で見て決めるほうが安全です。
次に、CLAUDE.mdへ規約を書きます。Claude CodeはCLAUDE.mdを毎セッションの冒頭で読みます。コルーチン用のルールは、禁止事項と代替手段をセットで書くと伝わりやすくなります。例えば次のような内容です。
## コルーチンのテスト規約
- suspend関数とFlowのテストは `runTest` で書く。`runBlocking` と `Thread.sleep` は使わない
- 待ち時間は `delay` を実際に待たず、`advanceTimeBy` と `currentTime` で確かめる
- 本番コードで `withContext(Dispatchers.IO)` を直書きしない。コンストラクターの引数で受ける
- Flowの検証は Turbine の `test { }` を使う。`first()` や `toList()` で済ませない
- `./gradlew test` が通るまで完了と報告しない最後の行はテスト実行を完了条件にするものです。TDDの回し方はClaude Codeでテスト駆動開発を回す手順に詳しくあります。
runTestで待ち時間を飛ばす
runTest は suspend 関数を呼べるテスト用のブロックです。runBlocking に似ていますが、READMEが挙げる違いは次の点です。
runBlockingとの違い
delayが飛ばされる
delayは自動でスキップされ、タスクの相対的な実行順序だけが保たれます。60秒で打ち切る
テストが永遠に止まらないよう、実行は60秒でタイムアウトします。
timeout引数で延ばせます。子の例外を最後に投げる
子コルーチンで起きた未捕捉の例外は、テストの終わりに投げ直されます。
仮想時間を操作できる
currentTimeで今の仮想時間を読み、advanceTimeByなどで進められます。
書き換えの差は次のとおりです。
実時間を待つテストから仮想時間のテストへ
runBlocking
@Test
fun retries() = runBlocking {
val r = fetchWithRetry() // 内部の delay(1_000) を実際に待つ
assertEquals("ok", r)
}runTest
@Test
fun retries() = runTest {
val r = fetchWithRetry() // delay は即座に終わる
assertEquals("ok", r)
assertEquals(1_000, currentTime) // 仮想時間は1秒進む
}currentTime をassertに入れると、待ち時間の長さそのものを検証できます。リトライ間隔やバックオフの仕様を、実時間なしでテストにできます。
仮想時間を自分で進める
delay を飛ばすだけでは足りない場面があります。途中の状態を確かめたいときです。runTest の中では TestCoroutineScheduler が時間を管理し、READMEには次の操作が載っています。
| 操作 | 動き |
|---|---|
currentTime | 動き今の仮想時間を返す |
runCurrent() | 動き今の時刻に予約済みのタスクを実行する |
advanceTimeBy(d) | 動き仮想時間を d だけ進めながらタスクを実行する |
advanceUntilIdle() | 動き残りのタスクをすべて実行する |
「3秒後にキャッシュが切れる」という仕様なら、2.9秒の時点と3秒の時点で状態を比べられます。
@Test
fun cacheExpiresAfterThreeSeconds() = runTest {
val cache = ExpiringCache(scope = backgroundScope, ttlMillis = 3_000)
cache.put("k", "v")
advanceTimeBy(2_900)
assertEquals("v", cache.get("k"))
advanceTimeBy(200)
assertNull(cache.get("k"))
}ExpiringCache は説明用の仮のクラスです。ここで使っている backgroundScope は、runTest が用意するスコープです。runTest は中の全コルーチンの完了を待ちますが、backgroundScope で起動したものはテストの終わりにキャンセルされます。永久に動き続けるコルーチンを持つ対象に向いています。
StandardTestDispatcher と UnconfinedTestDispatcher の違いにも注意が要ります。既定の runTest では、launch したコルーチンは runCurrent() などで進めるまで動きません。runTest(UnconfinedTestDispatcher()) にすると、最初の中断点まで即座に入ります。順序を気にしないテストなら楽ですが、実行の順序が仕様に含まれるテストでは標準のほうを使います。
withContextのDispatchersは注入に変える
最も見落としやすいのがここです。READMEは、withContext(Dispatchers.IO) や withContext(Dispatchers.Default) の中の delay は、テストでも仮想時間に乗らないと説明しています。テストは実際に1秒かかり、仮想時間は0のままです。
落とし穴
Dispatchers.IOを直書きした関数はdelayが飛ばない
本番コードに withContext(Dispatchers.IO) { delay(1_000) ... } があると、runTest の中でも1秒を実際に待ちます。READMEの対処は、CoroutineDispatcher を依存性注入・サービスロケーター・デフォルト引数のどれかで差し替え可能にし、テストでは TestDispatcher を渡す方法です。
デフォルト引数を使うと、呼び出し側のコードを変えずに済みます。
class ImageResizer(
private val io: CoroutineDispatcher = Dispatchers.IO,
) {
suspend fun load(): Int = withContext(io) {
delay(1_000)
1
}
}
@Test
fun loadSkipsDelay() = runTest {
val resizer = ImageResizer(StandardTestDispatcher(testScheduler))
assertEquals(1, resizer.load())
assertEquals(1_000, currentTime)
}StandardTestDispatcher(testScheduler) のように runTest と同じスケジューラーを渡す点が要です。スケジューラーを共有しないと、時間が別々に進みます。Claude Codeには、リファクタリングを依頼するときにこの形を示します。「既存の呼び出しは変えず、CoroutineDispatcher をコンストラクターのデフォルト引数にして」と伝えれば、変更範囲を絞れます。
Dispatchers.Main を使うViewModelは別の手当てが必要です。Dispatchers.setMain で TestDispatcher に差し替え、後始末に Dispatchers.resetMain() を呼びます。TestDispatcher で差し替えると、新しく作る TestScope や TestDispatcher が、そのスケジューラーを既定で使います。
FlowをTurbineで検証する
Flowの検証に toList() を使うと、終わらないFlowでテストが止まります。途中の値を順に確かめることもできません。Turbineの test { } は、Flowを別コルーチンで収集し、値・完了・エラーをイベントとして1つずつ取り出させます。
class Poller(private val fetch: suspend () -> Int) {
fun values(): Flow<Int> = flow {
repeat(3) {
emit(fetch())
delay(5_000)
}
}
}
@Test
fun pollsThreeTimes() = runTest {
var n = 0
Poller { ++n }.values().test {
assertEquals(1, awaitItem())
assertEquals(2, awaitItem())
assertEquals(3, awaitItem())
awaitComplete()
}
}5秒おきのポーリングですが、runTest の仮想時間で動くので実際には待ちません。主なAPIは次のとおりです。
awaitItem()は次の値を待つawaitComplete()は正常終了を待つawaitError()はエラーで終わるのを待つexpectMostRecentItem()は直近の値だけを取り、前の値を読み飛ばすcancelAndIgnoreRemainingEvents()は残りのイベントを捨てて終える
Turbineは、検証ブロックを終えた時点で未消費のイベントが残っていると、テストを失敗させます。READMEの例では、2つ目の値と完了イベントが残って落ちます。値の取りこぼしに気づける点は、toList() にない強みです。
Flowの中で例外を投げても、テストに例外は飛びません。エラーイベントとして届くので、awaitError() で受けます。
flow<Int> { throw RuntimeException("broken!") }.test {
assertEquals("broken!", awaitError().message)
}複数のFlowを同時に見るときは、testIn を turbineScope の中で使います。READMEは、runTest の backgroundScope を渡すと後始末が自動になると案内しています。testIn は自動でコルーチンを片付けないので、cancel()・awaitComplete()・awaitError() のいずれかを呼ばないとテストが止まります。
落とし穴
Turbineのタイムアウトは仮想時間ではなく実時間
Turbineが待つタイムアウトは実時間で、runTest の仮想時計は無視されます。既定は3秒で、test(timeout = ...) で変えられます。仮想時間を何十秒進めたかは、この3秒と関係ありません。
structured concurrencyをテストで確かめる
Kotlinのコルーチンは親子の木構造を作ります。親は子の完了を待ちます。親が失敗するかキャンセルされると、子は再帰的にキャンセルされます。この性質も、仮想時間を使えば短いテストで確認できます。
@Test
fun childFailureCancelsSibling() = runTest {
var siblingCancelled = false
assertFailsWith<IllegalStateException> {
coroutineScope {
launch {
try {
delay(10_000)
} catch (e: CancellationException) {
siblingCancelled = true
throw e
}
}
launch {
delay(1_000)
error("boom")
}
}
}
assertTrue(siblingCancelled)
assertEquals(1_000, currentTime)
}1つ目の子は10秒待つ予定でしたが、2つ目の子が1秒で失敗するので、仮想時間1,000msでキャンセルされます。10秒ぶんを待たずに「兄弟がキャンセルされる」ことと「いつ止まったか」の両方を検証できます。
withTimeout も仮想時間で動きます。READMEの例では、withTimeout(1_000) の中で delay(999) と delay(2) を呼ぶと、実時間を使わず TimeoutCancellationException が出ます。タイムアウトの仕様をテストにするときの足場になります。
Claude Codeに回させるループと権限
テストを書かせるときの流れは、Claude Codeのドキュメントにある「テストを足す」レシピと同じです。覆われていないコードの特定、テストの雛形、エッジケース、実行して失敗を直す、の順です。レシピの最後は次のプロンプトです。
run the new tests and fix any failuresコルーチンでは、実行結果の読み方まで指示に含めると効きます。例えば次のように伝えます。
PriceRepository の再試行をテストして。runTest と仮想時間を使い、
currentTime で待ち時間を assert して。./gradlew test を実行し、
実時間を待っているテストがないか所要時間も確認して毎回の許可確認を減らすなら、.claude/settings.json に許可ルールを置きます。ルールは * の位置に癖があります。Bash(npm run *) のように、サブコマンドの後ろに * を置く形が基本です。
{
"permissions": {
"allow": [
"Bash(./gradlew test *)",
"Bash(./gradlew test)"
]
}
}* を含まないルールは、完全に同じコマンドにだけ合います。オプション付きで呼ぶことが多いなら、両方の形を書きます。設定の確認は /permissions からできます。操作は/permissionsコマンドの解説にまとめています。
生成されたテストで確認する4点
Claude Codeが書いたテストは、通っただけで合格にしません。次の4点を目で見ます。
runBlockingやThread.sleepが残っていないか。残っていれば実時間を待っているwithContext(Dispatchers.IO)を使う対象で、テストが所要時間を食っていないか- Flowの検証が
toList()やfirst()で済まされていないか。Turbineなら途中の値も追える currentTimeのassertが、仕様の待ち時間と合っているか
サーバー側のコルーチンを検証する場合は、Ktorのテストも同じ考え方で組めます。
まとめ
コルーチンのテストが遅くなるのは、実時間で delay を待つ書き方のときです。runTest と仮想時間に寄せれば、待ち時間は実行時間から消え、待ち時間の長さを仕様としてassertできます。
Claude Codeに任せるときは、CLAUDE.mdに「runBlocking 禁止・Dispatcherは注入・FlowはTurbine」の3点を書いておくのが最短です。そのうえで、Dispatchers.IO 直書きの関数が残っていないかだけは、生成後に自分の目で見ます。ここだけは、テストが緑でも実時間を待っていることがあるからです。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。