默认情况下,Compose 测试会与界面同步。通过 ComposeTestRule 调用断言或操作时,测试将预先同步,直到界面树处于空闲状态。
通常,您无需执行任何操作。但是,您应该了解一些极端情况。
同步测试时,您可以使用虚拟时钟将 Compose 应用的时间提前。这意味着 Compose 测试不会实时运行,从而能够尽快通过测试。
但是,如果您不使用同步测试的方法,则不会发生任何重组,并且界面会暂停。
@Test
fun counterTest() {
val myCounter = mutableStateOf(0) // State that can cause recompositions.
var lastSeenValue = 0 // Used to track recompositions.
composeTestRule.setContent {
Text(myCounter.value.toString())
lastSeenValue = myCounter.value
}
myCounter.value = 1 // The state changes, but there is no recomposition.
// Fails because nothing triggered a recomposition.
assertTrue(lastSeenValue == 1)
// Passes because the assertion triggers recomposition.
composeTestRule.onNodeWithText("1").assertExists()
}请注意,此要求仅适用于 Compose 层次结构,而不适用于应用的其余部分。
停用自动同步功能
通过 ComposeTestRule(如 assertExists())调用断言或操作时,您的测试会与 Compose 界面同步。在某些情况下,您可能需要停止此同步并自行控制时钟。例如,您可以控制时间,以便在界面仍处于繁忙状态时对动画进行精确截图。如需停用自动同步功能,请将 mainClock 中的 autoAdvance 属性设置为 false:
composeTestRule.mainClock.autoAdvance = false
一般情况下,您需要自行将时间提前。您可以使用 advanceTimeByFrame() 仅提前一帧,或使用 advanceTimeBy() 提前一段特定时间:
composeTestRule.mainClock.advanceTimeByFrame()
composeTestRule.mainClock.advanceTimeBy(milliseconds)
空闲资源
Compose 可以同步测试和界面,以便以空闲状态完成各项操作和断言,从而根据需要等待或将时钟提前。但是,某些影响界面状态的异步操作可在后台运行,而测试无法得知这些结果。
您可以在测试中创建并注册这些空闲资源,以便在确定受测应用是忙碌还是空闲时将这些资源考虑在内。除非需要注册其他空闲资源(例如,如果您运行的后台作业未与 Espresso 或 Compose 同步),否则无需执行任何操作。
此 API 与 Espresso 的空闲资源非常相似,用于指示受测对象是空闲还是忙碌。您可以使用 Compose 测试规则注册 IdlingResource 的实现。
composeTestRule.registerIdlingResource(idlingResource)
composeTestRule.unregisterIdlingResource(idlingResource)
手动同步
在某些情况下,您必须将 Compose 界面与测试的其他部分或您测试的应用同步。
waitForIdle() 函数会等待 Compose 空闲,但该函数取决于 autoAdvance 属性:
composeTestRule.mainClock.autoAdvance = true // Default
composeTestRule.waitForIdle() // Advances the clock until Compose is idle.
composeTestRule.mainClock.autoAdvance = false
composeTestRule.waitForIdle() // Only waits for idling resources to become idle.
请注意,在这两种情况下,waitForIdle() 还会等待待定的绘制和布局通过。
此外,您还可以将时钟提前,直到满足 advanceTimeUntil() 的特定条件为止。
composeTestRule.mainClock.advanceTimeUntil(timeoutMs) { condition }
请注意,给定条件应当检查受到此时钟影响的状态(仅适用于 Compose 状态)。
优化动画测试
测试高保真动画时,您通常需要停用自动前进功能,并手动逐帧浏览,以断言中间界面状态。对于这些特定的逐帧循环,请使用 runWithoutImplicitWait 方法来执行断言。当您手动控制时钟时,标准节点查询(例如 onNodeWithTag 或 fetchSemanticsNode)会触发冗余的隐式同步,因此绕过这些查询可以显著缩短测试运行时长。
使用指南
- 手动时钟管理:当
mainClock.autoAdvance设置为false且界面处于当前帧的已知稳定状态时,请使用此 API。 - 界面线程执行:为确保界面树的稳定性,请在界面线程上调用
runWithoutImplicitWait,例如使用runOnUiThread。在界面线程之外运行它会使您的测试面临竞态条件和过时的状态读取。 - 只读断言:相应代码块应严格包含只读断言。任何会改变状态的操作都应在此代码块之外执行。
示例
@Test fun runWithoutImplicitWaitSample() = runComposeUiTest { setContent { MainScreen() } mainClock.autoAdvance = false // Trigger an animation onNodeWithText("Start Animation").performClick() // Step through the animation frame-by-frame while (hasPendingWork()) { mainClock.advanceTimeByFrame() waitForIdle() runOnUiThread { // Suppress implicit synchronization inside this block to avoid redundant // waits on each node query, making the frame assertions execute much faster. runWithoutImplicitWait { val box1 = onNodeWithTag("Box1").fetchSemanticsNode() val box2 = onNodeWithTag("Box2").fetchSemanticsNode() val box3 = onNodeWithTag("Box3").fetchSemanticsNode() // Assert the exact intermediate state of all three properties for this frame assert(box1.boundsInRoot.right <= box2.boundsInRoot.left) assert(box2.boundsInRoot.right <= box3.boundsInRoot.left) } } } }
主线程同步
Compose 测试现在支持主线程同步,让您可以直接从主线程安全地调用 waitForIdle(以及 Compose 界面操作和断言)。
以前,Compose 测试严格执行双线程模型:测试作业在后台测试线程上进行,而界面更新在主线程上进行。从主线程(例如,在 runOnUiThread 块内)调用 waitForIdle 或 runOnIdle 等同步方法会抛出 IllegalStateException,因为框架强制执行严格的线程检查以防止主线程同步。
启用主线程同步后,即使在主线程上进行阻塞调用,Compose 测试框架现在也可以推进时钟并处理待处理的工作。
何时使用主线程同步
虽然将测试保留在后台线程中仍然是纯 Compose 测试的标准做法,但在以下几种特定场景中,主线程同步非常有利:
- 复杂的 View 互操作性:在测试同时包含 Compose 和旧版 Android View 的混合界面时,操控 View 通常需要在主线程上运行。现在,您可以按顺序与视图互动并断言 Compose 节点,而无需不断切换线程上下文。
- 同步状态突变:如果您的架构依赖于严格的主线程绑定状态持有者,您现在可以改变状态并立即等待 Compose 界面稳定,而无需离开主线程。
- 自定义测试运行程序:如果您要构建自定义测试基础架构或利用测试运行程序本身在主线程上执行的环境,Compose 测试现在可以干净利落地执行,而无需委托给后台线程。
示例
从历史上看,由于主线程上严格禁止同步,开发者不得不在后台测试运行程序线程和界面线程之间来回切换,导致测试不连贯:
@Test fun testBidirectionalInteropUIUpdates_old() { val scenario = launchFragmentInContainer<InteropFragment>() composeTestRule.waitForIdle() scenario.onFragment { fragment -> fragment.legacyButton.performClick() } // Jump to Test Thread to verify state settles inside compose composeTestRule.waitForIdle() composeTestRule.onNodeWithText("Legacy Clicks: 1").assertIsDisplayed() composeTestRule.onNodeWithText("Increment Legacy TextView").performClick() composeTestRule.waitForIdle() // Jump back to Main Thread to verify target view state settles scenario.onFragment { fragment -> assert(fragment.legacyTextView.text.toString() == "Compose Clicks: 1") } }
启用主线程同步后,Compose 和 View 层次结构的断言可以在同一块中执行:
@Test fun testBidirectionalInteropUIUpdates_new() { val scenario = launchFragmentInContainer<InteropFragment>() composeTestRule.waitForIdle() scenario.onFragment { fragment -> fragment.legacyButton.performClick() composeTestRule.waitForIdle() composeTestRule.onNodeWithText("Legacy Clicks: 1").assertIsDisplayed() composeTestRule.onNodeWithText("Increment Legacy TextView").performClick() composeTestRule.waitForIdle() assert(fragment.legacyTextView.text.toString() == "Compose Clicks: 1") } }
等待条件
依赖于外部工作(例如数据加载或 Android 的测量或绘制,即 Compose 外部的测量或绘制)的任何条件应使用更为宽泛的概念,例如 waitUntil():
composeTestRule.waitUntil(timeoutMs) { condition }
您还可以使用任何 waitUntil 辅助函数:
composeTestRule.waitUntilAtLeastOneExists(matcher, timeoutMs)
composeTestRule.waitUntilDoesNotExist(matcher, timeoutMs)
composeTestRule.waitUntilExactlyOneExists(matcher, timeoutMs)
composeTestRule.waitUntilNodeCount(matcher, count, timeoutMs)
其他资源
- 在 Android 平台上测试应用:此 Android 测试主着陆页更全面地介绍了测试基础知识和技巧。
- 测试基础知识:详细了解 Android 应用测试背后的核心概念。
- 本地测试:您可以在自己的工作站上本地运行一些测试。
- 插桩测试:最好也运行插桩测试。也就是说,直接在设备上运行的测试。
- 持续集成:借助持续集成,您可以将测试集成到部署流水线中。
- 测试不同的屏幕尺寸:由于用户可使用多种设备,因此您应测试不同的屏幕尺寸。
- Espresso:虽然 Espresso 旨在用于基于 View 的界面,但其知识仍有助于 Compose 测试的某些方面。