4. 使用ComposeTestRule进行简单UI断言:onNodeWithText、assertIsDisplayed、assertTextEquals
4.1 原理:语义节点树与断言机制
Compose UI 测试的核心是 ComposeTestRule,它在测试期间维护一棵 语义节点树(Semantics Tree)。每个可组合项在树中对应一个语义节点,节点携带文本、可见性、点击事件等属性。断言流程分为三步:查找 → 匹配 → 验证。onNodeWithText 按文本内容定位节点,assertIsDisplayed 检查节点是否在屏幕上可见,assertTextEquals 校验节点文本与预期值完全一致。
关键理解:语义节点树是 Compose 测试的“DOM 树”,所有 UI 断言都基于对这颗树的查询和属性校验。理解树结构是写出稳定断言的前提。
4.2 核心API速查表
| API | 功能 | 参数 | 返回值 |
|---|---|---|---|
| onNodeWithText | 通过文本内容查找语义节点 | text: String |
SemanticsNodeInteraction |
| assertIsDisplayed | 验证节点在屏幕上可见 | 无 | SemanticsNodeInteraction |
| assertTextEquals | 验证节点文本与预期完全一致 | text: String, vararg |
SemanticsNodeInteraction |
4.3 onNodeWithText:通过文本定位节点
onNodeWithText 是最常用的查找器,它基于语义节点的 text 属性做精确匹配。默认情况下使用合并后的语义树(merged tree),对于 Text、Button 等组件可直接命中。如果需要匹配未合并的子节点,可配合 useUnmergedTree() 使用。
// 查找文本为“登录”的节点
composeTestRule.onNodeWithText("登录")
// 配合 Matcher 做更精确的定位
composeTestRule.onNodeWithText("登录")
.assertIsDisplayed()
// 使用 unmerged tree 查找子组件文本
composeTestRule.onNodeWithText("收件人")
.useUnmergedTree()
.assertIsDisplayed()
注意:onNodeWithText 执行 精确匹配,空格、标点、大小写都参与比较。如果文本被截断或拼接,请使用
onNode(hasText(..., substring = true)) 代替。
4.4 assertIsDisplayed:验证节点可视性
assertIsDisplayed 检查语义节点的 isVisible 属性。节点必须同时满足:在屏幕可视区域内、未被父容器裁剪、透明度 > 0、尺寸 > 0。如果节点被滚动出屏幕或被其他组件覆盖,断言会失败。
// 验证“提交”按钮可见
composeTestRule.onNodeWithText("提交")
.assertIsDisplayed()
// 组合使用:先查找再链式断言
composeTestRule.onNodeWithText("确认删除")
.assertIsDisplayed()
.assertTextEquals("确认删除")
常见陷阱:即使组件在代码中设置为
visible = true,如果其父容器高度为 0 或被移出屏幕,assertIsDisplayed 仍会失败。排查时先打印语义树确认节点存在。
4.5 assertTextEquals:验证文本内容
assertTextEquals 校验节点文本与预期值是否完全一致。支持传入多个字符串,按语义节点中文本出现的顺序依次匹配。常用于验证标签、按钮文案、提示信息等动态文本内容。
// 单文本匹配
composeTestRule.onNodeWithText("欢迎回来")
.assertTextEquals("欢迎回来")
// 多文本匹配(按顺序)
composeTestRule.onNodeWithText("用户名")
.assertTextEquals("用户名", "请输入账号")
// 与后台数据对比
val expectedName = viewModel.userName.value
composeTestRule.onNodeWithText(expectedName)
.assertTextEquals(expectedName)
| 断言方法 | 校验内容 | 失败场景示例 |
|---|---|---|
assertIsDisplayed |
节点是否在屏幕上可见 | 节点被折叠、尺寸为0、透明度为0 |
assertTextEquals |
文本内容是否完全一致 | 多了一个空格、中英文混入、大小写差异 |
4.6 完整实现流程
以下是一个标准的 Compose UI 测试实现步骤,涵盖从环境配置到断言的完整链路。
- 添加测试依赖(build.gradle)
- 创建测试类,声明 ComposeTestRule
- 设置 Composable 内容:调用
setContent渲染被测组件 - 使用 onNodeWithText 定位目标节点
- 链式调用断言:assertIsDisplayed、assertTextEquals
- 运行并验证测试结果
// build.gradle (module)
dependencies {
androidTestImplementation "androidx.compose.ui:ui-test-junit4:1.5.0"
debugImplementation "androidx.compose.ui:ui-test-manifest:1.5.0"
}
// LoginScreenTest.kt
@RunWith(AndroidJUnit4::class)
class LoginScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun loginButton_displayedAndTextCorrect() {
// 3. 设置被测 Composable
composeTestRule.setContent {
LoginScreen()
}
// 4. 定位节点
val button = composeTestRule.onNodeWithText("登录")
// 5. 链式断言
button.assertIsDisplayed()
button.assertTextEquals("登录")
}
}
4.7 调试技巧
当断言失败时,快速定位问题是关键。以下方法可帮助排查语义节点状态。
- 打印语义树:使用
printToLog输出完整节点结构 - 查看所有节点:
onAllNodes()配合printToLog列出所有语义节点 - 使用测试标签:为组件添加
testTag后用onNodeWithTag定位
// 打印语义树到 Logcat
composeTestRule.onAllNodes().printToLog("UI_TEST")
// 输出示例 (Logcat)
// Node #1: Text = "登录" Role = Button Visible = true
// Node #2: Text = "忘记密码" Role = Text Visible = false
// 使用测试标签定位
composeTestRule.onNodeWithTag("login_button")
.assertIsDisplayed()
调试建议:在
setContent 后立即调用 printToLog,确保语义树已正确构建。对比预期节点与实际输出,可快速发现拼写、大小写或中英文差异。
4.8 常见错误与避坑
| 错误类型 | 典型表现 | 解决方案 |
|---|---|---|
| 文本不匹配 | assertTextEquals 抛出 AssertionError | 检查中英文、空格、换行符;使用 hasText(substring = true) 做包含匹配 |
| 节点未找到 | onNodeWithText 抛出 AssertionError: No node found |
确认组件已渲染(非条件渲染);使用 waitForExists 等待异步加载 |
| 多个节点匹配 | 抛出 More than one node found |
使用 onFirst() 取第一个节点,或配合 hasText + hasParent 缩小范围 |
| 节点不可见 | assertIsDisplayed 失败 | 检查父容器宽高、滚动位置、透明度;打印语义树确认 isVisible 属性 |
| 异步更新未生效 | 文本内容与后台数据不一致 | 使用 waitUntil 或 waitForIdle 等待 Composition 稳定 |
4.9 性能与稳定性建议
- 避免重复查找:将
onNodeWithText结果存入变量复用,不要在每个断言中重新查找。 - 合理使用等待:后台任务更新 UI 时,先调用
waitForIdle()或waitUntil(timeout)确保树稳定再执行断言。 - 精简断言数量:每个测试用例验证 1~3 个关键属性即可,过度断言会拖慢测试套件。
- 使用 unmerged tree 谨慎:启用
useUnmergedTree()会增加节点数量,仅当默认合并树无法定位时使用。 - 固定测试数据:用假数据或 Mock 数据替代真实网络请求,避免因数据波动导致断言不稳定。
// 推荐写法:复用节点引用 + 等待空闲
@Test
fun loginButton_withBackendUpdate() {
composeTestRule.setContent { LoginScreen() }
// 等待后台任务完成
composeTestRule.waitForIdle()
val loginBtn = composeTestRule.onNodeWithText("登录")
loginBtn.assertIsDisplayed()
loginBtn.assertTextEquals("登录")
}