Compose UI Test 基础:从框架到实战

说起 Compose UI Test,我最初接触时也有点懵——毕竟传统 View 系统的测试写法和 Compose 差别不小。但用顺手之后你会发现,Compose 的测试设计其实更干净、更贴近状态驱动。说白了,它就是一套让你能“像用户一样操作 UI,并验证 UI 变化”的工具。

这一节我们不扯虚的,直接讲清楚三个核心问题:用什么测、怎么搭规则、怎么写第一个测试。嗯,我还会分享几个我在项目里踩过的坑,帮你省点时间。

一、Compose 测试框架长什么样?

Compose 的测试框架基于 JUnit4/5,并在上层封装了 Compose 特有的规则(TestRule)和语义节点(Semantics Node)系统。你可能觉得“又是新框架又要学一堆”,其实它的核心就两样东西:

  • ComposeTestRule:负责启动 Compose 组件、控制合成(Composition)、触发渲染。
  • Semantics 匹配器:通过语义属性(如文本、内容描述、标签)来定位 UI 元素,而不是通过 ID 或坐标。

为什么要用语义?你想想看,传统 View 测试里经常写 onView(withId(R.id.btn_save)),一旦布局层改 ID 就全崩。Compose 里我们直接用 onNodeWithText("保存"),是不是更接近真实用户的操作习惯?

个人小贴士: 我有个项目,UI 完全由 Compose 重写后,测试维护成本降了将近一半。因为语义属性不会因为重新排版而变,只要文案不改,测试几乎不用动。

二、测试规则:createComposeRule 怎么用?

每个 Compose 测试都需要一个 测试规则。最常用的是 createComposeRule(),它负责在测试方法前启动 Compose 运行环境,测试结束后自动清理。

来看一个标准写法:

@RunWith(AndroidJUnit4::class)
class MyComposeTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun testButtonClick() {
        // 设置 Compose 内容
        composeTestRule.setContent {
            MyButton(text = "点击我")
        }
        // 测试操作与断言
        composeTestRule.onNodeWithText("点击我")
            .performClick()
        // ...
    }
}

注意这里 @get:Rule 是 JUnit 的 TestRule 注入方式。我记得刚转 Kotlin 时总忘记写 @get:,结果每跑一次测试都抛“Rule 未初始化”,后来才反应过来——Rule 注解必须打在 getter 上,不然 JUnit 拿不到。嗯,这是第一个坑。

避坑指南: 我曾经在同一个测试类里混合使用 createComposeRule() 和 ActivityScenarioRule,导致 Compose 内容和 Activity 生命周期互相干扰。后来我统一只用 createAndroidComposeRule<MainActivity>(),才稳定下来。如果你的测试需要 Activity 完整生命周期,就用 createAndroidComposeRule。

顺便提一句,测试规则还有 createEmptyComposeRule(),它不自动注入 Content 环境,适合需要手动精细控制场景。我很少用,但知道它存在就行。

三、编写基础 UI 测试:三步走

写一个基础测试其实就三步:设置内容 → 查找节点 → 断言或操作。我把它拆成一张表格,方便你对照:

步骤 常用 API 说明
设置内容 composeTestRule.setContent { … } 传入 Compose 组件树,类似 setContentView 但更灵活
查找节点 onNodeWithText() / onNodeWithTag() / onNode() 基于语义匹配器找到 UI 元素
操作与断言 .performClick() / .assertIsDisplayed() / .assertTextEquals() 模拟点击、滚动,验证状态或可见性

你会发现 Compose 的 API 非常简洁。比如查找节点时,很少需要嵌套复杂匹配器——语义树天然支持扁平化搜索。我曾在项目里深度嵌套了 5 层 Row + Column,用 onNodeWithText("确认") 照样一秒定位到。

来看一个完整示例吧:

@Test
fun testGreetingShowsCorrectText() {
    composeTestRule.setContent {
        Greeting(name = "Android")
    }
    // 查找节点并断言
    composeTestRule
        .onNodeWithText("Hello, Android!")
        .assertIsDisplayed()
}

这里 assertIsDisplayed() 会等待界面稳定后再验证。onNodeWithText 内部其实调用了 SemanticsMatcher.expectValue,很灵活。

实用技巧: 当多个节点有相同文本时,可以链式调用 .onChildren() 或使用 useUnmergedTree = true 显式搜索未合并的语义树。我有个列表 item 展示相同标题的场景,就用 onAllNodesWithText("标题") 拿到集合再按索引断言。

四、再聊一个我常犯的错误

刚开始写 Compose 测试时,我总习惯在 setContent 里直接传一个 ViewModel 实例。但 Compose 测试的合成环境与 Activity 的生命周期是解耦的,直接传可能导致状态不一致。正确做法是:

  • 如果 ViewModel 依赖 Android 组件(如 Application),用 createAndroidComposeRule 并配合 hilt 注入。
  • 如果 ViewModel 是纯 Kotlin 的,反而可以用 ViewModelProvider.Factory 在 setContent 中构建。

我曾经因为没注意这点,测试里 ViewModel 的状态总跟 UI 不同步,debug 了整整一上午。最后发现是 mutableStateOf 的初始化时间与 Compose 重组时机对不上。解决办法: 在 setContent 之前预创建好 ViewModel 的 state 对象,或者用 launch 等待重组完成后再断言。

五、知识体系总览

下面这张图把 Compose UI Test 的核心脉络串联起来了。你可以作为本章的“思维导图”来收藏:

Compose UI Test 基础框架 测试框架 JUnit + ComposeTestRule 测试规则 createComposeRule 编写测试 三步:设置/查找/断言 语义树 (Semantics) onNodeWithText() assertIsDisplayed() 示例 setContent { MyButton() } onNodeWithText("OK") 操作 .performClick() .assertTextEquals("OK") 核心思想:用语义描述 UI,用规则驱动测试

六、小结一下

到现在你应该能清晰地回答这几个问题了:

  • Compose 测试框架基于 JUnit + ComposeTestRule,核心是语义匹配。
  • 测试规则用 @get:Rule 声明,createComposeRule() 或 createAndroidComposeRule() 根据场景选。
  • 基础测试三行代码:setContent → onNodeWithXxx → perform/assert。

我个人建议你在项目里先写一个最简单的“页面展示测试”,比如验证标题是否存在。它能帮你快速验证测试环境有没有配对。等到跑通第一个测试之后,你再慢慢加上点击、滚动、状态变化这些进阶操作。别一上来就写复杂的交互测试,容易把自己搞懵。我就是这么过来的,一开始踩过不少坑,但基础打牢了,后面反而越写越快。

额外提醒: Compose 测试默认会等待动画和重组完成,所以一般情况下 assertIsDisplayed() 自带等待机制。但如果你有明确的时间延迟(比如 2 秒后 UI 才变化),还是需要 composeTestRule.waitUntil { condition } 来显式等待。我遇到过组合动画循环不结束导致测试超时的案例,后来加了个 timeout 参数解决了。