Claude Media
Claude CodeでKotlinのコルーチンをテストする — runTestで仮想時間を進める

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が挙げる違いは次の点です。

runTest

runBlockingとの違い

  • delayが飛ばされる

    delay は自動でスキップされ、タスクの相対的な実行順序だけが保たれます。

  • 60秒で打ち切る

    テストが永遠に止まらないよう、実行は60秒でタイムアウトします。timeout 引数で延ばせます。

  • 子の例外を最後に投げる

    子コルーチンで起きた未捕捉の例外は、テストの終わりに投げ直されます。

  • 仮想時間を操作できる

    currentTime で今の仮想時間を読み、advanceTimeBy などで進められます。

書き換えの差は次のとおりです。

前と後

実時間を待つテストから仮想時間のテストへ

BEFORE

runBlocking

@Test
fun retries() = runBlocking {
    val r = fetchWithRetry() // 内部の delay(1_000) を実際に待つ
    assertEquals("ok", r)
}
AFTER

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点を目で見ます。

  1. runBlocking や Thread.sleep が残っていないか。残っていれば実時間を待っている
  2. withContext(Dispatchers.IO) を使う対象で、テストが所要時間を食っていないか
  3. Flowの検証が toList() や first() で済まされていないか。Turbineなら途中の値も追える
  4. currentTime のassertが、仕様の待ち時間と合っているか

サーバー側のコルーチンを検証する場合は、Ktorのテストも同じ考え方で組めます。

まとめ

コルーチンのテストが遅くなるのは、実時間で delay を待つ書き方のときです。runTest と仮想時間に寄せれば、待ち時間は実行時間から消え、待ち時間の長さを仕様としてassertできます。

Claude Codeに任せるときは、CLAUDE.mdに「runBlocking 禁止・Dispatcherは注入・FlowはTurbine」の3点を書いておくのが最短です。そのうえで、Dispatchers.IO 直書きの関数が残っていないかだけは、生成後に自分の目で見ます。ここだけは、テストが緑でも実時間を待っていることがあるからです。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/common-workflows
  2. code.claude.com/docs/en/permissions
  3. code.claude.com/docs/en/memory
  4. kotlinlang.org/docs/coroutines-basics.html
  5. github.com/Kotlin/kotlinx.coroutines/blob/master/kotlinx-coroutines-t…
  6. github.com/cashapp/turbine
この記事を共有:XはてブLinkedIn