controlling-the-test-clock について
Android Compose テスト開発者向けに、MainTestClock がフレーム、再コンポーズ、アニメーション、コルーチン処理を動かす仕組みを説明するスキルです。時間の進め方と、v1 から v2 へのテスト方式の違いを扱います。
主な機能
- 01MainTestClock とスケジューラーの解説
- 0216 ms のフレーム周期の説明
- 03フレーム時間の切り上げの説明
- 04自動・手動のクロック制御
- 05v2 のキュー済みタスクの処理
活用シーン
- 01時間を進めた後のフレーム数の異常調査
- 02Compose テストの v1 から v2 への移行
- 03Compose の状態条件までテストを進める
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ skydoves/ android-testing-skills --skill controlling-the-test-clockインストール後、Claude Code で/controlling-the-test-clockを入力して読み込まれたか確認します。
詳細
テストクロックの制御 — フレーム、時間、リコンポーザー
MainTestClock は、Compose のテストでリコンポジション、アニメーション、LaunchedEffect を駆動する唯一のつまみです。このスキルでは、その API、背後にある 16 ms のフレームモデル、autoAdvance によってテストが「フレームワークがクロックを駆動する」状態から「テストがクロックを駆動する」状態へ切り替わる仕組みを説明します。アニメーションのレシピとアイドル状態/待機のレシピは、関連する別のスキルにあります。このスキルでは、どちらにも必要な仕組みを説明します。
このスキルを使う場面
- 開発者が、
mainClock.advanceTimeBy(...)が実際に何をするのか、なぜ時間が切り上げられるのか、またはアニメーションがなぜ 1 フレームずれて見えるのかを尋ねている。 - 開発者が
androidx.compose.ui.test.junit4.createComposeRule(v1)からandroidx.compose.ui.test.junit4.v2.createComposeRule(v2)に移行していて、テストで明示的なmainClock.runCurrent()またはadvanceTimeBy(0)が必要になった。 - 開発者が
MainTestClock、TestCoroutineScheduler、TestMonotonicFrameClock、ComposeTimeoutException、advanceTimeUntil、または「テストクロックと実時間」について言及している。 - 開発者が特定のフレーム数を必要とするテストを書いていて、
advanceTimeBy(5)によってフレームが 0、1、または複数生成されるのか分からない。 - 開発者が、1 フレーム後もアニメーションの
playTimeが0のままなのはなぜかと尋ねている。
このスキルを使わない場面
- 目的が決定論的なアニメーションテスト(
autoAdvance = falseのレシピ)である。../testing-animations-deterministically/SKILL.mdを使ってください。 - 目的が
IdlingResource、非同期処理、または「UI が安定した」状態を待つことである。../synchronizing-with-idle/SKILL.mdを使ってください。 - 目的がより上位のテスト構造(ルールの設定、
setContent、JUnit のライフサイクル)である。../../patterns/structuring-a-compose-test/SKILL.mdを使ってください。 - 目的が
createComposeRuleとrunComposeUiTestのどちらを選ぶかである。../../setup/choosing-test-rule-vs-runtest/SKILL.mdを使ってください。
前提条件
- テストソースセットに
androidx.compose.ui:ui-testとandroidx.compose.ui:ui-test-junit4(または JUnit を使わないrunComposeUiTest)があること。 createComposeRule()のComposeContentTestRule、またはrunComposeUiTest { … }のComposeUiTestレシーバーがあること。- 推奨: v2 のエントリーポイント(
androidx.compose.ui.test.junit4.v2.createComposeRule、androidx.compose.ui.test.v2.runComposeUiTest)。v1 形式は@Deprecated(level = WARNING)です(skydoves のホットテイク #6)。
考え方のモデル
MainTestClock は MonotonicFrameClock ではありません。これは、kotlinx.coroutines.test.TestCoroutineScheduler を駆動する advance メソッドを持つインターフェースです。リコンポーザーは、withFrameNanos が 同じスケジューラーに対する delay(frameDelayMillis) として実装された TestMonotonicFrameClock からフレームを読み取ります(TestMonotonicFrameClock.jvmAndAndroid.kt:105-117)。スケジューラーを進めると delay が発火し、それによってフレームが生成され、onPerformTraversals が実行されて、各 Compose ルートで測定とレイアウトが行われます。
MainTestClock.advanceTimeBy(ms)
│
▼ AbstractMainTestClock.advanceScheduler
TestCoroutineScheduler.advanceTimeBy(ms) → runCurrent()
│
▼ delay(frameDelayMillis) inside TestMonotonicFrameClock fires
performFrame() ── awaiters (withFrameNanos) resume FIRST
│ ── then onPerformTraversals → recomposition → measure/layout
▼
Recomposer applies snapshot writes
このため、autoAdvance = false にするだけで UI 全体を停止できます。明示的な mainClock.advance* の呼び出しがない限り、ほかにスケジューラーを進めるものはありません(MainTestClock.kt:43-48 の KDoc)。
MainTestClock の概要
各項目の出典は compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/MainTestClock.kt です。
| メンバー | 動作 |
|---|---|
currentTime: Long | テストクロックの時刻をミリ秒単位で示します。実時間ではありません。 scheduler.currentTime を読み取ります(AbstractMainTestClock.kt:35-36)。 |
scheduler: TestCoroutineScheduler | リコンポーザーとクロックが共有するスケジューラーです。外部から runCurrent() を呼び出すときに便利です(MainTestClock.kt:84-99)。 |
autoAdvance: Boolean | デフォルトは true です。true の場合、フレームワークは waitForIdle/waitUntil 中にクロックを自動的に進めます。false の場合、クロックを進めるのは明示的な advanceTime* 呼び出しだけです(MainTestClock.kt:101-115)。 |
advanceTimeByFrame() | 1 フレーム分(Android/Desktop では 16 ms)だけ進めます。advanceScheduler(frameDelayMillis) として実装されています(AbstractMainTestClock.kt:40-42)。 |
advanceTimeBy(milliseconds, ignoreFrameDuration = false) | ignoreFrameDuration = true でない限り、フレーム時間の最も近い倍数に切り上げます(AbstractMainTestClock.kt:44-52、MainTestClock.kt:120-145)。 |
advanceTimeUntil(timeoutMillis = 1_000, condition) | condition() が true になるまで、フレームごとに進めます。タイムアウトはテストクロックに基づきます。ComposeTimeoutException をスローします(MainTestClock.kt:147-167、AbstractMainTestClock.kt:54-72)。 |
runCurrent() は AbstractMainTestClock で公開されています(AbstractMainTestClock.kt:96-98)。クロックを進めずに、現在時刻に実行予定のすべてのタスクを実行します。v2 で状態を切り替えた後、次のアサーションの前にキュー内のタスクを実行する必要がある場合に使います。
フレームモデル
- フレーム時間:
DefaultFrameDelay = 16_000_000Lns = 16 ms(TestMonotonicFrameClock.jvmAndAndroid.kt:33)。 performFrame内のフレームごとの実行順序:- すべての
withFrameNanos待機処理が、新しいフレーム時刻で再開します。アニメーションはここで動作します。 onPerformTraversalsが実行されます — コンポジション+測定+レイアウト。- 待機処理によってキューに追加された再開処理がディスパッチされます。
- すべての
MainTestClock.kt:49-60 からの引用:
保留中の再コンポジションと、フレーム時刻を待っているアニメーションの両方がある場合、このクロックを進めると、まず新しいフレーム時刻がアニメーションに送られ、その後に再コンポジションが実行されます。… アニメーションは再コンポジションより先にフレーム時刻を受け取るため、状態変数を切り替えてアニメーションを開始した後の最初のフレームでは、アニメーションに開始時刻が設定されません。
最後の文が、すべてのアニメーションテストで setContent の直後に advanceTimeByFrame() を追加で呼び出してアニメーションを「開始」させる理由です。状態を切り替えたフレームでアニメーションがスケジュールされ、次のフレームで再生時間が 0 に初期化されます(skydoves の大胆な見解 #3)。
ワークフロー
1. currentTime は確認用としてのみ読み取り、同期の仕組みとしては使わないでください
// RIGHT — assert the clock advanced as much as the test asked
val before = rule.mainClock.currentTime
rule.mainClock.advanceTimeBy(durationMillis = 320)
assertEquals(320, rule.mainClock.currentTime - before)
// WRONG
while (rule.mainClock.currentTime < target) { /* spin */ }
// WRONG because: nothing in this loop drives the scheduler. The loop runs forever.
2. 適切な時間の進め方を選ぶ
| 目的 | 呼び出し |
|---|---|
| 「フレームをちょうど 1 つ実行する。」 | mainClock.advanceTimeByFrame() |
| 「N フレーム実行する。」 | mainClock.advanceTimeBy(N * 16L) |
| 「既知のアニメーション時間分を実行する。」 | mainClock.advanceTimeBy(durationMillis = 300)(次の 16 ms 境界まで切り上げられます) |
| 「新しいフレームを生成せずに時間を進める。」 | mainClock.advanceTimeBy(milliseconds = 8, ignoreFrameDuration = true) |
| 「Compose の状態条件が true になるまで待つ。」 | mainClock.advanceTimeUntil(timeoutMillis = 5_000) { state.value == … } |
3. v2 で状態を変更した後、アサーションの前に保留中のタスクを実行する
v2 の createComposeRule は、コンポジションに StandardTestDispatcher を使用します。タスクはすぐに実行されず、キューに追加されます。autoAdvance = false の場合、waitForIdle もノードクエリも、これらのタスクを実行しません。対処するには mainClock.runCurrent() または mainClock.advanceTimeBy(0) を使います。
rule.mainClock.autoAdvance = false
rule.runOnUiThread { uiState = newValue }
// Under v2, the snapshot write is queued. Drain it before asserting.
rule.mainClock.advanceTimeByFrame()
rule.onNodeWithTag("status").assertTextEquals("Updated")
AbstractMainTestClock.advanceTimeUntil は、isStandardTestDispatcherSupportEnabled が true の場合、最初に scheduler.runCurrent() を呼び出します(AbstractMainTestClock.kt:59-61)。
4. ComposeTimeoutException は時間ではなく条件に対する判定として扱います
advanceTimeUntil は、テストクロックが timeoutMillis を超えて進んでも condition() が true を返さない場合に、ComposeTimeoutException("Condition still not satisfied after $timeoutMillis ms") をスローします(AbstractMainTestClock.kt:65-68)。この例外は、クロック駆動の処理では条件が満たされないことを意味します。通常は、スナップショットへの書き込みがない、LaunchedEffect が開始されない、または別のスレッドで状態が読み取られていることが原因です。
パターン
パターン:切り上げ
// WRONG
rule.mainClock.advanceTimeBy(milliseconds = 5)
// expectation: "1 frame ran"
// WRONG because: the framework rounds 5 ms UP to the next 16 ms multiple — i.e. 16 ms.
// One frame did run, but the developer is reading the API as if it could advance by 5 ms.
// Use ignoreFrameDuration = true if sub-frame stepping is genuinely required.
// RIGHT
rule.mainClock.advanceTimeByFrame() // exactly 16 ms, exactly one frame
// or
rule.mainClock.advanceTimeBy(milliseconds = 5, ignoreFrameDuration = true) // 5 ms, 0 frames
パターン:アニメーションの「開始」フレーム
// WRONG
rule.mainClock.autoAdvance = false
rule.setContent { Crossfade(showFirst) { /* … */ } }
rule.mainClock.advanceTimeBy(300) // expect animation finished
rule.onNodeWithText("Second").assertExists()
// WRONG because: the toggle frame only schedules the animation; the animation's playTime is 0
// going into the next frame. The first 16 ms of the 300 are eaten by setup.
// RIGHT — see CrossfadeTest.kt:70-93
rule.mainClock.autoAdvance = false
rule.setContent { Crossfade(showFirst) { /* … */ } }
rule.mainClock.advanceTimeByFrame() // kick off
rule.mainClock.advanceTimeBy(durationMillis = 300) // step
rule.onNodeWithText("Second").assertExists()
パターン:v1 から v2 への移行で状態の読み取りが古いままになる
// WRONG (v2)
val rule = androidx.compose.ui.test.junit4.v2.createComposeRule()
@Test fun foo() {
var counter by mutableStateOf(0)
rule.setContent { Text(counter.toString()) }
rule.runOnUiThread { counter = 1 }
rule.onNodeWithText("1").assertExists()
// WRONG because: with StandardTestDispatcher, the snapshot apply is queued.
// No frame has been produced. The composition still reads `0`.
}
// RIGHT (v2)
@Test fun foo() {
var counter by mutableStateOf(0)
rule.setContent { Text(counter.toString()) }
rule.runOnUiThread { counter = 1 }
rule.mainClock.advanceTimeByFrame()
rule.onNodeWithText("1").assertExists()
}
パターン:advanceTimeUntil で実時間を節約
// LESS PREFERRED — wall clock
rule.waitUntil(timeoutMillis = 5_000) { state.value == Phase.Done }
// PREFERRED — test clock; framework iterates frame-by-frame and the timeout is in
// test clock time (no real wall-clock burn between iterations).
rule.mainClock.advanceTimeUntil(timeoutMillis = 5_000) { state.value == Phase.Done }
どちらの方法も、autoAdvance == true の場合はテストクロックを進めます。waitUntil は反復ごとに mainClock.advanceTimeByFrame() と Thread.sleep(10) を呼び出します(ComposeUiTest.android.kt:899-902)。一方、advanceTimeUntil はフレーム単位でテストクロックだけを進めます(AbstractMainTestClock.kt:54-72)。違いはタイムアウトの基準(実時間かテストクロックか)と、テストクロックを使う方法では Thread.sleep のオーバーヘッドがないことです。
必須ルール
- 必須:
MainTestClock.currentTimeはテストクロックの時間のみを表すものとして扱います。これをSystem.currentTimeMillis()と比較しても意味はありません。 - 必須: 意図が「正確に 1 フレーム」の場合は、
advanceTimeBy(16)ではなくadvanceTimeByFrame()を呼び出します。意図を明確にでき、丸めについて考える必要がなくなります。 - 必須:
advanceTimeBy(milliseconds = N)はframeDelayMillis(Android/Desktop では 16 ms)の次の倍数まで切り上げられることを把握します。切り上げを抑制するには、ignoreFrameDuration = trueを渡します(AbstractMainTestClock.kt:44-52)。 - 必須: v2(
StandardTestDispatcher)では、状態を切り替えた後、結果の UI 状態を読み取る前にadvanceTimeByFrame()(またはadvanceTimeBy(0)/runCurrent())を呼び出します。v1 のUnconfinedTestDispatcherは即時にディスパッチするためこの呼び出しは不要ですが、v2 では必要です(skydoves のホットテイク #6)。 - 禁止: Compose のスナップショットシステム外の条件(例:
Job.isCompleted、OkHttpコールバック)にadvanceTimeUntilを使用してはいけません。テストクロックを進めても、こうした条件が満たされることはありません。その場合はwaitUntil(実時間)またはIdlingResourceが適切なツールです。../synchronizing-with-idle/SKILL.mdを参照してください。 - 禁止:
MainTestClockとMonotonicFrameClockを混同してはいけません。これらは異なる型です。MainTestClockは、リコンポーザーが使用するTestMonotonicFrameClockを駆動するスケジューラーを公開します。 - 推奨: ディスパッチャーが
kotlinx.coroutines.test.runTestのセマンティクスと一致するよう、v2 のエントリーポイントを使用します。v1 のcreateComposeRule/runComposeUiTest形式は非推奨であり、WARNINGが表示されます。 - 推奨: 待機する条件を Compose の状態から確認できる場合は、
waitUntilよりadvanceTimeUntilを使用します。テストクロックはより高速で決定的です。実時間を使う方法では、反復ごとに 10 ms スリープします。
検証
- 「1 フレーム」にはすべての呼び出し箇所で
advanceTimeByFrame()を使用し、advanceTimeBy(...)は時間の長さに意味がある場合に限って使用します。 -
X < 16のadvanceTimeBy(milliseconds = X)が存在する場合、意図を説明するコメントまたはignoreFrameDuration = trueフラグがあります。 - v1 から v2 への移行後、状態の変更に続いてアサーションを行う箇所では、
mainClock.advanceTimeByFrame()/advanceTimeBy(0)/runCurrent()を呼び出すか、runOnIdle { … }でキューに入ったタスクを処理します。 -
advanceTimeUntilのすべての箇所では Compose の状態だけを読み取ります。Job.isCompletedや外部カウンターは使用しません。 - クロックテストで発生した
ComposeTimeoutExceptionは、タイムアウトを延長する理由ではなく、スナップショットへの書き込みがないこと、または別のスレッドで状態が読み取られていることを示すものとして扱います。
参考資料
- Android Developers — Compose のテスト:https://developer.android.com/develop/ui/compose/testing
- Android Developers — アニメーションのテスト:https://developer.android.com/develop/ui/compose/animation/testing
- Compose UI のリリースノート:https://developer.android.com/jetpack/androidx/releases/compose-ui
compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/MainTestClock.kt— 公開インターフェースとComposeTimeoutException。compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/AbstractMainTestClock.kt— 丸め規則、runCurrent、advanceScheduler。compose/ui/ui-test/src/jvmAndAndroidMain/kotlin/androidx/compose/ui/test/TestMonotonicFrameClock.jvmAndAndroid.kt—DefaultFrameDelay = 16_000_000L、delayベースのフレームループ。compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/MainTestClockImpl.android.kt— Android の actual。compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/ComposeUiTest.android.kt— 885-905 行目にあるwaitUntilの 10 ms の実時間スリープ。- 関連するスキル:
../testing-animations-deterministically/SKILL.md—autoAdvance = falseを使う手順の全体。 - 関連するスキル:
../synchronizing-with-idle/SKILL.md—waitForIdle、waitUntil、advanceTimeUntilの使い分け。