关于 testing-animations-deterministically
此 skill 为 Jetpack Compose 动画测试提供手动控制测试时钟的方法,面向需要验证动画中间状态或测试无限动画的 Android 开发者。内容包括关闭自动推进、逐帧推进以及动画期间在 UI 线程修改状态。
主要功能
- 01在设置内容前关闭自动推进
- 02手动推进动画帧与时间
- 03测试无限动画
- 04动画期间使用 UI 线程修改状态
适用场景
- 01断言动画的中间帧
- 02测试循环进度指示器
- 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 规则配置。