1. Compose 测试概述

咱们先聊点实在的——Compose 测试到底有多重要?

我入行做 Android 时,最早接触的还是 XML 布局和 View 体系。那时候写 UI 测试,说实话挺痛苦的。每次改个文案、调个间距,测试就崩,而且崩得莫名其妙。你想想看,一个按钮的 visibility 设错了,得跑完整套仪器测试才查得出来。后来转型到 Compose,我一开始还带着老思路去写 UI。直到有一次重构首页列表,改了个重组条件,结果数据更新时整个列表闪了一下,用户反馈就来了。

嗯,从那次之后,我彻底明白了:Compose 的底层逻辑和传统 View 体系完全不同。它的核心是「状态驱动 UI + 重组」,这意味着你的测试策略必须跟着变。

这一节我们先搭个框架,搞清楚 Compose 测试到底测什么、怎么分类、核心概念有哪些。

1.1 为什么 Compose 测试如此特殊?

传统 View 测试,你关注的是:

  • 控件是否显示
  • 点击事件是否触发
  • Activity 生命周期是否正确

但 Compose 不一样。Compose 的函数本质是 @Composable 函数,它不直接操作 View 对象,而是通过重组来更新 UI。所以测试里的「渲染」变成了「执行 Composable 函数 + 检查节点树」。

我在项目里踩过的一个坑是:用 ActivityScenario 启动一个 Compose 界面,然后直接断言某个 Text 的内容。结果跑了三遍,断言都通过了,但界面上有个动画一直在跑,耗电异常。这就是因为我没有关注「重组次数」——Compose 的重组可以同步执行,但动画的回调会持续触发重组。

⚠ 避坑指南:我曾经在测试一个带 Lottie 动画的详情页时,发现测试通过率只有 80%。查了半天,发现是我没有在测试中等待动画结束。Compose 的 withTimeout 配合 idleTimeout 才能稳定。
记住:Compose 测试不只是测 UI 显示,更要测「重组行为是否预期」。

1.2 测试类型分为哪几层?

我个人的习惯是把 Compose 测试分成三个层级,和官方推荐差不多,但重点不太一样:

层级 目标 常用工具 运行速度
单元测试(Unit) 测试单个 Composable 函数的逻辑,不含重组副作用 JUnit + compose-ui-test 的 createComposeRule() 快(纯逻辑)
集成测试(Integration) 测试多 Composable 之间状态共享、重组触发条件 createComposeRule() + Mock 回放 中等
端到端测试(E2E) 模拟用户操作,验证真实设备上的表现 Espresso + Compose Test Rule 慢

你可能会问:单元测试能测 Compose?能。因为 Composable 函数的行为本质上就是「输入 State,输出 Node tree」。用 createComposeRule() 设置初始状态,然后断言节点树上的属性即可。

// 单元测试示例:测试一个简单的计数器
@Test
fun counterDisplaysInitialValue() {
    composeTestRule.setContent {
        Counter(count = 0)
    }
    composeTestRule.onNodeWithText("0").assertExists()
}

但注意,这种测试只验证了静态渲染。如果你要验证「点击加号后值是否 +1」,那就需要引入交互测试,跑到了集成测试的范畴。

💡 我的习惯:我会把「状态变化 → UI 更新」这条路径放到集成测试里测。因为 Compose 的重组粒度很细,你很难靠纯单元测试覆盖所有边际情况。

1.3 核心概念:重组与测试的关系

Compose 测试里最难理解的就是「重组」到底怎么在测试代码中体现。说白了,重组就是 Compose 框架的「重绘调度」。它由 Recomposer 管理,当某个 state 变化时,它会通知所有依赖该 state 的 Composable 函数重新执行。

测试中,我们要面对的问题是:

  • 同步 vs 异步:生产环境的重组是异步的,但测试环境默认是同步的(compose-test-rule 内部会等待重组完成)。你想想看,如果不等待就直接断言,很可能拿到的是上一帧的节点树。
  • 副作用:LaunchedEffect、rememberCoroutineScope 等副作用在测试里是挂起的,需要用 runBlockingTest 或 advanceTimeBy 来驱动。
  • Mock 回放——这是咱们这门课的核心。当你需要模拟网络、数据库、Sensor 等外部数据源时,传统的 Mock 框架(如 Mockito)在 Compose 测试里经常不够用,因为 recomposition 是多阶段的。我踩过的坑是:Mockito 对一个 Flow 设置了 thenReturn,但 Compose 重组时 Flow 发射了两次,Mockito 只会返回第一次的数据。后来我们用了一个自研的 Mock 回放框架来解决。
🎯 重点回顾:
- Compose 测试以「Composable 函数执行 + 节点树校验」为基本单位。
- 测试类型分层:单元、集成、E2E,各有用例范围。
- 重组是核心机制,测试必须考虑同步/异步与副作用。
- Mock 回放是解决复杂外部依赖的关键方法(后续章节细讲)。

1.4 本章知识结构图

下面这张图把 Compose 测试的整体轮廓梳理了一遍。我把它放在开头,是为了让大家心里先有个地图,后边每章都会往里填细节。

Compose 测试知识体系 测试类型分层 单元测试(Unit) → 集成测试(Integration) → 端到端测试(E2E) 核心概念 重组(Recomposition) | 副作用(Side-effects) | 状态驱动 同步/异步调度 | 节点树(Node Tree) 测试工具与技术 createComposeRule() | Semantics 树 | 时间操控(advanceTimeBy) Mock 回放框架 | 状态快照(Snapshot) 说明: 本课程重点在于「集成测试层」的 Mock 回放方法,该层连接了单元测试的纯逻辑和 E2E 的全链路。

1.5 聊聊我对 Compose 测试的定位

我个人并不主张把所有 UI 逻辑都塞到 E2E 里测。Compose 的重组粒度太细了,你不可能每条组合路径都跑一遍真实设备。我更喜欢把主要精力放在「集成测试」层——也就是用 createComposeRule() 配合 Mock 回放,去验证核心交互流程。这样既能跑得快(比 E2E 快 3-5 倍),又能覆盖边界重组情况。

举个例子:我有一个商品详情页,需要展示「库存状态」和「价格」。库存数据来自网络,价格数据来自本地数据库。传统做法是启动 Activity,开网络 Mock,再开数据库 Mock,跑一次 E2E 至少 20 秒。改成集成测试后,我直接用一个 Coroutine 发射预定义的数据流,5 秒内就能跑完 10 个不同场景。这个对比太明显了。

当然,E2E 也有它不可替代的地方——比如验证真实设备上的触摸反馈、屏幕旋转、多窗口模式。但对于大部分日常迭代,集成测试的性价比是最高的。

📌 核心观点:Compose 测试不是一种「替代」,而是一种「补充」。把正确的测试放到正确的层级上,才能让项目跑得稳。