mcpField
T
testing-animations-deterministically

testing-animations-deterministically

github.com/skydoves测试与质量339121 次安装22 周前更新
代码仓库

关于 testing-animations-deterministically

此 skill 为 Jetpack Compose 动画测试提供手动控制测试时钟的方法,面向需要验证动画中间状态或测试无限动画的 Android 开发者。内容包括关闭自动推进、逐帧推进以及动画期间在 UI 线程修改状态。

主要功能

  1. 01在设置内容前关闭自动推进
  2. 02手动推进动画帧与时间
  3. 03测试无限动画
  4. 04动画期间使用 UI 线程修改状态

适用场景

  1. 01断言动画的中间帧
  2. 02测试循环进度指示器
  3. 03排查动画测试的 CI 波动

安装

复制下面的命令,在终端中运行。

$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;有限动画则会在一次自动推进中直接结束,中间状态无从观察。本技能介绍修复这一问题的五行方案,以及每个初次尝试的开发者都会踩到的 runOnUiThread 与 runOnIdle 陷阱。

何时使用此技能

  • 测试要断言动画的中间帧(淡入淡出过程中的 alpha、滚动过程中的偏移量、Crossfade 释放过程中的状态)。
  • 可组合项包含不确定动画(不带 progress 参数的 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 hot take #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。

默认设置为何会失败

当测试规则构建运行环境时,它会在 recomposer 上安装 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 hot take #3:**动画测试必须设置 mainClock.autoAdvance = false。没有其他受支持的途径。

五行方案

@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 是错误的:第一次组合就可能启动不确定动画,而在测试接管之前,框架已经开始自动推进时钟。

启动帧的原因

摘自 MainTestClock.kt:53-60:

由于动画在重组_之前_接收帧时间,因此通过切换状态变量启动动画后,动画不会在第一帧获得其启动时间。……动画会在[下一帧]获得其第一个帧时间,并将播放时间初始化为 t=0。

换句话说:切换状态的帧负责调度。下一帧才是 playTime = 0。如果没有启动用的 advanceTimeByFrame(),动画时长最初的 16 ms 会被设置过程消耗,测试读取到的状态就会落后于预期。这就是“我的动画偏了一帧”的根本原因。

工作流

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 是 companion 中的 const val 16L。传入的时长始终应为 16 ms 的倍数——它会通过 require(duration.mod(FrameDuration) == 0L) 确保计算准确。
  • 开头的 waitForIdle() 可以使用,因为 autoAdvance = false 会阻止帧推进;waitForIdle 只会等待 setContent 已触发的测量/布局过程。
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),因此不会在暂停的动画上死锁,但仍会等待测量/布局/绘制以及所有已注册的 IdlingResource。在动画进行期间,实际效果只是多余的空闲检查,会增加干扰:任何待处理的 IdlingResource(Espresso 链接或自定义资源)都可能让测试在帧中途停滞。在时钟暂停期间修改状态时,请使用 runOnUiThread——它会完全跳过空闲检查:

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

runOnUiThread 会通过 Instrumentation.runOnMainSync 将 lambda 提交到 UI 线程(AndroidSynchronization.android.kt),且不会进行任何空闲状态观测。这正是动画进行期间所需的行为。

模式

模式:错误做法——使用 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。

强制规则

  • 必须在任何断言动画中间状态或包含不确定动画的测试中,先设置 mainClock.autoAdvance = false,再调用 setContent。否则,InfiniteAnimationPolicy 会在第一帧抛出 CancellationException(skydoves 热评 #3)。
  • 必须在调用 setContent 后(或在任何启动新动画的状态切换后)调用 mainClock.advanceTimeByFrame(),然后再通过 advanceTimeBy(duration) 推进时间。切换状态的那一帧只负责调度;下一帧才会初始化 playTime = 0(MainTestClock.kt:53-60)。
  • 时钟暂停时,必须通过 runOnUiThread { … } 修改状态。动画进行期间不得使用 runOnIdle { … }(skydoves 热评 #5;参见 CrossfadeTest.kt:86)。
  • 对于暂停时钟期间可通过 Compose 状态观察的条件,必须优先使用 mainClock.advanceTimeUntil { state },而不是 rule.waitUntil { state }(skydoves 热评 #4)。
  • 等待动画时不得使用 Thread.sleep。它会导致测试时钟完全失步(skydoves 热评 #7)。
  • 不得在测试之间保持 autoAdvance = false。默认值设为 true 自有原因;请重新启用它(或依赖每个测试的规则生命周期),以免影响其他测试。
  • **优先采用:**持续时间设为 FrameDuration(16 ms)的整数倍,以确保逐帧计算精确。半帧时长会向上取整,容易让读者意外。
  • **优先采用:**使用 v2 入口,以便组合使用 StandardTestDispatcher。迁移说明请参阅 ../controlling-the-test-clock/SKILL.md。

验证

  • 每个动画测试都在调用 setContent 之前设置 mainClock.autoAdvance = false。
  • 每个动画测试都在 setContent 后立即调用且仅调用一次 advanceTimeByFrame()(启动帧),之后可以调用零次或多次 advanceTimeBy(durationMillis = …)。
  • 任何动画测试方法中都不存在 Thread.sleep(在测试文件中搜索:grep -n 'Thread\.sleep' src/androidTest/**/*.kt)。
  • CI 日志中未出现 CancellationException("Infinite animations are disabled on tests")。
  • 动画中途的状态修改使用 runOnUiThread { … },而不是 runOnIdle { … }。
  • 暂停时钟期间对可观察状态的等待使用 mainClock.advanceTimeUntil,而不是 waitUntil。
  • 测试在本地和 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 规则配置。

评论

支持 Markdown