mcpField
S
setting-up-host-vs-device-tests

setting-up-host-vs-device-tests

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

关于 setting-up-host-vs-device-tests

为 Android Compose 开发者提供测试源集选择指南,说明哪些测试适合 Robolectric 主机运行,哪些需要真实设备能力,并涵盖时钟与截图等待方式。

主要功能

  1. 01按能力选择主机或设备测试
  2. 02说明测试源集与类结构
  3. 03使用主时钟推进动画与手势
  4. 04识别截图、波纹和无障碍测试限制
  5. 05区分 Thread.sleep 的适用场景

适用场景

  1. 01确定 Compose UI 测试放入 test/ 还是 androidTest/
  2. 02排查主机与模拟器测试结果不一致
  3. 03安排截图、波纹或无障碍验证
  4. 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)近似模拟真实
    最低 SDK23(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。

评论

支持 Markdown