20、测试基础:Compose UI 测试框架,semantics 与测试标签,findNode 与断言

说实话,很多做 Android 的同学对 UI 测试都有点「敬而远之」。我以前也一样,总觉得写测试比写功能还费劲。但自从在 Jetpack Compose 里踩过几次坑之后,我彻底改变了看法——没有测试的 Compose 代码,就像没系安全带的赛车

这一章,我们就来聊聊 Compose UI 测试的核心三板斧:测试框架怎么搭semantics 标签怎么用findNode 和断言怎么写。我会结合自己项目里的真实教训,帮你把这些知识点串起来。

Compose UI 测试核心 测试框架 createComposeRule / 依赖配置 Semantics 与测试标签 contentDescription / testTag findNode 与断言 onNode / assertIsDisplayed @RunWith setContent semantics 属性 testTag 修饰符 onNodeWithTag assertTextEquals 目标:可维护、可信任的 UI 测试 避免「改一行代码,测三天回归」

1. 测试框架:从零搭一个 Compose 测试环境

在 Compose 里写 UI 测试,核心依赖就两个。你需要在 build.gradle.kts 里加上它们:

// 模块级 build.gradle.kts
androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.6.0")
debugImplementation("androidx.compose.ui:ui-test-manifest:1.6.0")

嗯,这里要注意:ui-test-manifest 必须用 debugImplementation,因为它只在 debug 构建中生效。我曾经因为图省事直接用了 implementation,结果 release 包编译时报了一堆奇怪的 manifest 合并错误……折腾了俩小时。

测试类本身很简单。我个人习惯这样写:

@RunWith(AndroidJUnit4::class)
class MyComposeTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun testButtonDisplaysCorrectText() {
        composeTestRule.setContent {
            MyButton(text = "点击我")
        }

        // 后面我们会用 findNode 来验证
    }
}

createComposeRule() 会帮你自动管理 Compose 的 composition 生命周期。你只需要在 setContent 里放上你要测试的 Composable 函数就行。

💡 我的小技巧: 如果测试涉及动画或延迟,可以用 composeTestRule.mainClock.autoAdvance = false 手动控制时间,避免测试不稳定。

2. Semantics:Compose 测试的「眼睛」

你想想看,传统的 View 系统有 R.id 来定位控件。Compose 里没有这个东西,那测试框架怎么找到某个 Button 或 Text 呢?

答案就是 Semantics(语义)。每个 Composable 在渲染时都会生成一个语义树,测试框架就是通过这棵树来定位节点的。

最常用的两个语义属性:

  • contentDescription:无障碍描述,也用于测试定位。
  • testTag:专门给测试用的标签,不会影响 UI 显示。

举个例子:

@Composable
fun MyButton(text: String, onClick: () -> Unit) {
    Button(
        onClick = onClick,
        modifier = Modifier.testTag("my_button") // 测试标签
    ) {
        Text(
            text = text,
            modifier = Modifier.semantics {
                contentDescription = "按钮文字"
            }
        )
    }
}

我在项目中遇到过一个问题:团队里有人给 testTag 取了很随意的名字,比如 "btn1""btn2"。结果半年后回来维护,根本分不清哪个是哪个。我建议你给 testTag 起名时带上层级或功能,比如 "login_screen_submit_button",这样测试代码可读性会好很多。

⚠️ 注意: testTag 只在 debug 构建中保留。如果你在 release 构建里也需要测试,请使用 contentDescription 或者自定义语义属性。

3. findNode:如何精准定位 UI 元素

有了语义标签,接下来就是找到它。Compose 测试提供了几种 onNode 方法:

方法 作用 常用场景
onNodeWithTag(tag) 通过 testTag 查找 最推荐,明确且高效
onNodeWithText(text) 通过显示的文本查找 验证文字内容
onNodeWithContentDescription(desc) 通过 contentDescription 查找 无障碍测试
onNode(hasText("xxx")) 使用语义匹配器 复杂条件组合

来看一个完整的测试例子:

@Test
fun testButtonClick() {
    var clickCount = 0

    composeTestRule.setContent {
        MyButton(text = "点击我") {
            clickCount++
        }
    }

    // 找到按钮并点击
    composeTestRule.onNodeWithTag("my_button")
        .performClick()

    // 断言点击次数
    assertThat(clickCount).isEqualTo(1)
}

这里我用的是 onNodeWithTag,因为它最稳定。文本可能会因为国际化而改变,但 testTag 不会。

🔑 核心原则: 优先使用 testTag 定位,其次才是文本或描述。这样测试对 UI 改动的容忍度更高。

4. 断言:验证 UI 状态

找到节点之后,我们需要断言它的状态。Compose 测试提供了丰富的断言方法:

  • assertIsDisplayed() —— 验证组件是否可见
  • assertTextEquals("xxx") —— 验证文本内容
  • assertHasClickAction() —— 验证是否可点击
  • assert(isEnabled()) —— 验证是否启用

举个例子,验证一个按钮在点击后文字变化:

@Test
fun testButtonTextChangesAfterClick() {
    composeTestRule.setContent {
        var clicked by remember { mutableStateOf(false) }
        Button(
            onClick = { clicked = true },
            modifier = Modifier.testTag("toggle_btn")
        ) {
            Text(if (clicked) "已点击" else "未点击")
        }
    }

    // 初始状态
    composeTestRule.onNodeWithTag("toggle_btn")
        .assertTextEquals("未点击")

    // 点击
    composeTestRule.onNodeWithTag("toggle_btn")
        .performClick()

    // 点击后状态
    composeTestRule.onNodeWithTag("toggle_btn")
        .assertTextEquals("已点击")
}

我曾经犯过一个低级错误:在断言时用了 assertTextContains 但没注意大小写,结果测试在 CI 上一直失败。后来我养成了习惯——文本断言尽量用 assertTextEquals,除非你明确需要模糊匹配

5. 避坑指南:我踩过的三个坑

最后分享几个实战中容易翻车的地方:

  1. 忘记加 @RunWith(AndroidJUnit4::class) —— 测试会直接跳过,而且不报错。我第一次遇到时还以为自己代码写错了。
  2. setContent 里用 LaunchedEffect 做异步操作 —— 测试不会等待它完成。你需要用 composeTestRule.waitForIdle() 或者 runOnIdle 来同步。
  3. 多个相同 testTag 的组件 —— onNodeWithTag 会找到第一个,但如果你有列表,建议用 onAllNodesWithTag 然后按索引取。
📌 我的调试习惯: 如果测试找不到节点,可以在测试里加一句 composeTestRule.onRoot().printToLog("TAG"),它会打印出完整的语义树,你一眼就能看到标签有没有生效。

好了,这一章的内容就到这里。Compose UI 测试其实不难,关键是理解 semantics 是测试的桥梁testTag 是你的定位锚点。把这些基础打牢,后面写复杂交互测试时你会轻松很多。

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