关于 android-testing
面向 Android 测试的参考技能,重点介绍测试先行、Compose 测试和 Kotlin Multiplatform 易错点。帮助开发者选择合适的测试形式,并处理协程调度器、原生目标编译和截图测试问题。
主要功能
- 01RED-GREEN-REFACTOR 测试流程
- 02KMP 测试名称与目标检查
- 03Compose 测试调度器处理
- 04优先使用语义进行 UI 选择
- 05UI、集成与截图测试选择
- 06确定性截图测试准备
适用场景
- 01在编写生产代码前验证行为
- 02排查 iOS 目标的 commonTest 编译错误
- 03测试 Compose 协程与动画
- 04为界面状态建立稳定截图测试
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ rcosteira79/ android-skills --skill android-testing安装后在 Claude Code 中输入/android-testing确认已加载。
详细介绍
Android 测试
以测试优先为基础的 Android 专项测试。本参考聚焦于测试优先原则,以及容易出错的 Compose 测试和 KMP 陷阱,而非三层测试、使用 Fake 而非 Mock、runTest 或 Given-When-Then 命名等基础知识——除非某个名称会导致一个目标平台无法编译。
测试优先(基础)
- **没有先编写并观察到失败的测试,就不编写生产代码。**先编写测试,观察它失败(RED),再编写能通过测试的最少代码(GREEN),然后重构。没有亲眼观察到失败的行为,就没有得到验证。
- 只有当一个测试因为某个 Bug 而失败,并且修复后通过时,这个 Bug 才算修复。
这项原则可以叠加在任何专门的 TDD 技能(superpowers:test-driven-development、ace:test-driven-development)之上,但不要求使用它们。要从头搭建测试栈(测试 DI、选择 JUnit/Robolectric/Roborazzi/Paparazzi、插桩测试运行器、Compose Preview Screenshot Testing、UI Automator、Jacoco),请参阅 Google 官方的 testing-setup 技能(android skills add testing-setup)。
commonTest 中的名称必须兼容 Kotlin/Native,而不仅是 JVM
commonTest 会针对每个声明的目标平台分别编译,因此 jvmTest 通过并不能证明 iOS 编译也能通过。反引号在所有目标平台上都没问题——它们是转义语法,不属于名称的一部分——但 Kotlin/Native 前端会拒绝其中包含以下 24 个字符的名称:. ; , ( ) [ ] { } / < > : \ $ & ~ * ? # | § % @(FirNativeIdentifierChecker——自 Kotlin 1.7 起,这是一项无条件错误;这些字符也包含 IR 名称修饰器自身保留的字符)。Kotlin/JVM 只禁止 . ; [ ] / < > : \。逗号是两组限制的差异所在;空格则都不在其中,因此带逗号的 Given-When-Then 命名约定会导致所有 Native 目标平台编译失败,但 CI 仍会通过:
// commonTest — compiles for jvm; fails compileTestKotlinIosSimulatorArm64 with
// error: name contains illegal characters: ","
@Test fun `given an empty cart, when an item is added, then the total updates`() { … }
// commonTest — same sentence, commas dropped. Backticks and spaces compile everywhere.
@Test fun `given an empty cart when an item is added then the total updates`() { … }
由此得出两条规则。所有 commonTest 声明名称中都不要使用 , . ( ) : ?——检查范围包括类、属性、类型别名、值参数和枚举项,而不仅是 fun。此外,验证 KMP 测试时要使用 allTests 或 <target>Test,绝不要用 jvmTest,因为它只依赖 compileTestKotlinJvm;仅面向设备的目标平台根本没有测试任务,因此要显式编译其测试二进制文件(linkDebugTestIosArm64)。
不要将这一规则与 Kotlin 编码约定提到的 Android 规则混淆:当 minSdk 低于 30 时,D8 会拒绝空格,这只会影响插桩测试——而不会影响逗号,因为 DEX 的 SimpleName 语法在任何 API 级别都不允许逗号。
Compose 测试现在默认使用 StandardTestDispatcher
createComposeRule() / runComposeUiTest {} 默认使用 StandardTestDispatcher(与 kotlinx.coroutines.test.runTest 一致)——**没有单独的“v2”包。**该行为受 androidx.compose.ui.test.ComposeUiTestFlags.isStandardTestDispatcherSupportEnabled 控制(默认值为 true),因此常规的 androidx.compose.ui.test.junit4.createComposeRule 已会使用它。要固定调度器,请将其传入:createComposeRule(effectContext = StandardTestDispatcher())。在此默认行为下,之前会立即执行(旧的 UnconfinedTestDispatcher 行为)的 LaunchedEffect 可能需要显式调用 mainClock.advanceTimeBy(0) / runCurrent() 来处理队列中的任务;只有在需要临时恢复旧行为时,才将该标志设为 false。
双调度器陷阱(值得一提的协程测试注意事项):MainDispatcherRule 的 TestDispatcher 与 runTest { } 创建的调度器使用的是不同的 TestCoroutineScheduler。将 mainRule.dispatcher 传给 runTest(mainRule.dispatcher),这样 Dispatchers.Main 和测试主体才会共享一个调度器——否则 advanceUntilIdle() 只会清空其中一个,断言就会与 ViewModel 产生竞争。
语义优先,testTag 作为后备方案
优先选择面向用户的语义,而不是 testTag ——真实用户和屏幕阅读器能看到语义;除了测试之外,没人能看到 testTag。选择器优先级:(1)onNodeWithText;(2)onNodeWithContentDescription;(3)角色/状态匹配器(hasClickAction()、isSelected()、isFocused()、isEnabled());(4)仅当没有稳定的面向用户的文本,或文本重复/含义不明确时(相同条目的列表、按语言区域变化的文案、多个实例),才使用 onNodeWithTag。文本断言能经受重构并覆盖无障碍体验;testTag 断言会在标签更改时立即失效,还会漏掉面向用户的回归问题。
(值得了解的另一种观点:skydoves/android-testing-skills 主张优先使用标签,以增强国际化(i18n)的稳健性,并以 androidx/material3 自身 1825 : 424 : 46 的 testTag : onNodeWithText : onNodeWithContentDescription 比例作为依据。如果你有单独的无障碍测试,这种做法有其道理;本技能默认语义优先,因为它能捕获一类 testTag 永远无法发现的错误。)
将回调作为测试接口
Composable 的契约是“渲染状态、发出回调”——应准确测试这一点;不要通过 ViewModel mock 来执行断言。
@Test fun `tapping article row invokes onArticleClick with id`() {
var clickedId: String? = null
composeTestRule.setContent { ArticleRow(Article(id = "42", title = "Hello"), onArticleClick = { clickedId = it }) }
composeTestRule.onNodeWithText("Hello").performClick()
assertEquals("42", clickedId)
}
如何选择测试形式——用能证明行为的最小契约
| 证明内容 | 形式 |
|---|---|
| 渲染的文本、条件内容、加载/错误分支、回调连线 | 普通 UI Compose 测试(状态 + 回调,不包含图) |
| 焦点导航、键盘、电视/D-pad | 使用 performKeyInput + assertIsFocused() 的 Compose 测试(参见 compose/references/focus-navigation.md) |
| 语义无法证明的视觉契约——间距、主题颜色、字体排版、阴影、渐变、骨架屏 | 截图测试,每种有意义的状态测一次 |
| 状态持有器能否正确更新 UI | 状态持有器单元测试 + 一次连线冒烟测试 |
| 生命周期、导航或 DI 集成本身 | 集成测试(createAndroidComposeRule、Hilt 规则、真实依赖图) |
**测试时钟与墙上时钟:**对于任何 Compose 状态可观测的条件,使用 mainClock.advanceTimeUntil(ms) { state.value == Done }(确定且快速);只有对于非 Compose 条件(Job.isCompleted、外部计数器)才使用 rule.waitUntil(ms) { … }。在同一测试中混用两者是常见的不稳定因素。
动画测试必须在 setContent 之前设置 mainClock.autoAdvance = false ——否则,框架的 InfiniteAnimationPolicy 会在不确定时长的动画上抛出 CancellationException,而有限时长的动画会在一次自动推进中结束,无法观察到中间状态。暂停后,先用 advanceTimeByFrame()(启动动画)驱动帧,再用 advanceTimeBy(durationMillis) 推进时间。
**确保截图结果确定:**使用固定的状态数据(截图路径中不包含当前时间、随机种子或远程 URL);冻结时钟(Clock.fixed(...))和动画进度;对于图片较多的屏幕,使用假图片加载器(Coil 3 的测试替身:LocalImageLoader provides FakeImageLoader(LocalContext.current),或 setContentWithFakeImageLoader { … });每种有意义的状态(加载/错误/成功/空)截一张,而不是为每个 UI 元素各截一张。