mcpField
T
testing-animations-deterministically

testing-animations-deterministically

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

testing-animations-deterministically について

Jetpack Compose のアニメーションをテストクロックで制御する Android 開発者向けのスキルです。中間状態や無限アニメーションのテストに加え、クロック設定と停止中の状態変更を扱います。

主な機能

  1. 01コンテンツ設定前に自動進行を停止
  2. 02フレームと時間を手動で進行
  3. 03無限アニメーションのテスト
  4. 04アニメーション中の UI スレッドでの状態変更

活用シーン

  1. 01アニメーションの中間フレームの検証
  2. 02ループする進捗インジケーターのテスト
  3. 03CI で不安定なアニメーションテストの調査

インストール

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

$npx skills add https://github.com/skydoves/android-testing-skills --skill testing-animations-deterministically

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

詳細

アニメーションを決定論的にテストする — autoAdvance = false 以外ありえません

クロックを一時停止しない Compose アニメーションテストは、定義上、不安定です。mainClock.autoAdvance = true(デフォルト)の場合、未確定アニメーションが開始するとフレームワークの InfiniteAnimationPolicy が CancellationException をスローし、有限アニメーションは自動的に一気に進んでしまい、途中の状態を観測できません。このスキルでは、これを解決する5行の手順に加え、初めて試すすべての開発者がつまずく runOnUiThread と runOnIdle の落とし穴について説明します。

このスキルを使う場面

  • アニメーションの途中のフレーム(フェード途中のアルファ値、スクロール途中のオフセット、Crossfade の破棄途中)をテストで検証する場合。
  • コンポーザブルに未確定アニメーション(進捗引数のない LinearProgressIndicator()、rememberInfiniteTransition、ループする withFrameMillis)が含まれている場合。
  • 開発者から CancellationException("Infinite animations are disabled on tests") が発生したと報告があり、止める方法を尋ねられた場合。
  • 開発者が「アニメーションを待つ」ために Thread.sleep(500) を使おうとしている場合。
  • 開発者のアニメーションテストがローカルでは通るのに CI では失敗する、またはその逆の場合。
  • 開発者が autoAdvance、advanceTimeByFrame、advanceTimeBy、Crossfade、「不安定なアニメーションテスト」、InfiniteAnimationPolicy に言及した場合。

このスキルを使わない場面

  • アニメーションが副作用であり、テストで最終状態だけを確認する場合。デフォルトの autoAdvance = true のほうが高速です。../synchronizing-with-idle/SKILL.md を参照してください。
  • クロックの動作自体(フレームモデル、丸め規則、v1 と v2 のディスパッチャー)が不明確な場合。まず ../controlling-the-test-clock/SKILL.md を読んでください。
  • テスト対象の条件が Compose の状態ではない場合(Job.isCompleted、Mockito.verify など)。../synchronizing-with-idle/SKILL.md の waitUntil または IdlingResource を使ってください。
  • リップルやエレベーションのピクセルを対象に、RenderThread を待つスクリーンショット比較を行う場合。これが Thread.sleep を使ってよい唯一のケースです(skydoves のホットテイク #7)。

前提条件

  • androidx.compose.ui:ui-test と androidx.compose.ui:ui-test-junit4(または JUnit なしの runComposeUiTest)。
  • createComposeRule() の ComposeContentTestRule(推奨の v2:androidx.compose.ui.test.junit4.v2.createComposeRule)。
  • ../controlling-the-test-clock/SKILL.md に記載されたフレームモデルの知識。特に、切り替えのフレームでアニメーションがスケジュールされ、次のフレームで playTime = 0 に初期化されること。

デフォルト設定が失敗する理由

テストルールが環境を構築すると、リコンポーザーに InfiniteAnimationPolicy が設定されます。compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/ComposeUiTest.android.kt:567-575 より引用:

infiniteAnimationPolicy =
    object : InfiniteAnimationPolicy {
        override suspend fun <R> onInfiniteOperation(block: suspend () -> R): R {
            if (mainClockImpl.autoAdvance) {
                throw CancellationException("Infinite animations are disabled on tests")
            }
            return block()
        }
    }

withInfiniteAnimationFrameNanos を呼び出すと(rememberInfiniteTransition、未確定の進捗インジケーター、一部のループ API で使用されます)、このポリシーを通ります。autoAdvance == true の間、ポリシーは例外をスローします。終わりのないアニメーションを待ち続けないように、フレームワークはそうしています。

解決策は、アニメーションが許可されるように setContent の前に autoAdvance を無効にし、その後、手動でフレームを進めることです。Skydoves のホットテイク #3: アニメーションテストでは mainClock.autoAdvance = false が必要です。他にサポートされている方法はありません。

手順(5行)

@Test fun anyAnimation() {
    rule.mainClock.autoAdvance = false                     // 1. pause the clock FIRST
    rule.setContent { /* the composable */ }               // 2. set content
    rule.mainClock.advanceTimeByFrame()                    // 3. kick-off frame
    rule.mainClock.advanceTimeBy(durationMillis = 300)     // 4. step the animation
    rule.onNodeWithTag("…").assertIsDisplayed()            // 5. assert
}

順序が重要です。setContent の後に autoAdvance = false を設定するのは誤りです。最初のコンポジションですでに未確定アニメーションが始まることがあり、その場合、テストが制御する前にフレームワークが自動的に時間を進め始めています。

kick-off フレームが必要な理由

MainTestClock.kt:53-60 より:

アニメーションは再コンポジションの 前に フレーム時刻を受け取るため、状態変数を切り替えてアニメーションを開始した直後の最初のフレームでは、アニメーションに開始時刻が設定されません。…アニメーションは [次のフレームで] 最初のフレーム時刻を受け取り、再生時間を t=0 に初期化します。

つまり、切り替えたフレームではスケジュールが設定されます。次のフレームが playTime = 0 です。kick-off の advanceTimeByFrame() がないと、アニメーションの再生時間の最初の 16 ms がセットアップに消費され、テストで読み取る状態が期待より遅れます。これが「アニメーションが 1 フレームずれている」根本原因です。

ワークフロー

1. setContent の前にクロックを一時停止する
val rule = androidx.compose.ui.test.junit4.v2.createComposeRule()

@Test fun crossfade_disposes_old_content() {
    rule.mainClock.autoAdvance = false                     // <-- before setContent
    var showFirst by mutableStateOf(true)
    var disposed = false
    rule.setContent {
        Crossfade(showFirst) {
            BasicText(if (it) "First" else "Second")
            DisposableEffect(Unit) { onDispose { disposed = true } }
        }
    }
    rule.mainClock.advanceTimeByFrame()                    // kick off
    rule.mainClock.advanceTimeBy(durationMillis = 300)
    rule.runOnUiThread { showFirst = false }
    rule.mainClock.advanceTimeUntil { disposed }           // test-clock wait
    rule.onNodeWithText("First").assertDoesNotExist()
    rule.onNodeWithText("Second").assertExists()
}

これは compose/animation/animation/src/androidDeviceTest/kotlin/androidx/compose/animation/CrossfadeTest.kt:70-93 にある、標準的な Crossfade テストです。

2. フレームごとの割合アサーション:onAnimationFrame ヘルパー

固定時間のアニメーションのすべてのフレームでアサーションする必要があるテストでは、compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListItemPlacementAnimationTest.kt:1724-1738 からこのヘルパーを取り入れてください。

private fun onAnimationFrame(duration: Long = Duration, onFrame: (fraction: Float) -> Unit) {
    require(duration.mod(FrameDuration) == 0L)
    rule.waitForIdle()
    rule.mainClock.advanceTimeByFrame()
    var expectedTime = rule.mainClock.currentTime
    for (i in 0..duration step FrameDuration) {
        val fraction = i / duration.toFloat()
        onFrame(fraction)
        if (i < duration) {
            rule.mainClock.advanceTimeBy(FrameDuration)
            expectedTime += FrameDuration
            assertThat(expectedTime).isEqualTo(rule.mainClock.currentTime)
        }
    }
}

このヘルパーについて:

  • FrameDuration は const val 16L のコンパニオンです。計算が正確になるよう、必ず 16 ms の倍数の時間を渡してください。require(duration.mod(FrameDuration) == 0L) で確認します。
  • 先頭の waitForIdle() は使用できます。autoAdvance = false によってフレームの進行がブロックされるため、waitForIdle が待つのは setContent によってすでにトリガーされた measure/layout パスだけです。
3. 不定進捗インジケーター
// compose/material3/material3/src/androidDeviceTest/.../ProgressIndicatorTest.kt:132-156
@Test fun indeterminateLinearProgressIndicator_Progress() {
    val tag = "linear"
    rule.mainClock.autoAdvance = false                     // 1
    rule.setMaterialContent(lightColorScheme()) {
        LinearProgressIndicator(modifier = Modifier.testTag(tag))
    }
    rule.mainClock.advanceTimeByFrame()                    // kick off the infinite animation
    rule.onNodeWithTag(tag).assertRangeInfoEquals(ProgressBarRangeInfo.Indeterminate)
}

autoAdvance = false がないと、このテストは CancellationException("Infinite animations are disabled on tests") ですぐに失敗します。インジケーターのループする rememberInfiniteTransition が最初のコンポジションでポリシーに抵触するためです。

4. クロック一時停止中に状態を変更する

runOnIdle { … } は最初に waitForIdle() を呼び出します。autoAdvance = false の場合、waitForIdle() はフレームクロックの待機処理と保留中の再コンポジションを無視するため(MainTestClock.kt:107-115)、一時停止中のアニメーションでデッドロックすることはありません。ただし、measure / layout / draw と登録済みの IdlingResource は引き続き待機します。アニメーションの途中でこれを使うと、冗長なアイドル確認が入ってノイズになります。保留中の IdlingResource(Espresso のリンクやカスタムリソースなど)によって、テストがフレームの途中で停止する可能性があります。クロック一時停止中の状態変更には、アイドル確認を完全に省略する runOnUiThread を使ってください。

// CrossfadeTest.kt:86 — the canonical pattern
rule.runOnUiThread { showFirst = false }

runOnUiThread は Instrumentation.runOnMainSync(AndroidSynchronization.android.kt)を介してラムダを UI スレッドにポストし、アイドル状態を一切確認しません。アニメーションの途中で必要なのは、まさにこれです。

パターン

パターン:誤り — アニメーションを待つために Thread.sleep を使う
// WRONG
@Test fun fade_in() {
    var visible by mutableStateOf(false)
    rule.setContent {
        val alpha by animateFloatAsState(if (visible) 1f else 0f, tween(300))
        Box(Modifier.alpha(alpha).testTag("box"))
    }
    rule.runOnUiThread { visible = true }
    Thread.sleep(500)
    rule.onNodeWithTag("box").assertIsDisplayed()
}
// WRONG because: Thread.sleep desyncs from MainTestClock. The test thread sleeps; the
// scheduler sleeps with it (autoAdvance is still true, but no one is calling waitForIdle,
// so no frames are produced). The test is racing against host-driven measure/layout, not
// against the animation. (skydoves hot take #7 — Thread.sleep is a smell.)
// RIGHT
@Test fun fade_in() {
    rule.mainClock.autoAdvance = false
    var visible by mutableStateOf(false)
    rule.setContent {
        val alpha by animateFloatAsState(if (visible) 1f else 0f, tween(300))
        Box(Modifier.alpha(alpha).testTag("box"))
    }
    rule.runOnUiThread { visible = true }
    rule.mainClock.advanceTimeByFrame()                    // kick off
    rule.mainClock.advanceTimeBy(durationMillis = 300)     // step
    rule.onNodeWithTag("box").assertIsDisplayed()
}
パターン:誤り — 不定アニメーションで autoAdvance = true のままにする
// WRONG
@Test fun progressIsIndeterminate() {
    rule.setContent { LinearProgressIndicator(modifier = Modifier.testTag("p")) }
    rule.onNodeWithTag("p").assertRangeInfoEquals(ProgressBarRangeInfo.Indeterminate)
}
// WRONG because: with autoAdvance = true, the InfiniteAnimationPolicy throws
// CancellationException("Infinite animations are disabled on tests") on the first frame.
// The test fails before it reaches the assertion.
// RIGHT
@Test fun progressIsIndeterminate() {
    rule.mainClock.autoAdvance = false
    rule.setContent { LinearProgressIndicator(modifier = Modifier.testTag("p")) }
    rule.mainClock.advanceTimeByFrame()                    // kick off
    rule.onNodeWithTag("p").assertRangeInfoEquals(ProgressBarRangeInfo.Indeterminate)
}
パターン:誤り — アニメーションの途中で runOnIdle を使う
// WRONG
rule.mainClock.autoAdvance = false
rule.setContent { /* … animation … */ }
rule.mainClock.advanceTimeByFrame()
rule.mainClock.advanceTimeBy(150)
rule.runOnIdle { showFirst = false }                       // mid-animation mutation
rule.mainClock.advanceTimeBy(150)
// WRONG because: runOnIdle calls waitForIdle() first. Under autoAdvance = false,
// waitForIdle is a no-op for clock-driven work (MainTestClock.kt:107-115) — so it
// does NOT deadlock on a paused animation, but it still waits on measure/layout/draw
// AND on every registered IdlingResource. Any pending IdlingResource (e.g. the
// Espresso link, a custom resource) can stall the test mid-frame for no benefit.
// The CrossfadeTest reference implementation uses runOnUiThread for this reason.
// RIGHT
rule.runOnUiThread { showFirst = false }                   // direct post, no idle observation
rule.mainClock.advanceTimeBy(150)

(skydoves の率直な意見 #5 — 状態変更は runOnIdle または runOnUiThread を通してください。クロック一時停止中は runOnUiThread を選んでください。)

パターン:クロック一時停止中に状態駆動で待機する
// PREFERRED — test-clock wait
rule.mainClock.advanceTimeUntil(timeoutMillis = 1_000) { disposed }
// LESS PREFERRED — wall-clock; sleeps 10 ms per iteration
rule.waitUntil(timeoutMillis = 1_000) { disposed }

Skydoves の率直な意見 #4: waitUntil のタイムアウトは実時間、advanceTimeUntil はテストクロックです。取り違えるとテストが不安定になります。クロックを一時停止したアニメーションテストで、Compose の状態から確認できる条件を待つ場合は、常に advanceTimeUntil が正解です。

必須ルール

  • 必須: 中間のアニメーション状態を検証するテスト、または不確定なアニメーションを含むテストでは、setContent の前に mainClock.autoAdvance = false を設定してください。そうしないと、最初のフレームで InfiniteAnimationPolicy が CancellationException をスローします(skydoves のホットテイク #3)。
  • 必須: setContent の後(または新しいアニメーションを開始する状態切り替えの後)に mainClock.advanceTimeByFrame() を呼び出してから、advanceTimeBy(duration) で時間を進めてください。切り替え時のフレームはスケジュールするだけで、次のフレームで playTime = 0 が初期化されます(MainTestClock.kt:53-60)。
  • 必須: クロックが一時停止している間は、runOnUiThread { … } を使って状態を変更してください。アニメーションの途中で runOnIdle { … } を使用してはいけません(skydoves のホットテイク #5。CrossfadeTest.kt:86 を参照)。
  • 必須: 一時停止中のクロック期間内で Compose の状態から観測可能な条件を待つ場合は、rule.waitUntil { state } より mainClock.advanceTimeUntil { state } を優先してください(skydoves のホットテイク #4)。
  • 必須: アニメーションを待つために Thread.sleep を使用してはいけません。テストクロックとの同期が完全に失われます(skydoves のホットテイク #7)。
  • 必須: テストをまたいで autoAdvance = false のままにしてはいけません。デフォルトが true なのには理由があります。他のテストが予期せぬ影響を受けないよう、再び有効にするか、テストごとのルールのライフサイクルに任せてください。
  • 推奨: フレームごとの計算を正確にするため、FrameDuration(16 ms)の倍数となる時間を使ってください。半フレームの時間は切り上げられるため、読み手を混乱させます。
  • 推奨: コンポジションで StandardTestDispatcher を使用する v2 のエントリーポイントを優先してください。移行に関する注記は ../controlling-the-test-clock/SKILL.md を参照してください。

検証

  • すべてのアニメーションテストで、setContent の前に mainClock.autoAdvance = false を設定している。
  • すべてのアニメーションテストで、setContent の直後に advanceTimeByFrame() を1回だけ呼び出し(開始処理)、その後は advanceTimeBy(durationMillis = …) を0回以上呼び出している。
  • アニメーションテストのメソッドに Thread.sleep が存在しない(テストファイルを検索:grep -n 'Thread\.sleep' src/androidTest/**/*.kt)。
  • CI ログに CancellationException("Infinite animations are disabled on tests") が出現しない。
  • アニメーション中の状態変更には runOnIdle { … } ではなく runOnUiThread { … } を使用している。
  • 一時停止中のクロック期間内で、状態から観測可能な条件を待つ際には waitUntil ではなく mainClock.advanceTimeUntil を使用している。
  • ローカルと CI の両方で、フレークなく50回連続でテストに合格する。

参考資料

  • Android Developers — アニメーションのテスト:https://developer.android.com/develop/ui/compose/animation/testing
  • Android Developers — Compose のテスト:https://developer.android.com/develop/ui/compose/testing
  • compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/ComposeUiTest.android.kt:567-575 — InfiniteAnimationPolicy ブロック。
  • compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/MainTestClock.kt:43-78 — フレームごとの処理順序と開始処理の説明。
  • compose/animation/animation/src/androidDeviceTest/kotlin/androidx/compose/animation/CrossfadeTest.kt:70-93 — autoAdvance = false と runOnUiThread { state = … } を組み合わせた標準的なテスト。
  • compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListItemPlacementAnimationTest.kt:1724-1738 — フレームごとの割合を検証する onAnimationFrame ヘルパー。
  • compose/material3/material3/src/androidDeviceTest/kotlin/androidx/compose/material3/ProgressIndicatorTest.kt:132-156 — 不確定な進捗インジケーターのテスト手順。
  • 関連スキル:../controlling-the-test-clock/SKILL.md — フレームモデル、丸め処理、v1/v2 のディスパッチャーの違い。
  • 関連スキル:../synchronizing-with-idle/SKILL.md — waitUntil と advanceTimeUntil の違い、および Compose 以外の非同期処理での IdlingResource の使用方法。
  • 関連スキル:../../patterns/structuring-a-compose-test/SKILL.md — アニメーションテストにおける JUnit ルールの設定方法。

コメント