2. Compose UI 测试框架概览: Testing API、语义树、Matcher 与 Assertion
写 UI 测试,说白了就是让机器替你用手点一遍界面,还得自动检查对不对。
Compose 的测试框架跟传统 View 系统不太一样,它绕过了布局层级,直接在一棵「语义树」上做匹配和断言。
这套机制用起来很顺手,但前提是你得搞懂它的几个核心概念。
这一节我就把这几个东西串起来讲清楚:Testing API 怎么启动测试、语义树 到底是什么、Matcher 和 Assertion 怎么定位和验证。
你搞懂了它们的关系,后面写快照回归测试才能心里有数。
2.1 Compose Testing API — 测试的入口
在 Compose 里写测试,第一件事就是拿到一个测试规则。
框架提供了两个核心入口:
| API | 适用场景 | 典型用法 |
|---|---|---|
createComposeRule() | 纯 Compose 单元测试(不依赖 Activity) | 快速验证组件逻辑 |
createAndroidComposeRule() | 需要 Activity 环境的集成测试 | 验证页面交互 & 导航 |
我个人习惯用 createComposeRule() 起步,因为启动快、依赖少。
等需要测 Activity 生命周期或 Intent 时,再换 createAndroidComposeRule 不迟。
核心三步走:
- 调用
rule.setContent { /* 你的 Composable */ }加载组件 - 通过
onNode()系列方法在语义树上定位节点 - 调用
assertXXX()或performXXX()做验证 / 操作
@Test
fun testButtonDisplayed() {
rule.setContent {
Button(onClick = { }) { Text("Submit") }
}
rule.onNodeWithText("Submit").assertIsDisplayed()
}
嗯,这里要注意:setContent 每次调用都会重建整棵树,所以你可以在同一个测试方法里测多个互不干扰的场景。
2.2 语义树 — UI 的「抽象骨架」
Compose 没有 View 树那套东西,它造了一棵 语义树(Semantic Tree)。
每个可访问的 UI 元素都会在这棵树上挂一个节点,节点上绑着语义属性:文本、内容描述、角色、操作等。
为什么要费劲弄个新树?
因为 Compose 的布局树太动态了,重组频繁,直接拿布局树做测试定位既慢又不稳。
语义树只保留对用户「有意义」的信息,轻量、稳定,而且天然支持无障碍。
怎么看这棵树?
测试运行中,你可以在 Logcat 里搜 semantics 标签,或者用 rule.onRoot().printToLog() 把整棵树打印出来。
调试定位问题时,这招特别管用。
rule.onRoot().printToLog("SEMANTIC_TREE")
// 输出示例:
// Node #1
// Role: Button
// Text: ['Submit']
// Actions: [OnClick]
// Children: [...]
我记得第一次排查一个「点击没反应」的用例时,就是在语义树里发现目标节点压根没挂上 OnClick 操作。
当时就觉得,能直接看到树结构太救命了。
2.3 Matcher & Assertion — 定位与验证
语义树搭好了,接下来就是在树上找节点、做检查。
这个活儿交给 Matcher 和 Assertion 配合完成。
Matcher:找到你要的节点
Compose 提供了丰富的匹配器,最常用的几个:
hasText(string)— 匹配文本内容hasContentDescription(string)— 匹配内容描述hasTestTag(string)— 匹配自定义的testTaghasClickAction()— 匹配可点击的节点isEnabled()/isFocused()— 匹配状态
你可以用 and、or、not 把它们组合起来,精准锁定目标:
// 找一个同时有文本"Submit"且可点击的按钮
rule.onNode(
hasText("Submit") and hasClickAction()
).assertIsDisplayed()
// 找一个没有内容描述的图标
rule.onNode(
hasContentDescription("") or not(hasAnyContentDescription())
)
我在项目中遇到过一个问题:某个 Icon 明明显示在屏幕上,但用 hasText 死活匹配不到。
后来才发现它用的是 contentDescription 而非文本,换成 hasContentDescription 就过了。
所以匹配前最好先看一眼语义树,确认节点上挂的是什么属性。
Assertion:验证节点状态
找到节点之后,就该断言了。
常用断言方法:
| 断言方法 | 验证内容 |
|---|---|
assertIsDisplayed() | 节点在屏幕上可见 |
assertIsNotDisplayed() | 节点不可见或已移除 |
assertTextEquals(str) | 文本内容完全一致 |
assertHasClickAction() | 节点绑定了点击操作 |
assertIsEnabled() | 节点处于可用状态 |
rule.onNodeWithText("Submit")
.assertIsDisplayed()
.assertHasClickAction()
.assertIsEnabled()
断言的执行是同步的,但 Compose 的渲染是异步的。
测试框架内部会做 等待 & 重试(默认 1 秒),直到节点出现或超时。
所以你很少需要手动加 Thread.sleep() — 这比我以前写 Espresso 时舒服多了。
2.4 实践建议 & 避坑指南
概念讲完了,分享几个我在真实项目中积累的「肌肉记忆」:
我曾经踩过的一个坑:
在列表(LazyColumn)里用 onNodeWithText 匹配一个「加载更多」按钮,结果怎么也找不到。
后来才发现列表项被 Recycler 回收了,节点不在语义树上。
解决方案是用 onNode(hasText("加载更多") and isDisplayed()) 确保节点可见,或者先滚动到目标位置。
- 优先用
hasTestTag:给