20、测试基础:Compose UI 测试框架,semantics 与测试标签,findNode 与断言
说实话,很多做 Android 的同学对 UI 测试都有点「敬而远之」。我以前也一样,总觉得写测试比写功能还费劲。但自从在 Jetpack Compose 里踩过几次坑之后,我彻底改变了看法——没有测试的 Compose 代码,就像没系安全带的赛车。
这一章,我们就来聊聊 Compose UI 测试的核心三板斧:测试框架怎么搭、semantics 标签怎么用、findNode 和断言怎么写。我会结合自己项目里的真实教训,帮你把这些知识点串起来。
1. 测试框架:从零搭一个 Compose 测试环境
在 Compose 里写 UI 测试,核心依赖就两个。你需要在 build.gradle.kts 里加上它们:
// 模块级 build.gradle.kts
androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.6.0")
debugImplementation("androidx.compose.ui:ui-test-manifest:1.6.0")
嗯,这里要注意:ui-test-manifest 必须用 debugImplementation,因为它只在 debug 构建中生效。我曾经因为图省事直接用了 implementation,结果 release 包编译时报了一堆奇怪的 manifest 合并错误……折腾了俩小时。
测试类本身很简单。我个人习惯这样写:
@RunWith(AndroidJUnit4::class)
class MyComposeTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun testButtonDisplaysCorrectText() {
composeTestRule.setContent {
MyButton(text = "点击我")
}
// 后面我们会用 findNode 来验证
}
}
createComposeRule() 会帮你自动管理 Compose 的 composition 生命周期。你只需要在 setContent 里放上你要测试的 Composable 函数就行。
composeTestRule.mainClock.autoAdvance = false 手动控制时间,避免测试不稳定。
2. Semantics:Compose 测试的「眼睛」
你想想看,传统的 View 系统有 R.id 来定位控件。Compose 里没有这个东西,那测试框架怎么找到某个 Button 或 Text 呢?
答案就是 Semantics(语义)。每个 Composable 在渲染时都会生成一个语义树,测试框架就是通过这棵树来定位节点的。
最常用的两个语义属性:
- contentDescription:无障碍描述,也用于测试定位。
- testTag:专门给测试用的标签,不会影响 UI 显示。
举个例子:
@Composable
fun MyButton(text: String, onClick: () -> Unit) {
Button(
onClick = onClick,
modifier = Modifier.testTag("my_button") // 测试标签
) {
Text(
text = text,
modifier = Modifier.semantics {
contentDescription = "按钮文字"
}
)
}
}
我在项目中遇到过一个问题:团队里有人给 testTag 取了很随意的名字,比如 "btn1"、"btn2"。结果半年后回来维护,根本分不清哪个是哪个。我建议你给 testTag 起名时带上层级或功能,比如 "login_screen_submit_button",这样测试代码可读性会好很多。
testTag 只在 debug 构建中保留。如果你在 release 构建里也需要测试,请使用 contentDescription 或者自定义语义属性。
3. findNode:如何精准定位 UI 元素
有了语义标签,接下来就是找到它。Compose 测试提供了几种 onNode 方法:
| 方法 | 作用 | 常用场景 |
|---|---|---|
onNodeWithTag(tag) |
通过 testTag 查找 |
最推荐,明确且高效 |
onNodeWithText(text) |
通过显示的文本查找 | 验证文字内容 |
onNodeWithContentDescription(desc) |
通过 contentDescription 查找 |
无障碍测试 |
onNode(hasText("xxx")) |
使用语义匹配器 | 复杂条件组合 |
来看一个完整的测试例子:
@Test
fun testButtonClick() {
var clickCount = 0
composeTestRule.setContent {
MyButton(text = "点击我") {
clickCount++
}
}
// 找到按钮并点击
composeTestRule.onNodeWithTag("my_button")
.performClick()
// 断言点击次数
assertThat(clickCount).isEqualTo(1)
}
这里我用的是 onNodeWithTag,因为它最稳定。文本可能会因为国际化而改变,但 testTag 不会。
testTag 定位,其次才是文本或描述。这样测试对 UI 改动的容忍度更高。
4. 断言:验证 UI 状态
找到节点之后,我们需要断言它的状态。Compose 测试提供了丰富的断言方法:
assertIsDisplayed()—— 验证组件是否可见assertTextEquals("xxx")—— 验证文本内容assertHasClickAction()—— 验证是否可点击assert(isEnabled())—— 验证是否启用
举个例子,验证一个按钮在点击后文字变化:
@Test
fun testButtonTextChangesAfterClick() {
composeTestRule.setContent {
var clicked by remember { mutableStateOf(false) }
Button(
onClick = { clicked = true },
modifier = Modifier.testTag("toggle_btn")
) {
Text(if (clicked) "已点击" else "未点击")
}
}
// 初始状态
composeTestRule.onNodeWithTag("toggle_btn")
.assertTextEquals("未点击")
// 点击
composeTestRule.onNodeWithTag("toggle_btn")
.performClick()
// 点击后状态
composeTestRule.onNodeWithTag("toggle_btn")
.assertTextEquals("已点击")
}
我曾经犯过一个低级错误:在断言时用了 assertTextContains 但没注意大小写,结果测试在 CI 上一直失败。后来我养成了习惯——文本断言尽量用 assertTextEquals,除非你明确需要模糊匹配。
5. 避坑指南:我踩过的三个坑
最后分享几个实战中容易翻车的地方:
- 忘记加
@RunWith(AndroidJUnit4::class)—— 测试会直接跳过,而且不报错。我第一次遇到时还以为自己代码写错了。 - 在
setContent里用LaunchedEffect做异步操作 —— 测试不会等待它完成。你需要用composeTestRule.waitForIdle()或者runOnIdle来同步。 - 多个相同
testTag的组件 ——onNodeWithTag会找到第一个,但如果你有列表,建议用onAllNodesWithTag然后按索引取。
composeTestRule.onRoot().printToLog("TAG"),它会打印出完整的语义树,你一眼就能看到标签有没有生效。
好了,这一章的内容就到这里。Compose UI 测试其实不难,关键是理解 semantics 是测试的桥梁,testTag 是你的定位锚点。把这些基础打牢,后面写复杂交互测试时你会轻松很多。