4. 使用ComposeTestRule进行简单UI断言:onNodeWithText、assertIsDisplayed、assertTextEquals

4.1 原理:语义节点树与断言机制

Compose UI 测试的核心是 ComposeTestRule,它在测试期间维护一棵 语义节点树(Semantics Tree)。每个可组合项在树中对应一个语义节点,节点携带文本、可见性、点击事件等属性。断言流程分为三步:查找 → 匹配 → 验证。onNodeWithText 按文本内容定位节点,assertIsDisplayed 检查节点是否在屏幕上可见,assertTextEquals 校验节点文本与预期值完全一致。

关键理解:语义节点树是 Compose 测试的“DOM 树”,所有 UI 断言都基于对这颗树的查询和属性校验。理解树结构是写出稳定断言的前提。
ComposeTestRule UI 断言执行流程 onNodeWithText("登录") 语义节点树遍历匹配 节点匹配成功? assertIsDisplayed assertTextEquals 测试通过 ✓ 抛出异常 是 是 否 断言在同一个语义节点交互对象上链式调用,先定位再校验

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 测试实现步骤,涵盖从环境配置到断言的完整链路。

  1. 添加测试依赖(build.gradle)
  2. 创建测试类,声明 ComposeTestRule
  3. 设置 Composable 内容:调用 setContent 渲染被测组件
  4. 使用 onNodeWithText 定位目标节点
  5. 链式调用断言:assertIsDisplayed、assertTextEquals
  6. 运行并验证测试结果
// 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("登录")
}