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就三个:onNodeassertperform。说白了就是:找到节点、验证状态、执行操作。

先看一个完整的例子:

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测试知识体系。你可以把它当作一个检查清单:

Compose UI测试 测试框架 ui-test-junit4 语义树 SemanticsNode 断言与操作 assert / perform 截图测试 captureToImage 匹配器 hasText / hasTestTag 操作类型 点击 / 输入 / 滚动 最佳实践 优先语义定位 · 避免动画干扰 · 截图只覆盖关键组件

23.6 避坑指南

最后,分享几个我踩过的坑:

  • 测试里不要用真实的时间延迟。比如 Thread.sleep(1000) 会让测试变慢且不稳定。用 composeTestRule.mainClock.advanceTimeBy(1000) 代替。
  • LazyColumn的滚动测试。我曾经写了一个测试,想验证列表第50项的内容。直接用 onNodeWithText 找不到,因为没滚动到。正确做法是先 performScrollToIndex(49),再断言。
  • 截图测试的基准图要纳入版本控制。否则换一台机器跑测试,基准图丢了,所有截图测试都会失败。我习惯把基准图放在Git LFS里。
  • 测试用例要独立。每个测试方法之间不要共享状态。用 @Before 初始化,用 @After 清理。

一句话总结:Compose UI测试的核心是语义树,你不需要关心组件怎么画出来的,只需要关心用户能感知到的行为和状态。截图测试是最后的防线,但别滥用。


公众号:蓝海资料掘金营,微信deep3321