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,测试时就能通过它找到组件。
举个例子,一个简单的 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 的点击事件,但它是在主线程执行的。如果你的点击事件里有网络请求或协程,记得用 runBlockingTest 或 advanceUntilIdle() 等待异步操作完成。
composeTestRule.waitUntil(timeoutMillis) { condition } 等待某个条件成立。比如等待加载动画消失、等待网络请求完成。
避坑指南:我踩过的几个坑
写 Compose 测试这一年多,我踩过不少坑。挑几个典型的说说:
- 忘记加 testTag: 有一次我写测试,怎么都找不到按钮。查了半天,发现是 Composable 里忘了加
Modifier.testTag()。嗯,这种低级错误犯过一次就够了。 - 文本匹配大小写:
findByText默认区分大小写。如果你的 UI 里显示的是「登录」,测试里写「登录」是找不到的。可以用ignoreCase = true参数。 - 多个节点匹配: 如果页面上有两个相同文本的组件,
onNodeWithText会报错「找到多个节点」。这时候需要用onAllNodesWithText获取列表,再通过索引或额外条件筛选。 - 动画导致测试不稳定: Compose 的动画默认是开启的。测试时建议用
TestMonotonicFrameClock来控制时间,或者直接禁用动画。
知识体系总览
下面这张图,帮你理清 Compose 测试的核心逻辑:
这张图展示了 Compose 测试的完整流程。你想想看,从 setContent 启动组件,到用语义定位找到目标,再到执行点击等操作,最后验证结果——每一步都清晰明了。而且整个过程不依赖传统的 View 层级,完全基于语义树,这也是 Compose 声明式 UI 的独特优势。
总结
Compose 测试其实不复杂。记住三个核心点:
- ComposeTestRule 是测试的入口,负责管理 Composable 生命周期
- 语义测试 是 Compose 的独特方式,通过语义节点定位 UI 元素
- findByText 是最常用的定位方法,但
testTag更稳定
我个人建议:从今天开始,给你的每个 Composable 都加上 testTag。哪怕现在不写测试,以后补起来也方便。别像我一样,等到出问题了才后悔没写测试。
好了,这一章的内容就到这里。记住,测试不是负担,而是保护伞。它能让你在改代码时更自信,在发版时更安心。