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 不迟。

核心三步走:

  1. 调用 rule.setContent { /* 你的 Composable */ } 加载组件
  2. 通过 onNode() 系列方法在语义树上定位节点
  3. 调用 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) — 匹配自定义的 testTag
  • hasClickAction() — 匹配可点击的节点
  • 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 时舒服多了。

Compose UI 测试框架 — 核心流程 Compose Testing API 语义树构建 Matcher 匹配定位 Assertion 断言验证 测试结果输出 ▸ 启动 & 管理测试环境 ▸ 构建 UI 语义节点树 ▸ 通过匹配器定位组件 ▸ 断言验证组件状态 ▸ 生成通过 / 失败报告 整个流程从上到下串联, 每个环节都依赖前一层的 输出作为输入。 语义树是核心数据模型, Matcher 和 Assertion 都是围绕它工作的。

2.4 实践建议 & 避坑指南

概念讲完了,分享几个我在真实项目中积累的「肌肉记忆」:

我曾经踩过的一个坑:
在列表(LazyColumn)里用 onNodeWithText 匹配一个「加载更多」按钮,结果怎么也找不到。
后来才发现列表项被 Recycler 回收了,节点不在语义树上。
解决方案是用 onNode(hasText("加载更多") and isDisplayed()) 确保节点可见,或者先滚动到目标位置。