关于 setting-up-host-vs-device-tests
为 Android Compose 开发者提供测试源集选择指南,说明哪些测试适合 Robolectric 主机运行,哪些需要真实设备能力,并涵盖时钟与截图等待方式。
主要功能
- 01按能力选择主机或设备测试
- 02说明测试源集与类结构
- 03使用主时钟推进动画与手势
- 04识别截图、波纹和无障碍测试限制
- 05区分 Thread.sleep 的适用场景
适用场景
- 01确定 Compose UI 测试放入 test/ 还是 androidTest/
- 02排查主机与模拟器测试结果不一致
- 03安排截图、波纹或无障碍验证
- 04替换不稳定的动画等待方式
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ skydoves/ android-testing-skills --skill setting-up-host-vs-device-tests安装后在 Claude Code 中输入/setting-up-host-vs-device-tests确认已加载。
详细介绍
设置主机测试与设备测试——选择正确的源码集
Compose 测试可在 Robolectric(JVM,速度快,无需模拟器)或真实/虚拟设备(完整 Android 堆栈、RenderThread、无障碍功能)上原样运行。相同的 runComposeUiTest { setContent { … } } 代码块在两者中都能编译——只有底层的 Looper 和 Choreographer 不同。本指南说明每种测试应放在哪种环境中、Robolectric 类的基本结构,以及唯一合理的 Thread.sleep 例外情况(截图测试等待 RenderThread)。
何时使用本指南
- 用户准备开始编写 Compose UI 测试,并询问“放在 test/ 还是 androidTest/?”。
- 测试在本地模拟器上通过,但在 CI 的 Robolectric 运行器上失败(反之亦然)。
- 用户报告
enableAccessibilityChecks(...)发出的Build.FINGERPRINT == "robolectric"警告。 - 截图测试在主机运行器上生成黑色/空白 PNG。
- ripple/
pressInteraction测试在 Robolectric 上没有渲染 ripple,用户正在排查原因。 - 用户写了
Thread.sleep(1000)来“等待动画”,并询问为什么结果不稳定。 - 用户提到
androidDeviceTest/androidHostTest源码集(androidx本身使用)。
何时不要使用本指南
- 依赖项尚未正确配置——请先查看
./configuring-test-dependencies/SKILL.md。 - 需要在
createComposeRule()和runComposeUiTest { }之间做选择——请参阅./choosing-test-rule-vs-runtest/SKILL.md。 - 测试使用了正确的环境,但在空闲状态/动画方面不稳定——请参阅
../../synchronization/synchronizing-with-idle/SKILL.md和../../synchronization/testing-animations-deterministically/SKILL.md。
前置条件
androidx.compose.ui:ui-test、ui-test-junit4和ui-test-manifest已配置到所选环境对应的配置中——请参阅./configuring-test-dependencies/SKILL.md。- 对于主机测试:在
testImplementation中添加org.robolectric:robolectric,并在 Android 代码块中设置testOptions { unitTests.isIncludeAndroidResources = true }。 - 对于设备测试:已配置模拟器或实体设备,并将
androidx.test.runner.AndroidJUnitRunner(或 Hilt 子类)设为testInstrumentationRunner。 - 了解
MainTestClock的语义——请参阅同步技能集。
工作流
-
1. 根据功能而非速度选择环境。 对照表:
功能 主机(Robolectric, src/test/)设备(instrumentation, src/androidTest/)重组 + 状态变更 可用 可用 布局 + 测量 可用 可用 通过 performTouchInput进行触摸输入可用(合成事件) 可用(真实输入管线) Modifier.indication涟漪效果不可用(RenderThread 不会被驱动) 可用 截图捕获( captureToImage)不可用(没有真实 Surface) 可用 enableAccessibilityChecks(...)无法得出结论—— ComposeUiTestExt.android.kt:50-53会记录Log.w("ComposeUiTest", "Accessibility checks are currently not supported by Robolectric"),并且仍会安装验证器,但 Robolectric 无法忠实地驱动无障碍服务(b/332778271)。即使通过,也不能据此下结论。可用( @RequiresApi(34))需要实时钟变化的手势检测器(双击、长按计时) 仅在手动调用 mainClock.advanceTimeBy(...)时可用自动可用 Activity 生命周期(真实的 onPause/onResume)近似模拟 真实 最低 SDK 23( compose/ui/ui-test/src/androidHostTest/.../Constants.kt中的internal const val RobolectricMinSdk = 23)模块的 minSdk速度(大致) 数秒 数十秒 + 模拟器启动 CI 开销 仅 JVM 模拟器服务或 Firebase Test Lab 逻辑/重组/查找器覆盖测试请选择主机环境。涉及 RenderThread、
Modifier.indication的真实动画、截图黄金测试或无障碍验证的测试请选择设备环境。 -
2. 将文件放入对应的源集。 androidx 的约定是:
src/ ├── androidHostTest/ # = test/ — Robolectric on JVM │ └── kotlin/.../FooTest.kt ├── androidDeviceTest/ # = androidTest/ — instrumentation │ └── kotlin/.../FooScreenshotTest.kt └── androidCommonTest/ # helpers shared by both └── kotlin/.../FooTestHelpers.kt对于没有 KMP 源集的典型应用模块,对应的是
src/test/(主机)和src/androidTest/(设备)。两者中的runComposeUiTest { setContent { … } }函数体完全相同,无需修改即可编译——运行时只有 Looper/Choreographer 不同。 -
3. 配置主机测试类骨架。 从
compose/ui/ui-test/src/androidHostTest/kotlin/androidx/compose/ui/test/RobolectricComposeTest.kt复制 androidx 的标准模式:
@RunWith(AndroidJUnit4::class)
@Config(minSdk = 23)
@OptIn(ExperimentalTestApi::class)
class MyHostTest {
@Before
fun setup() {
// capture the master timeout so we can restore it afterwards
masterTimeout = IdlingPolicies.getMasterIdlingPolicy()
}
@After
fun tearDown() {
masterTimeout?.let {
IdlingPolicies.setMasterPolicyTimeout(it.idleTimeout, it.idleTimeoutUnit)
}
}
@Test
fun stateChange() = runComposeUiTest {
setContent { ClickCounter() }
onNodeWithText("Click me").performClick()
onNodeWithText("Click count", substring = true).assertTextEquals("Click count: 1")
}
private var masterTimeout: IdlingPolicy? = null
}
AndroidJUnit4::class 在 JVM 上会委托给 Robolectric,在设备上则委托给 AndroidJUnit4ClassRunner,因此同一个类可移植运行。@RunWith(RobolectricTestRunner::class) 也可行,但会将该类限定为仅在主机上运行。
- 4. 配置设备测试类骨架。
@RunWith(AndroidJUnit4::class)
class MyDeviceTest {
@get:Rule val rule = createComposeRule() // or v2 import — see ./choosing-test-rule-vs-runtest/
@Test
fun stateChange() {
rule.setContent { ClickCounter() }
rule.onNodeWithText("Click me").performClick()
rule.onNodeWithText("Click count", substring = true).assertTextEquals("Click count: 1")
}
}
-
5. 对任何动画/手势检测测试,使用
mainClock.advanceTimeBy(...)。 在 Robolectric 上,手势检测器(双击、长按)依赖测试时钟推进,不会接收真实的墙上时钟计时。RobolectricComposeTest.kt的源码对此有直接说明:手势检测器通过检测时钟变化来检测事件,因此需要手动调用mainClock.advanceTimeBy(...)。这同样适用于由重组器帧时钟驱动的任何animateFloatAsState——参见../../synchronization/testing-animations-deterministically/SKILL.md。 -
6. 明确唯一合理的
Thread.sleep用例:等待 RenderThread 的截图测试。 Compose 的IdlingResource会汇总重组器、快照和帧时钟等待器,但 RenderThread 不在其汇总范围内。Ripple 动画(Modifier.indication)以及由平台渲染管线拥有的任何绘制时动画,都无法通过mainClock.advanceTimeBy或waitForIdle等待。以下内容摘自compose/material3/material3/src/androidDeviceTest/.../ToggleButtonScreenshotTest.kt:115-123:
rule.mainClock.autoAdvance = false
rule.onNode(isToggleable()).performTouchInput { down(center) }
rule.mainClock.advanceTimeByFrame()
rule.waitForIdle() // wait for measure
rule.mainClock.advanceTimeBy(milliseconds = 200)
// Ripples are drawn on the RenderThread, not the main (UI) thread, so we can't wait for
// synchronization. Instead just wait until after the ripples are finished animating.
Thread.sleep(300)
assertAgainstGolden("toggleButton_lightTheme_defaultToPressed")
这就是唯一的例外。其他任何地方使用 Thread.sleep 都是不妥的——它会与 MainTestClock 失去同步,并产生让开发者疲于应对的间歇性失败。动画使用 mainClock.advanceTimeBy(durationMs)(测试时钟),外部状态则使用 rule.waitUntil(timeoutMillis = …) { … }(墙上时钟)。
-
7. 强制执行:主机测试中禁止使用
Thread.sleep。 主机测试本就无法驱动 RenderThread,因此不适用截图测试这一例外。将主机测试中的每个Thread.sleep(N)替换为mainClock.advanceTimeBy(N)(动画场景)或rule.waitUntil { … }(外部状态)。 -
8. 将截图、ripple 和无障碍测试放入设备源集。 主机运行器无法满足这些测试的要求。具体而言:
- 截图:
captureToImage()需要真实的Surface。主机上不会返回像素数据。 - Ripple:
Modifier.indication在 RenderThread 上绘制,主机上没有可观察的状态。 - 无障碍:
enableAccessibilityChecks(...)标注了@RequiresApi(34)。在 Robolectric 上,两个扩展实现都会检查Build.FINGERPRINT.lowercase() == "robolectric",发出Log.w警告,并且仍会安装验证器;但 Robolectric 无法忠实地驱动无障碍服务,因此任何通过结果都不能作为定论(b/332778271)。如需可信的结果,请在真实设备上运行无障碍检查。
- 截图:
-
9. 将快速逻辑/重组/状态测试放入主机源集。 适合在 Robolectric 上运行的示例:
StateRestorationTester.emulateSavedInstanceStateRestore()流程。- 纯状态变更验证(
assertTextEquals、assertIsOn)。 - 不依赖速度驱动的物理效果的 LazyList 查找器/滚动测试。
- 使用
mainClock.advanceTimeBy(durationMs)驱动且不需要光栅化的动画快照。
模式
模式:错误 vs 正确——在主机上运行截图测试
// WRONG — placed in src/test/ (Robolectric)
@RunWith(AndroidJUnit4::class)
@Config(minSdk = 23)
class ButtonScreenshotTest {
@Test
fun pressed() = runComposeUiTest {
setContent { Button(onClick = {}) { Text("OK") } }
onNode(hasText("OK")).performTouchInput { down(center) }
onNode(hasText("OK")).captureToImage().assertAgainstGolden("pressed")
}
}
// WRONG because: Robolectric has no real Surface, so captureToImage() returns black/empty
// pixels and the ripple from Modifier.indication never draws (RenderThread is not driven).
// The test will produce a false-positive pass or a meaningless golden file.
// RIGHT — placed in src/androidTest/ (device)
@RunWith(AndroidJUnit4::class)
class ButtonScreenshotTest {
@get:Rule val rule = createComposeRule()
@Test
fun pressed() {
rule.setContent { Button(onClick = {}) { Text("OK") } }
rule.mainClock.autoAdvance = false
rule.onNode(hasText("OK")).performTouchInput { down(center) }
rule.mainClock.advanceTimeByFrame()
rule.waitForIdle()
rule.mainClock.advanceTimeBy(milliseconds = 200)
Thread.sleep(300) // legitimate — RenderThread for ripples
rule.onNode(hasText("OK")).captureToImage().assertAgainstGolden("pressed")
}
}
模式:错误做法 vs 正确做法 — 主机端动画测试中的 Thread.sleep
// WRONG — host test
@Test
fun fadeIn() = runComposeUiTest {
val target = mutableStateOf(0f)
setContent { Box(Modifier.alpha(animateFloatAsState(target.value).value)) }
target.value = 1f
Thread.sleep(500) // smell
onNode(isRoot()).captureToImage() // also broken on host
}
// WRONG because: Thread.sleep desyncs from MainTestClock. The test clock has not advanced,
// the recomposer has not run withFrameNanos, and the animation made zero observable progress.
// RIGHT — host test (recomposition only — no pixel capture)
@Test
fun fadeIn() = runComposeUiTest {
mainClock.autoAdvance = false
val target = mutableStateOf(0f)
setContent { Box(Modifier.alpha(animateFloatAsState(target.value).value).testTag("box")) }
runOnUiThread { target.value = 1f }
mainClock.advanceTimeByFrame() // kick off
mainClock.advanceTimeBy(durationMillis = 500) // step forward by test clock
onNodeWithTag("box").assertIsDisplayed()
}
如需对淡出效果进行像素验证,请将测试移至设备源码集,并使用 captureToImage()。
模式:错误做法 vs 正确做法 — 主机端的无障碍检查
// WRONG — host
@Test
fun submitIsLabelled() = runComposeUiTest {
enableAccessibilityChecks() // logs warning, runs no checks
setContent { Submit() }
onNodeWithTag("submit").performClick()
}
// WRONG because: both ui-test-accessibility and ui-test-junit4-accessibility detect
// Build.FINGERPRINT.lowercase() == "robolectric", emit a Log.w, AND still install the
// validator — but Robolectric does not faithfully drive accessibility services, so the
// result is inconclusive (b/332778271). Run accessibility checks on a real device.
// RIGHT — device
@RunWith(AndroidJUnit4::class)
class SubmitA11yTest {
@get:Rule val rule = createAndroidComposeRule<MainActivity>()
@Test fun submitIsLabelled() {
rule.enableAccessibilityChecks()
rule.setContent { Submit() }
rule.onNodeWithTag("submit").performClick() // accessibility checks run automatically
}
}
模式:共享测试主体,使用两个源码集
如果测试只使用通用 API,相同的 runComposeUiTest { ... } 主体可以在两个源码集中编译。将主体放在 androidCommonTest/ 中,并创建两个精简包装:一个放在 androidHostTest/ 中(使用 @RunWith(AndroidJUnit4::class) @Config(minSdk = 23)),另一个放在 androidDeviceTest/ 中(不使用 @Config)。androidx 自己的 ui-test 模块就是这样在不重复代码的情况下测试这两种形式的。如果应用模块不使用 KMP 源码集,建议将主体内联保留在合适的形式中。
强制规则
- 禁止在主机源码集中放置依赖屏幕截图 /
captureToImage/ 涟漪效果 /Modifier.indication的测试。RenderThread 不会运行;即使测试看似通过,也没有意义。 - 禁止依赖主机端测试的
enableAccessibilityChecks(...)结果。这两个扩展都会在Build.FINGERPRINT.lowercase() == "robolectric"时记录警告,并仍会安装验证器,但 Robolectric 无法忠实地驱动无障碍服务,因此结果无法定论——请在 API 34+ 的真实设备上运行无障碍检查。 - 禁止在任何情况下于主机端测试中使用
Thread.sleep。屏幕截图例外不适用(主机端没有 RenderThread)。 - 除等待 RenderThread 完成涟漪效果 / 屏幕截图金样捕获外,禁止在设备端测试中使用
Thread.sleep。Skydoves 的第 7 条犀利观点:Thread.sleep是一种坏味道。其他任何情况下,都应将其替换为mainClock.advanceTimeBy(durationMs)(测试时钟)或rule.waitUntil(timeoutMillis) { ... }(墙上时钟)——参见../../synchronization/synchronizing-with-idle/SKILL.md。 - 必须为 Robolectric 主机端测试添加
@Config(minSdk = 23)或更高版本的注解。androidx 的主机端测试基础设施不支持更低的 SDK 级别(internal const val RobolectricMinSdk = 23)。 - 必须为手势检测或动画主机端测试添加注解,并手动推进时钟,即调用
mainClock.advanceTimeBy(...)。Robolectric 不会根据实时信号推进测试时钟。 - 必须在模块的
android { }块中设置testOptions { unitTests.isIncludeAndroidResources = true },以便 Robolectric 在主机端 Compose 测试期间读取合并后的资源。 - **优先:**从主机端测试开始,以便快速获得反馈。仅在功能需要时(RenderThread、无障碍、真实输入时序)才改用设备端测试。
- **优先:**当测试必须同时适用于两种形式时,将主体提取到
androidCommonTest/(KMP)中,或提取为供两种形式调用的辅助函数。
验证
-
./gradlew :<module>:testDebugUnitTest对所有主机端测试运行并通过;./gradlew :<module>:connectedDebugAndroidTest对所有设备端测试运行并通过。 - 所有主机端测试中均未出现
Thread.sleep(grep -r "Thread.sleep" src/test src/androidHostTest)。 - 主机源码集中未出现
captureToImage、enableAccessibilityChecks,也没有依赖Modifier.indication涟漪效果的测试。 - 设备源码集中的每处
Thread.sleep都有注释说明 RenderThread / 屏幕截图方面的理由。 - Robolectric 主机端测试类带有
@RunWith(AndroidJUnit4::class)(或@RunWith(RobolectricTestRunner::class))以及@Config(minSdk = 23)(或更高版本)。 - 动画 / 手势主机端测试将
mainClock.autoAdvance = false,并显式调用mainClock.advanceTimeBy(durationMs)。
参考资料
- Compose 测试概览(Android Developers):https://developer.android.com/develop/ui/compose/testing
- Robolectric — Compose 支持:http://robolectric.org/
- Compose UI 发行说明:https://developer.android.com/jetpack/androidx/releases/compose-ui
- 测试动画(Android Developers):https://developer.android.com/develop/ui/compose/animation/testing
compose/ui/ui-test/src/androidHostTest/kotlin/androidx/compose/ui/test/RobolectricComposeTest.kt— 标准宿主类骨架:@RunWith(AndroidJUnit4::class) @Config(minSdk = RobolectricMinSdk)、手势检测器时钟注释,以及IdlingPolicies的设置与拆除。compose/ui/ui-test/src/androidHostTest/kotlin/androidx/compose/ui/test/Constants.kt—internal const val RobolectricMinSdk = 23。compose/material3/material3/src/androidDeviceTest/kotlin/androidx/compose/material3/ToggleButtonScreenshotTest.kt:115-123— 合理使用Thread.sleep(300),等待 RenderThread 完成波纹后再执行assertAgainstGolden。compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/RobolectricIdlingStrategy.android.kt— 驱动宿主空闲循环的 Robolectric 空闲策略。compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/ComposeIdlingResource.android.kt— 重组器、快照和帧时钟聚合器(每次调用最多处理 100 帧);不包含 RenderThread,因此波纹测试需要使用Thread.sleep。