mcpField
C
controlling-the-test-clock

controlling-the-test-clock

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

关于 controlling-the-test-clock

面向 Android Compose 测试开发者的参考技能,说明 MainTestClock 如何驱动帧、重组、动画和协程任务。内容解释时间推进规则及 v1 到 v2 测试方式的差异。

主要功能

  1. 01讲解 MainTestClock 与调度器
  2. 02说明 16 ms 帧周期
  3. 03解释帧时长的向上取整
  4. 04介绍自动与手动时钟控制
  5. 05说明 v2 中排队任务的处理

适用场景

  1. 01排查推进时间后的帧数异常
  2. 02将 Compose 测试从 v1 迁移到 v2
  3. 03推进测试直至满足 Compose 状态条件

安装

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

$npx skills add https://github.com/skydoves/android-testing-skills --skill controlling-the-test-clock

安装后在 Claude Code 中输入/controlling-the-test-clock确认已加载。

详细介绍

控制测试时钟——帧、时间与 Recomposer

在 Compose 测试中,MainTestClock 是驱动重组、动画和 LaunchedEffect 的唯一旋钮。本技能介绍它的接口、其背后的 16 ms 帧模型,以及 autoAdvance 如何将测试从“框架驱动时钟”切换为“测试驱动时钟”。动画方案和空闲/等待方案分别位于相邻技能中;本技能会介绍它们共同依赖的机制。

何时使用本技能

  • 开发者询问 mainClock.advanceTimeBy(...) 实际会做什么、为什么会向上取整,或者为什么动画看起来差一帧。
  • 开发者正从 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) 会产生零帧、一帧还是多帧。
  • 开发者询问为什么经过一帧后,动画的 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。Recomposer 会从 TestMonotonicFrameClock 读取帧;它的 withFrameNanos 是通过针对同一个调度器调用 delay(frameDelayMillis) 实现的(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()精确推进一帧(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_000L ns = 16 ms(TestMonotonicFrameClock.jvmAndAndroid.kt:33)。
  • performFrame 内部每帧的执行顺序:
    1. 所有 withFrameNanos 等待者都会以新的帧时间恢复执行。动画在此运行。
    2. 执行 onPerformTraversals——组合 + 测量 + 布局。
    3. 分发等待者排队的恢复执行任务。

引自 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. 选择合适的推进方法
目标调用
“恰好运行一帧。”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")

如果 isStandardTestDispatcherSupportEnabled 为 true,AbstractMainTestClock.advanceTimeUntil 会先调用 scheduler.runCurrent()(AbstractMainTestClock.kt:59-61)。

4. 将 ComposeTimeoutException 视为对条件的判定,而不是对时钟的判定

当测试时钟推进超过 timeoutMillis,但 condition() 仍未返回 true 时,advanceTimeUntil 会抛出 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() 比较毫无意义。
  • 必须在意图是“恰好一帧”时调用 advanceTimeByFrame()(而非 advanceTimeBy(16))。这能清楚表达意图,也避免考虑舍入问题。
  • 必须了解 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)。
  • 不得将 advanceTimeUntil 用于 Compose 快照系统之外的条件(例如 Job.isCompleted、OkHttp 回调)。这些条件不会因测试时钟走动而变为真——此时应使用 waitUntil(实际时间)或 IdlingResource。参见 ../synchronizing-with-idle/SKILL.md。
  • 不得混淆 MainTestClock 和 MonotonicFrameClock。它们是不同类型。MainTestClock 暴露的调度器会驱动重组器使用的 TestMonotonicFrameClock。
  • **优先使用:**v2 入口点,使分发器与 kotlinx.coroutines.test.runTest 语义一致。v1 的 createComposeRule / runComposeUiTest 形式已弃用,并标记为 WARNING。
  • **优先使用:**只要等待的条件可通过 Compose 状态观察到,就优先使用 advanceTimeUntil 而非 waitUntil。测试时钟更快且确定;实际时间方式每次迭代会休眠 10 ms。

验证

  • 所有调用点都使用 advanceTimeByFrame() 表示“一帧”;仅当时长本身有意义时才使用 advanceTimeBy(...)。
  • 不存在未附注释说明意图或未设置 ignoreFrameDuration = true 的 advanceTimeBy(milliseconds = X) 调用,其中 X < 16。
  • 从 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 实现。
  • 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 之间进行选择。

评论

支持 Markdown