23、测试Compose UI:Compose测试框架、语义树、断言与操作、截图测试
说实话,很多开发者写Compose UI的时候,跑起来看着没问题就完事了。但我在项目里吃过亏——改了一个按钮的padding,结果某个页面的无障碍标签全乱了,测试没覆盖到,上线后被用户吐槽。从那以后,我老老实实把UI测试补上了。
这一章,我们就来聊聊Compose UI测试。说白了,就是怎么用代码验证你的界面长什么样、能不能点、读屏软件能不能读。别怕,Compose的测试框架比传统的View系统友好太多了。
核心要点:Compose测试基于语义树(Semantics Tree),而不是像素比对。这意味着你测试的是“用户能感知到的行为”,而不是“某个像素的颜色对不对”。
23.1 Compose测试框架概览
Compose测试框架是Jetpack家族的一部分。你只需要在build.gradle里加一行依赖:
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version"
嗯,这里要注意。如果你要用截图测试,还得额外加一个:
androidTestImplementation "androidx.compose.ui:ui-test-screenshot:$compose_version"
我个人习惯把测试用例放在 app/src/androidTest/ 目录下。为什么是androidTest而不是unitTest?因为UI测试需要真机或模拟器环境,跑在JVM上是不行的。
23.2 语义树——Compose测试的灵魂
你想想看,传统的View系统测试,你得通过ID找控件。Compose不一样,它用的是语义树。每个Composable组件在渲染时,会生成一个语义节点。这些节点组成了一棵树——就是语义树。
为什么要搞这么复杂?因为Compose要支持无障碍、测试、以及一些底层优化。语义树里包含了组件的角色、文本、状态、操作等信息。
举个例子,一个简单的Button:
Button(onClick = { /* 点击事件 */ }) {
Text("提交")
}
它在语义树里会生成一个节点,角色是“Button”,文本是“提交”,还有一个“点击”操作。测试框架就是通过这棵树来找到你的组件,然后执行操作或做断言。
小技巧:你可以用 SemanticsNodeInteractionsProvider 来调试语义树。在测试代码里加一句 composeTestRule.onAllNodes(hasText("提交")).printToLog("TAG"),就能看到当前界面上所有匹配的语义节点。
23.3 断言与操作——怎么写测试用例
Compose测试的核心API就三个:onNode、assert、perform。说白了就是:找到节点、验证状态、执行操作。
先看一个完整的例子:
class MainScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun testButtonClick() {
// 启动被测界面
composeTestRule.setContent {
MyApp()
}
// 找到按钮并点击
composeTestRule.onNodeWithText("提交")
.performClick()
// 验证点击后的结果
composeTestRule.onNodeWithText("已提交")
.assertIsDisplayed()
}
}
这段代码做了三件事:
- setContent:启动Compose界面,相当于Activity的setContentView
- onNodeWithText:通过文本找到语义节点
- performClick:模拟点击操作
- assertIsDisplayed:断言某个组件可见
我在项目中遇到过一个问题:多个按钮有相同的文本,比如“确定”。这时候 onNodeWithText 会匹配到多个节点,测试会报错。解决方案是用 onAllNodes 配合 filter,或者用 onNode(hasText("确定") and hasParent(hasText("删除对话框"))) 来缩小范围。
常用匹配器一览
| 匹配器 | 作用 | 示例 |
|---|---|---|
| hasText | 匹配文本 | onNode(hasText("登录")) |
| hasContentDescription | 匹配内容描述(无障碍) | onNode(hasContentDescription("头像")) |
| hasTestTag | 匹配测试标签 | onNode(hasTestTag("submit_btn")) |
| hasClickAction | 匹配可点击的组件 | onNode(hasClickAction()) |
| isEnabled | 匹配启用状态的组件 | onNode(isEnabled()) |
注意:不要过度依赖 hasTestTag。它虽然方便,但会让测试和实现耦合太紧。我建议优先用文本或内容描述来定位,实在不行再用tag。
23.4 截图测试——让UI变化无处遁形
截图测试是什么?就是给界面拍一张“快照”,然后跟之前保存的基准图做对比。如果像素有差异,测试就失败。
这玩意儿特别适合检测UI回归。比如你改了某个组件的颜色、间距、字体大小,肉眼可能看不出来,但截图测试能精确到像素级别。
Compose官方提供了截图测试的支持,用法很简单:
@Test
fun testMainScreenScreenshot() {
composeTestRule.setContent {
MyApp()
}
// 拍摄截图并对比
composeTestRule.onRoot().captureToImage()
.assertAgainstGolden("main_screen_golden.png")
}
这里有几个坑,我踩过:
- 基准图要放在
app/src/androidTest/assets/目录下,命名要跟代码里一致 - 不同屏幕密度要分别维护基准图,否则测试会飘
- 动画会导致截图不一致,测试前记得关掉动画:
composeTestRule.mainClock.autoAdvance = false
我的建议:截图测试不要覆盖所有页面,只挑那些“容易变但必须保持一致”的组件。比如底部导航栏、对话框、表单布局。全量截图测试维护成本太高,团队容易放弃。
23.5 知识体系总览
下面这张图,是我梳理的Compose UI测试知识体系。你可以把它当作一个检查清单:
23.6 避坑指南
最后,分享几个我踩过的坑:
- 测试里不要用真实的时间延迟。比如
Thread.sleep(1000)会让测试变慢且不稳定。用composeTestRule.mainClock.advanceTimeBy(1000)代替。 - LazyColumn的滚动测试。我曾经写了一个测试,想验证列表第50项的内容。直接用
onNodeWithText找不到,因为没滚动到。正确做法是先performScrollToIndex(49),再断言。 - 截图测试的基准图要纳入版本控制。否则换一台机器跑测试,基准图丢了,所有截图测试都会失败。我习惯把基准图放在Git LFS里。
- 测试用例要独立。每个测试方法之间不要共享状态。用
@Before初始化,用@After清理。
一句话总结:Compose UI测试的核心是语义树,你不需要关心组件怎么画出来的,只需要关心用户能感知到的行为和状态。截图测试是最后的防线,但别滥用。
公众号:蓝海资料掘金营,微信deep3321