一、Compose UI 测试概述:你在测什么?

大家好,我是老张。做 Android 测试有些年头了,从早期的 Instrumentation 测试,到 Espresso,再到 Compose UI Test,一路踩坑过来。今天咱们聊聊 Compose UI 测试最基础的问题——它到底是什么?跟 View 系统那套测试有啥区别?又到底为什么非做不可?

我个人理解,Compose UI 测试,说白了就是验证你用 Compose 搭出来的界面好不好用、对不对。你写了个 Button,点了能不能跳转?写了个列表,滑起来会不会卡?这些事情,光靠肉眼盯着模拟器看,早晚要翻车。

一句话定义:Compose UI Test 是基于 Compose 运行时提供的语法树(Semantic Tree),通过模仿用户操作来验证界面行为的一种自动化测试方式。

跟 View 系统测试的区别

我当年从 View 系统切到 Compose 时,第一个感觉就是——测试写法完全变了。以前用 Espresso 测 View,你得先 onView(withId(R.id.btn_login)),然后 .perform(click())。现在呢?直接用 composeTestRule.onNodeWithText("登录")。你想想看,连 id 都不需要传了,直接拿文字找节点。

但区别远不止语法。我列个表格,你看一眼就明白:

对比维度 View 系统测试 (Espresso) Compose UI Test
渲染机制 基于 View Hierarchy,层层嵌套 基于 Semantic Tree,扁平语义
查找元素 靠 id、class、text 匹配 靠语义属性(文本、内容描述、标签)
异步处理 需要手动 IdlingResource 内置 Compose 协程调度,自动等待
代码结构 测试代码和 UI 代码分离 更建议写在一起,测试即文档

我个人的小建议:如果你团队还在用 View 系统,别急着全量迁移。可以先拿一个页面试试 Compose + UI Test 的写法,感受下语义树带来的清爽感。

为什么需要 UI 测试?

这个问题,我遇到过好多次。有些兄弟觉得:我有单元测试就够了,UI 测试又慢又脆。嗯,这话只说对了一半。

单元测试确实能覆盖业务逻辑,但它检查不了两件事:

  1. 布局对不对——比如你传了个空列表,页面展示的是“暂无数据”还是白屏一片?
  2. 交互响应——用户点错按钮、快速连点、滑动冲突,这些真实场景单元测试根本模拟不了。

我记得有一次做电商项目,我们单元测试全绿,结果上线第二天,用户反馈「购物车页面点加号闪退」。排查后发现是某个 composeable 的 animation 回调里访问了一个空对象。你说单元测试能测到?根本不可能。后来我们补了一个简单的 UI 测试,模拟点击加号 10 次,立刻复现 bug。

避坑指南:不要指望 UI 测试发现所有问题。它最适合的场景是——核心流转(登录、下单、支付)和边界状态(空数据、网络异常)。我之前就是踩了「全量覆盖」的坑,后来老老实实按优先级写。

所以,你想想看,UI 测试不是用来替代单元测试的,它是最后一道防线。尤其 Compose 这种声明式 UI,状态一变整个视图重建,光靠人眼看代码很难发现拼接错误。有了 UI 测试,至少能保证「用户看到的」和「你预期的」是一致的。

Compose UI 测试核心逻辑图

为了让你更直观理解,我画了一张流程图。它把 Compose UI 测试的完整数据流串起来了——从 Compose 渲染,到语义树生成,再到测试框架匹配和执行断言。

Compose UI 测试核心逻辑 Compose UI 测试核心数据流 Compose UI 渲染 语义树(Semantic Tree) ComposeTestRule 驱动 查找/匹配:onNodeWithText 断言/交互:assertExists / performClick 与传统 Espresso 对比: 不再依赖 View ID,而是语义匹配

看明白了吗?从上往下,Compose 渲染出界面后,框架自动构建语义树。ComposeTestRule 就是你的遥控器,通过它去树上找节点(比如找文字「登录」),然后要么断言它存在,要么模拟点击。整个过程,你都不需要关心控件 id 或者布局层级——这就是 Compose UI 测试最爽的地方。

简单体验一下代码

光说不练假把式。我随手写一个最简单的测试,你先感受下:

class LoginScreenTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun loginButton_shouldBeDisplayed() {
        composeTestRule.setContent {
            LoginScreen()
        }

        // 查找「登录」按钮
        composeTestRule.onNodeWithText("登录")
            .assertExists()               // 断言按钮存在
            .assertIsDisplayed()          // 断言按钮可见
    }
}

嗯,对,核心就是三个动作:setContent → onNode → assert。后期你还能加 performClick、performGesture 等交互行为。别着急,后面的章节我会手把手带你拆解每个 API 背后的原理和坑。

个人经验:刚开始不要追求复杂场景。先把你 App 里三个最核心的页面写个“存在性测试”——只检查关键文案或按钮是不是显示出来了。这一步做到,就能拦截 80% 的线上布局问题。