mcpField
S
synchronizing-with-idle

synchronizing-with-idle

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

关于 synchronizing-with-idle

面向 Jetpack Compose 测试编写者的技能,梳理各类等待与空闲 API 的语义和超时来源,并提供选择方法,帮助处理异步 UI 同步问题。

主要功能

  1. 01解释 Compose 的空闲条件
  2. 02对比等待 API 与超时
  3. 03提供按条件选择的决策表
  4. 04说明 IdlingResource 与 Espresso 桥接
  5. 05给出状态读写和断言模式

适用场景

  1. 01排查节点缺失或数量不符的间歇性失败
  2. 02等待 Snackbar、导航或 IO 状态更新
  3. 03判断 Compose 状态与外部任务该用哪种等待
  4. 04为异步外部工作注册 IdlingResource

安装

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

$npx skills add https://github.com/skydoves/android-testing-skills --skill synchronizing-with-idle

安装后在 Claude Code 中输入/synchronizing-with-idle确认已加载。

详细介绍

与空闲状态同步——选择合适的等待方式

Compose 测试使用了错误的等待原语,是导致测试不稳定的头号原因。本技能列出了 ComposeTestRule 和 ComposeUiTest 上所有空闲/等待 API,明确界定“空闲”的实际含义,并提供选择指南。动画专用的等待方式见 ../testing-animations-deterministically/SKILL.md;底层时钟语义见 ../controlling-the-test-clock/SKILL.md。

何时使用本技能

  • 测试偶尔因“未找到节点”或“节点数量不匹配”而失败,但生产代码没有问题。
  • 开发者想用 Thread.sleep(2000) 等待屏幕、Snackbar、导航过渡或由 IO 驱动的状态。
  • ViewModel 通过协程发布状态,测试需要等待 UI 反映该状态。
  • 开发者询问 IdlingResource、Espresso 桥接、runOnIdle 与 runOnUiThread 的区别,或 runWhenIdle 是否更快。
  • 开发者提到 waitUntil、waitUntilExactlyOneExists、waitUntilNodeCount、AndroidComposeUiTestTimeoutException 或 IdlingPolicies.setMasterPolicyTimeout。
  • 开发者在测试线程读取或修改状态(例如 state.firstVisibleItemIndex),并询问为什么有时会得到过期数据。

何时不使用本技能

  • 条件涉及暂停时钟上的动画。请使用 ../testing-animations-deterministically/SKILL.md 和 mainClock.advanceTimeUntil。
  • 不清楚 MainTestClock 本身的机制(帧、舍入、调度器)。请先阅读 ../controlling-the-test-clock/SKILL.md。
  • 测试失败的原因是测试标签/查找器问题,而非异步时序。请使用 ../../debug/printing-the-semantics-tree/SKILL.md 和 ../../finders/finding-nodes-by-tag-text-content/SKILL.md。
  • 实际问题涉及 Espresso 互操作。请使用 ../../interop/testing-with-espresso-interop/SKILL.md(同级技能,范围独立)。

前置条件

  • androidx.compose.ui:ui-test 和 androidx.compose.ui:ui-test-junit4(或 runComposeUiTest)。
  • 来自 createComposeRule() 的 ComposeContestTestRule(首选 v2:androidx.compose.ui.test.junit4.v2.createComposeRule)。
  • 对于实验性的 waitUntilNodeCount/waitUntilExactlyOneExists/waitUntilAtLeastOneExists/waitUntilDoesNotExist:在测试类或方法上添加 @OptIn(ExperimentalTestApi::class)。

“空闲”的含义

以下三个条件必须同时成立(compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/ComposeUiTest.kt:174-186):

  1. 没有待处理的重组。 没有待处理的快照应用通知,没有待处理工作的重组器,也没有在测试帧时钟上等待的 withFrameNanos 调用。
  2. 没有待处理的绘制调用。 测量和布局流程已经运行;在 Android 上,框架已请求绘制流程(Android 上由 Choreographer 驱动绘制,测试时钟不会驱动绘制——MainTestClock.kt:36-41)。
  3. 所有已注册的 IdlingResource 均报告 isIdleNow == true。 非 Compose 异步工作(如 OkHttp 调用、Room 查询)通过这种方式参与同步。

Compose 自身的工作会通过 ComposeIdlingResource 自动注册(compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/ComposeIdlingResource.android.kt),它会循环排空重组器、快照和帧时钟等待项,每次调用最多 100 帧(第 109 行)。如果测试需要超过 100 帧才能稳定下来,说明每次 waitForIdle 要处理的工作过多。

基础 API

以下各项均来自 ComposeUiTest.kt / ComposeTestRule.jvmAndAndroid.kt。

API效果超时来源
waitForIdle()阻塞当前线程,直到满足三个空闲条件。启用 autoAdvance = true 时,还会自动推进时钟以清空待处理工作。墙上时钟 — IdlingPolicies.getMasterIdlingPolicy()
awaitIdle()waitForIdle 的 suspend 变体。相同
waitUntil(conditionDescription, timeoutMillis = 1_000, condition)阻塞,直到 condition() 返回 true。每次迭代都会调用 mainClock.advanceTimeByFrame()(当 autoAdvance == true 时)并调用墙上时钟的 Thread.sleep(10)(ComposeUiTest.android.kt:899-902)。超时依据 System.nanoTime() 测量,因此即使测试时钟也在推进,超时仍按墙上时钟计算。墙上时钟 — 每次调用的 timeoutMillis
waitUntilNodeCount(matcher, count, timeoutMillis = 1_000L)@ExperimentalTestApi。通过 waitUntil 检查 onAllNodes(matcher).fetchSemanticsNodes(atLeastOneRootRequired = false).size == count(ComposeUiTest.kt:275-285)。墙上时钟
waitUntilExactlyOneExists(matcher, timeoutMillis = 1_000L)@ExperimentalTestApi。waitUntilNodeCount(matcher, 1, timeoutMillis) 的语法糖。墙上时钟
waitUntilAtLeastOneExists(matcher, timeoutMillis = 1_000L)@ExperimentalTestApi。(ComposeUiTest.kt:298-305)墙上时钟
waitUntilDoesNotExist(matcher, timeoutMillis = 1_000L)@ExperimentalTestApi。waitUntilNodeCount(matcher, 0, timeoutMillis) 的语法糖。墙上时钟
runOnIdle { … }先调用 waitForIdle(),再调用 runOnUiThread { … }。状态修改和读取的默认方式。墙上时钟(内部的 waitForIdle)
runOnUiThread { … }通过 Instrumentation.runOnMainSync 提交一个 FutureTask(AndroidSynchronization.android.kt)。如果已在 UI 线程,则就地运行。不会等待空闲。无
runWhenIdle { … }先调用 waitForIdle(),再调用 runOnUiThread { … },但会抑制 lambda 内节点查询触发的隐式 waitForIdle。仅用于断言的代码块执行更快。不得在内部修改状态。墙上时钟
awaitAndRunWhenIdle { … }runWhenIdle 的 suspend 变体。墙上时钟
hasPendingWork(): Boolean被动快照:主时钟上是否有等待者、是否有快照更改,或重组器是否有待处理工作?仅在 autoAdvance = false 时可靠(ComposeUiTest.kt:251-256)。无
IdlingResource 注册rule.registerIdlingResource(myResource) / unregisterIdlingResource。墙上时钟

超时来源 — 不要混淆

API来源默认值覆盖方式
waitForIdle / awaitIdle墙上时钟 — Espresso IdlingPolicies 主超时26 s在 @Before 中调用 IdlingPolicies.setMasterPolicyTimeout(...)
waitUntil(…) 系列墙上时钟每次调用 1000 ms每次调用设置 timeoutMillis
MainTestClock.advanceTimeUntil(…)测试时钟每次调用 1000 ms每次调用设置 timeoutMillis
runComposeUiTest(testTimeout = …)墙上时钟60.secondsrunComposeUiTest(testTimeout = 5.minutes) { … };抛出 AndroidComposeUiTestTimeoutException

Skydoves 热点观点 #4:waitUntil 的超时按墙上时钟计算;advanceTimeUntil 按测试时钟计算。等待条件可通过 Compose 状态观察时,始终优先使用 mainClock.advanceTimeUntil。仅当条件不属于 Compose 快照系统时才使用 waitUntil,例如 Job.isCompleted、由 LaunchedEffect 增加的计数器,或外部服务。

RobolectricIdlingStrategy 使用相同的 Espresso IdlingPolicies.getMasterIdlingPolicy(),因此全局调用 setMasterPolicyTimeout 延长超时时间,同样适用于主机(Robolectric)和设备测试。

Espresso 桥接

Espresso 有自己的 androidx.test.espresso.IdlingResource 接口,它与 Compose 的 androidx.compose.ui.test.IdlingResource 并不相同。桥接接口是 EspressoLink(compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/EspressoLink.android.kt):

Espresso (instrumentation tests)
        │ withStrategy { … }
        ▼
EspressoLink   ──implements──>  androidx.test.espresso.IdlingResource
        │
        │  delegates isIdleNow() to ──>  IdlingResourceRegistry
        ▼
IdlingResourceRegistry — Compose's registry
        │
        ├── ComposeIdlingResource   (recomposer + snapshot + frame-clock awaiters)
        └── any rule.registerIdlingResource(...)

主机测试中的 RobolectricIdlingStrategy 也会读取同一个注册表。开发者注册 Compose IdlingResource;桥接由框架完成。

interface IdlingResource {
    val isIdleNow: Boolean
    fun getDiagnosticMessageIfBusy(): String? = null
}

(摘自 compose/ui/ui-test/src/jvmAndAndroidMain/kotlin/androidx/compose/ui/test/IdlingResource.kt:37-52。)重写 getDiagnosticMessageIfBusy(),以便在等待超时时显示有用的信息 — 它会追加到超时异常文本中。

决策矩阵

Need to wait for…                         | Use
------------------------------------------|----------------------------------------------
A node count to stabilize                 | waitUntilNodeCount / waitUntilExactlyOneExists
A node to appear                          | waitUntilAtLeastOneExists
A node to disappear                       | waitUntilDoesNotExist
A Compose state condition                 | mainClock.advanceTimeUntil { state }
A non-Compose condition (Job, counter)    | waitUntil(conditionDescription) { condition }
"Just settle the UI"                      | waitForIdle()  (or runOnIdle for read+act)
External async work (HTTP, DB)            | rule.registerIdlingResource(MyIdlingResource)
A paused-clock animation frame            | mainClock.advanceTimeBy(...)  -- different skill
A particular state.value to read safely   | rule.runOnIdle { state.value }

工作流

1. 与状态交互时,默认使用 runOnIdle

在测试线程中读取或修改 Compose 状态会与 recomposer 发生竞争。recomposer 会在主线程上应用快照写入;测试线程可能会观察到尚未完全应用的状态。runOnIdle 会等待系统空闲,然后将操作派发到 UI 线程,从而解决此问题:

val firstVisibleIndex = rule.runOnIdle { state.firstVisibleItemIndex }

(skydoves 的犀利观点 #5 — 将状态变更统一通过 runOnIdle 或 runOnUiThread 处理。)

2. 只读断言代码块应使用 runWhenIdle
rule.runWhenIdle {
    val node = rule.onNodeWithTag("counter").fetchSemanticsNode()
    assertEquals("3", node.config[SemanticsProperties.Text].first().text)
    val rect = node.boundsInRoot
    // ... more reads ...
}

在普通的 runOnIdle 中,每次节点查询都会重新运行 waitForIdle()。在 runWhenIdle 中,由于代码块开始前已确认系统空闲,因此这些隐式等待会被抑制。这对手动逐帧推进的测试很重要 — 在 runOnIdle 中进行节点查询会反复调用 waitForIdle(),而在 autoAdvance = true 时,它会自动推进时钟,破坏测试的手动控制。不得在 runWhenIdle 中修改状态。

3. 等待 N 个节点匹配时,使用实验性的 waitUntilExactlyOneExists 等方法
@OptIn(ExperimentalTestApi::class)
@Test fun snackbarAppears() {
    rule.setContent { /* … triggers a snackbar after 200 ms … */ }
    rule.onNodeWithTag("trigger").performClick()
    rule.waitUntilExactlyOneExists(hasTestTag("snackbar"), timeoutMillis = 2_000)
    rule.onNodeWithTag("snackbar").assertTextEquals("Saved")
}

默认的 1000 ms 超时时间(ComposeUiTest.kt:278、ComposeUiTest.kt:301、ComposeUiTest.kt:321、ComposeUiTest.kt:334)对于 snackbar 和底部弹出层动画来说通常太短。请在每次调用时显式调高。

4. 仅在等待非 Compose 条件时使用 waitUntil { … }
// SnackbarHostTest.kt:77-88
val job = scope.launch {
    hostState.showSnackbar("1")
    Truth.assertThat(resultedInvocation).isEqualTo("1")
    hostState.showSnackbar("2")
    Truth.assertThat(resultedInvocation).isEqualTo("12")
    hostState.showSnackbar("3")
    Truth.assertThat(resultedInvocation).isEqualTo("123")
}
rule.waitUntil { job.isCompleted }

Job.isCompleted 不是 Compose 状态;mainClock.advanceTimeUntil { job.isCompleted } 会循环并超时,因为无论时钟推进多少,Job 都不会自行完成。

5. 对真正的外部异步工作注册 IdlingResource
class HttpIdlingResource(private val client: OkHttpClient) : IdlingResource {
    override val isIdleNow: Boolean
        get() = client.dispatcher.runningCallsCount() == 0
    override fun getDiagnosticMessageIfBusy(): String? =
        "${client.dispatcher.runningCallsCount()} HTTP calls still in flight"
}

@Before fun setUp() { rule.registerIdlingResource(HttpIdlingResource(client)) }
@After  fun tearDown() { rule.unregisterIdlingResource(HttpIdlingResource(client)) }

注册后,每次 waitForIdle(以及每次节点查询中隐式调用的 waitForIdle)都会阻塞,直到 isIdleNow == true。必须确保 isIdleNow 足够轻量 — 它会在主线程上调用(IdlingResource.kt:38-46)。

模式

模式:错误 — 使用 Thread.sleep 等待节点
// WRONG
@Test fun successScreen() {
    rule.setContent { App() }
    rule.onNodeWithTag("login").performClick()
    Thread.sleep(2_000)
    rule.onNodeWithTag("dashboard").assertExists()
}
// WRONG because: Thread.sleep is unrelated to Compose's synchronization. It either over-
// waits (slowing the test) or under-waits (flake when CI is slow). Compose's own
// IdlingResource is already wired; just wait on the node directly.
// RIGHT
@OptIn(ExperimentalTestApi::class)
@Test fun successScreen() {
    rule.setContent { App() }
    rule.onNodeWithTag("login").performClick()
    rule.waitUntilExactlyOneExists(hasTestTag("dashboard"), timeoutMillis = 2_000)
    rule.onNodeWithTag("dashboard").assertExists()
}
模式:错误 — 使用 waitUntil 等待可通过 Compose 状态观察的条件
// WRONG
rule.waitUntil(timeoutMillis = 5_000) { state.value == Phase.Done }
// WRONG because: timeout is measured in wall clock and each iteration burns ~10 ms
// of real time on Thread.sleep(10) (ComposeUiTest.android.kt:899-902). For a Compose
// state condition, advanceTimeUntil drives the test clock and is deterministic.
// RIGHT
rule.mainClock.advanceTimeUntil(timeoutMillis = 5_000) { state.value == Phase.Done }

(skydoves 的犀利观点 #4。)

模式:错误 — 从测试线程读取状态
// WRONG
val i = state.firstVisibleItemIndex
assertEquals(2, i)
// WRONG because: the test thread races the recomposer. The read may observe a half-applied
// snapshot, especially right after a click or scroll. Compose's docs are explicit: state
// reads from outside the UI thread are not synchronized.
// RIGHT
val i = rule.runOnIdle { state.firstVisibleItemIndex }
assertEquals(2, i)

(skydoves 的犀利观点 #5。)

模式:hasPendingWork() — 仅用于暂停时钟的被动检查
rule.mainClock.autoAdvance = false
rule.setContent { /* … */ }
rule.mainClock.advanceTimeByFrame()
rule.runOnUiThread { trigger = true }
assertTrue(rule.hasPendingWork())              // recomposition queued, no frame yet
rule.mainClock.advanceTimeByFrame()
assertFalse(rule.hasPendingWork())             // frame applied

摘自 ComposeUiTest.kt:251-256:

如果 autoAdvance 为 true,测试框架会持续处理待处理的工作。在这种情况下,调用此方法得到的只是某一时刻的快照,通常会返回 false。如果工作已排队但框架尚未自动推进,它可能会短暂返回 true,因此结果转瞬即逝、不可靠,不适合用于驱动测试逻辑。

当 autoAdvance == true 时,不得依据 hasPendingWork() 驱动测试逻辑。

模式:为缓慢的 CI 环境调高主空闲超时
@Before fun setUp() {
    IdlingPolicies.setMasterPolicyTimeout(60, TimeUnit.SECONDS)
}

当 waitForIdle(而非 waitUntil)在 CI 上超时、但在本地通过时使用。EspressoLink 和 RobolectricIdlingStrategy 都遵循此策略。

强制规则

  • 必须优先使用 mainClock.advanceTimeUntil { state },而不是 rule.waitUntil { state },前提是等待条件读取的是 Compose 状态(skydoves 热点观点 #4)。
  • 必须将测试线程中的状态读取包装在 runOnIdle { … } 或 runOnUiThread { … } 中。在测试线程直接读取 state.value 会与重组器发生竞争(skydoves 热点观点 #5)。
  • 必须在时钟暂停(autoAdvance = false)时,使用 runOnUiThread(而不是 runOnIdle)进行状态修改;runOnIdle 隐式调用的 waitForIdle 不适用于该模式。请参阅 ../testing-animations-deterministically/SKILL.md。
  • 必须对包含大量节点读取的纯断言代码块使用 runWhenIdle { … }——它会抑制多余的逐次查询 waitForIdle。不得在其中修改状态。
  • 必须优先使用 waitUntilExactlyOneExists / waitUntilAtLeastOneExists / waitUntilDoesNotExist,而不是手动编写 waitUntil { onAllNodes(...).fetchSemanticsNodes().isNotEmpty() }。这些方法更清晰,并会在超时消息中提供 conditionDescription。
  • 不得在 autoAdvance == true 时将 hasPendingWork() 视为可采取行动的依据。它只是被动快照,只有在时钟暂停时才可靠(ComposeUiTest.kt:251-256)。
  • 不得使用 Thread.sleep 等待状态稳定。请改用 waitUntil*、mainClock.advanceTimeUntil 或 IdlingResource(skydoves 热点观点 #7)。唯一合理的 Thread.sleep 用法是在 RenderThread 上进行 ripple/截图测试。
  • **优先:**为每个真实的外部异步源(HTTP、DB、外部计数器)使用一个 IdlingResource,在 @Before 中注册,在 @After 中注销。重写 getDiagnosticMessageIfBusy(),让超时消息更有用。
  • **优先:**在 @Before 中使用 IdlingPolicies.setMasterPolicyTimeout(...),以应对 CI 上因 Agent 运行缓慢导致的偶发失败——它会统一延长 Espresso 和 Robolectric 的超时时间。

验证

  • 除了文档中说明的 RenderThread/截图例外,所有测试方法中都不存在 Thread.sleep。
  • 测试线程中的每次状态读取都已包装在 runOnIdle { … } 中(搜索:grep -nP 'state\.\w+' src/androidTest)。
  • 对 Compose 状态条件使用的每个 waitUntil { state.value … } 都已迁移为 mainClock.advanceTimeUntil { state.value … }。
  • 每种“等待节点”逻辑都使用 waitUntilExactlyOneExists / waitUntilAtLeastOneExists / waitUntilDoesNotExist,而不是对 fetchSemanticsNodes() 手动编写 waitUntil。
  • 每个外部异步源都有一个 IdlingResource,并在 @Before 中注册、在 @After 中注销。
  • 没有任何调用点在 autoAdvance == true 时依赖 hasPendingWork()。
  • CI 运行连续完成 50 次迭代,且没有出现 AndroidComposeUiTestTimeoutException 或 ComposeTimeoutException 偶发失败。

参考资料

  • Android Developers — Compose 测试:https://developer.android.com/develop/ui/compose/testing
  • Android Developers — Compose 测试速查表:https://developer.android.com/develop/ui/compose/testing-cheatsheet
  • Espresso IdlingPolicies 参考:https://developer.android.com/reference/androidx/test/espresso/IdlingPolicies
  • compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/ComposeUiTest.kt:174-260 — waitForIdle、awaitIdle、waitUntil、runOnIdle、runOnUiThread、runWhenIdle、awaitAndRunWhenIdle、hasPendingWork。
  • compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/ComposeUiTest.kt:275-335 — 实验性的 waitUntilNodeCount / waitUntilExactlyOneExists / waitUntilAtLeastOneExists / waitUntilDoesNotExist 扩展。
  • compose/ui/ui-test/src/jvmAndAndroidMain/kotlin/androidx/compose/ui/test/IdlingResource.kt — 公共 IdlingResource 接口。
  • compose/ui/ui-test/src/jvmAndAndroidMain/kotlin/androidx/compose/ui/test/IdlingResourceRegistry.jvmAndAndroid.kt — 汇总用户注册的资源与 Compose 自身资源的注册表。
  • compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/ComposeIdlingResource.android.kt — Compose 自动注册的资源;第 109 行设有 100 帧上限。
  • compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/EspressoLink.android.kt — androidx.test.espresso.IdlingResource 桥接。
  • compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/ComposeUiTest.android.kt:885-908 — waitUntil 每隔 10 ms 进行一次墙上时钟轮询。
  • compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/AndroidSynchronization.android.kt — runOnUiThread 通过 Instrumentation.runOnMainSync 提交 FutureTask。
  • compose/material3/material3/src/androidDeviceTest/kotlin/androidx/compose/material3/SnackbarHostTest.kt:77-88 — 典型的 rule.waitUntil { job.isCompleted }。
  • 同级技能:../controlling-the-test-clock/SKILL.md — MainTestClock、帧模型、advanceTimeUntil。
  • 同级技能:../testing-animations-deterministically/SKILL.md — 使用暂停时钟的动画测试。
  • 同级技能:../../patterns/structuring-a-compose-test/SKILL.md — 空闲等待期间的 JUnit 生命周期。
  • 同级技能:../../debug/printing-the-semantics-tree/SKILL.md — “未找到节点”实际是查找器问题而非异步问题时的处理方法。

评论

支持 Markdown