26、测试基础:ComposeTestRule、semantics 测试、findByText

说实话,很多做 Android 开发的朋友,一听到「写测试」就头疼。我以前也这样,总觉得测试是 QA 的事,自己把功能跑通就行了。直到有一次,我改了一个看似无关的 UI 属性,结果把整个页面的点击事件搞崩了——而且是在发版前一天才发现的。嗯,从那以后,我再也不敢轻视 UI 测试了。

Compose 的测试,跟传统 View 系统完全不一样。它不依赖 Instrumentation 那一套繁琐的 findViewById,而是用一种叫 semantics(语义) 的方式来定位和验证 UI 元素。说白了,就是给每个组件打上「标签」,然后通过标签来找它、点它、验证它。

ComposeTestRule:测试的入口

在 Compose 里写测试,第一步就是创建 ComposeTestRule。它负责启动 Composable、控制主线程、等待布局完成。我个人习惯用 createComposeRule(),简单直接。

@get:Rule
val composeTestRule = createComposeRule()

@Test
fun testButtonClick() {
    composeTestRule.setContent {
        MyScreen()
    }
    // 接下来就可以写测试逻辑了
}

这里有个坑:setContent 必须在测试方法里调用,不能在 @Before 里提前设好。为什么?因为每个测试用例都需要独立的 Composable 树,避免状态污染。我曾经把 setContent 放到 @Before 里,结果多个测试互相影响,查了半天才找到原因。

semantics 测试:Compose 的「眼睛」

传统 View 测试里,我们通过 R.id.xxx 找控件。Compose 没有 id 的概念,它用的是 semantics 属性树。每个 Composable 在渲染时,都会生成一个语义节点,里面包含文本、角色、状态等信息。

你可以把 semantics 想象成「无障碍信息」。实际上,Compose 的测试框架就是基于无障碍 API 实现的。所以,如果你给组件加了 contentDescription,测试时就能通过它找到组件。

核心概念:测试不是找「View」,而是找「语义节点」。你验证的是「屏幕上有什么信息」,而不是「某个 View 对象是否存在」。

举个例子,一个简单的 Text 组件:

Text(text = "Hello World")

它的语义节点里就包含 text = "Hello World"。测试时,我们可以通过 findByText("Hello World") 找到它。

findByText:最常用的定位方式

findByText 是 Compose 测试里最常用的方法。它会在语义树里搜索包含指定文本的节点。用法很简单:

composeTestRule.onNodeWithText("Hello World").assertExists()

但要注意,findByText 默认是 精确匹配。如果你写 "Hello",它找不到 "Hello World"。需要模糊匹配的话,可以用 contains 参数:

composeTestRule.onNodeWithText("Hello", substring = true).assertExists()

我个人建议:能用精确匹配就用精确匹配,避免误匹配。只有在文本动态变化时才用 substring。比如一个显示「剩余 3 次机会」的文本,数字会变,那就用 substring 匹配「剩余」两个字。

其他语义定位方式

除了 findByText,还有几个常用的定位方法:

方法 说明 示例
onNodeWithTag 通过 testTag 定位 Modifier.testTag("login_btn")
onNodeWithContentDescription 通过 contentDescription 定位 常用于 Image 或 Icon
onNode(hasText("xxx")) 更灵活的语义匹配 可组合多个条件

testTag 是我最推荐的方式。它就像传统 View 的 android:id,稳定、唯一、不受文本变化影响。你只需要在 Composable 里加一行:

Modifier.testTag("submit_button")

测试时就能用 onNodeWithTag("submit_button") 精准定位。我在项目里给所有关键交互元素都加了 testTag,测试代码写起来特别顺畅。

实战:写一个完整的测试

光说不练假把式。我们写一个完整的测试用例,测试一个登录按钮的点击事件:

@Test
fun testLoginButton() {
    // 1. 设置 Composable
    composeTestRule.setContent {
        LoginScreen()
    }

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

    // 3. 验证点击后的效果
    composeTestRule.onNodeWithText("登录成功")
        .assertIsDisplayed()
}

这里有个细节:performClick() 会触发 Compose 的点击事件,但它是在主线程执行的。如果你的点击事件里有网络请求或协程,记得用 runBlockingTestadvanceUntilIdle() 等待异步操作完成。

小技巧:测试异步操作时,可以用 composeTestRule.waitUntil(timeoutMillis) { condition } 等待某个条件成立。比如等待加载动画消失、等待网络请求完成。

避坑指南:我踩过的几个坑

写 Compose 测试这一年多,我踩过不少坑。挑几个典型的说说:

  • 忘记加 testTag: 有一次我写测试,怎么都找不到按钮。查了半天,发现是 Composable 里忘了加 Modifier.testTag()。嗯,这种低级错误犯过一次就够了。
  • 文本匹配大小写: findByText 默认区分大小写。如果你的 UI 里显示的是「登录」,测试里写「登录」是找不到的。可以用 ignoreCase = true 参数。
  • 多个节点匹配: 如果页面上有两个相同文本的组件,onNodeWithText 会报错「找到多个节点」。这时候需要用 onAllNodesWithText 获取列表,再通过索引或额外条件筛选。
  • 动画导致测试不稳定: Compose 的动画默认是开启的。测试时建议用 TestMonotonicFrameClock 来控制时间,或者直接禁用动画。

知识体系总览

下面这张图,帮你理清 Compose 测试的核心逻辑:

Compose 测试核心逻辑 ComposeTestRule setContent() 启动 Composable 树 语义定位 onNodeWithText / Tag 交互操作 performClick() 等 结果验证 assertExists / assertIsDisplayed 核心流程:启动 → 定位 → 操作 → 验证 所有操作都基于语义树,不依赖 View 层级

这张图展示了 Compose 测试的完整流程。你想想看,从 setContent 启动组件,到用语义定位找到目标,再到执行点击等操作,最后验证结果——每一步都清晰明了。而且整个过程不依赖传统的 View 层级,完全基于语义树,这也是 Compose 声明式 UI 的独特优势。

总结

Compose 测试其实不复杂。记住三个核心点:

  • ComposeTestRule 是测试的入口,负责管理 Composable 生命周期
  • 语义测试 是 Compose 的独特方式,通过语义节点定位 UI 元素
  • findByText 是最常用的定位方法,但 testTag 更稳定

我个人建议:从今天开始,给你的每个 Composable 都加上 testTag。哪怕现在不写测试,以后补起来也方便。别像我一样,等到出问题了才后悔没写测试。

好了,这一章的内容就到这里。记住,测试不是负担,而是保护伞。它能让你在改代码时更自信,在发版时更安心。