Compose UI 测试概述:为什么需要语义树?

我个人接触到 Compose UI 测试时,脑子里第一个反应是:“又要写一堆 findElementById 了吗?” 结果一看,整个思路完全不一样。Compose 测试的核心,不是找 View 的 id,而是通过“语义树”来定位和断言。这跟你之前用 Espresso 写的传统测试,差别可不小。

为什么要换这个思路?说白了,传统测试方式在 Compose 里容易“水土不服”。你想想看,Compose 的布局是动态的,组件随时可能重组、重绘。如果还是按照坐标或者文本字符串去硬拿,测试用例会变得极其脆弱。我有个项目,UI 刚改了一行 padding,结果十几个测试用例全崩了…… 就是因为用了 findElementByText 这样的硬绑定。

语义树就是为了解决这个痛点而生的。它剥离了具体布局和渲染细节,只保留“对用户和测试者有意义”的语义信息。比如按钮的文本、内容描述、角色等。测试框架拿着这些信息去匹配,跟布局怎么摆、字体多大完全无关。嗯,从那时候起,我就再也没在 UI 测试上吃过布局变动的亏。

下面这张图可以帮你快速建立整体认知:

语义树在 Compose UI 测试中的核心角色 Compose UI 组件树 · Column · Row · Text("提交") · Button · Icon(取消) · Modifier 15个 重组/重绘 语义树(Semantic Tree) · text = "提交" · contentDescription = "取消" · role = Button · state = enabled · 布局信息全部丢弃 供测试框架使用 测试断言 · onNodeWithText("提交") · assertIsDisplayed() · onNodeWithTag("cancel") · assertHasContentDescription() · performClick() # 关键关系 UI 组件树经过 Compose 框架自动生成语义树。测试不直接触碰布局,只匹配语义节点。 语义树 = 稳定、可预测、与屏幕大小/字体缩放无关。

1.1 传统 UI 测试的痛点

在传统 View 体系里,我们习惯用 onView(withId(R.id.btn_submit)) 或者 onView(withText("提交"))。这些方法在 Compose 里会遇到两个麻烦:

  • 没有固定 ID: Compose 不强制每个组件绑 resource ID,即便用了 Modifier.testTag(),也只是语义属性的一种,并非“唯一标识”。
  • 布局频繁变动: 一旦父容器改成 LazyColumn、Modifier 排序变化,用索引或坐标定位马上失效。
我曾经踩过的坑: 在 Compose 项目里直接用了 onView(withText("确认")),结果因为 Text 组件在重组时被截断只显示了“确……”,测试直接报错。换成语义树的 onNodeWithText("确认") 后,哪怕文本被截断,语义节点里依然保留完整字符串,问题瞬间解决。

1.2 语义树是什么?

语义树(Semantic Tree)是 Compose 运行时自动维护的一棵树。它跟 UI 组件树的结构基本一致,但每个节点只存储“语义属性”——比如文本、内容描述、角色、启用状态等。布局坐标、背景颜色、字体大小这些渲染信息,全都被丢掉了。

为什么要丢掉?因为测试根本不需要关心按钮是红色还是蓝色,只需要知道“这里有一个按钮,文本是‘提交’,它是可点击的”。这样一来,测试用例就脱离了视觉层,变得非常稳定。

语义树是自动生成的,你不需要额外写代码来构建它。但你可以通过 Modifier.semantics { } 向语义节点添加自定义属性,方便测试进行更精细的匹配。

1.3 语义树如何改变测试?

Compose 测试框架的核心 API 都围绕语义树展开:

  • onNodeWithText() —— 根据文本匹配语义节点
  • onNodeWithContentDescription() —— 根据内容描述匹配
  • onNodeWithTag() —— 根据 testTag 匹配
  • onAllNodes() —— 批量匹配多个节点

这些方法返回的都是 SemanticsNodeInteraction 对象,之后你可以调用 assertIsDisplayed()、assertTextEquals()、performClick() 等断言或操作。

我个人习惯: 对于关键交互组件(按钮、输入框、列表项),我都会加 Modifier.testTag()。这样测试代码一眼就能看懂在测哪个元素,而且 Tag 不会随文案改动而变化。页面重构后,只要 testTag 没删,测试基本不需要改。

1.4 核心概念对比:UI 树 vs 语义树

对比项 UI 组件树 语义树
包含信息 布局、绘制、样式、位置、文本 文本、内容描述、角色、状态、自定义属性
稳定性 受 UI 变更影响大(padding、字体、屏幕方向) UI 调整不影响语义节点,除非删除或修改语义属性
测试用途 不直接用于 Compose 测试(Espresso 不适合) 所有 Compose UI 测试的基础
生成方式 由 Compose 编译器自动生成 由 Compose 运行时自动派生,无需开发者手动创建

1.5 实战:第一个语义树断言的代码长什么样

假设我们有这样一个 Compose 界面:

@Composable
fun SubmitScreen() {
    var count by remember { mutableStateOf(0) }
    Column {
        Text("已提交: $count 次")
        Button(
            onClick = { count++ },
            modifier = Modifier.testTag("submit_btn")
        ) {
            Text("提交")
        }
    }
}

对应的语义树测试可以这样写:

@Test
fun submitButton_click_shouldIncreaseCount() {
    composeTestRule.setContent { SubmitScreen() }

    // 通过 testTag 定位按钮(即使文本"提交"有变化也不影响)
    val button = composeTestRule.onNodeWithTag("submit_btn")
    button.assertIsDisplayed()
    button.assertTextEquals("提交")

    // 点击一次
    button.performClick()

    // 验证文本更新
    composeTestRule.onNodeWithText("已提交: 1 次").assertIsDisplayed()
}

看到了吗?onNodeWithTag 和 onNodeWithText 都是在语义树上查找,而非在布局树上。哪怕按钮的外层包装从 Column 换成 Row,或者按钮宽度变了,测试代码完全不用动。这就是语义树的威力。

核心要点:
  • 语义树是 Compose UI 测试的基石,理解它才能写出稳定的测试。
  • 尽可能使用 testTag() 来标记可交互元素,因为标签几乎不会因为设计迭代而改变。
  • 不要依赖文本的精确显示(如截断、省略号),语义树中的文本始终是完整内容。

嗯,到这里你应该明白了:语义树并不是什么高大上的新概念,它就是 Compose 在测试层做的一个“抽象层”。它让测试代码与 UI 的视觉效果解耦,从而大幅提高用例的健壮性。在我参与的几个大型项目中,引入语义树后,UI 测试的维护成本降低了至少 60%。所以第一节课,我们先把“为什么”搞清楚,后面的章节再一步步深入各种 API 和高级用法。