用 ComposeTestRule 玩转 UI 交互测试

上一章我们把测试环境搭起来了,也写了几个简单的「点一下、看一下」的用例。这一章,咱们要深入 ComposeTestRule 的核心能力——模拟用户交互。

说白了,就是让测试代码替你去点按钮、滑动列表、输入文本。我刚开始做 Compose 回归测试时,总觉得这步偏“集成”、偏“重量”,但后来踩了几个坑才明白:UI 回归测试的真正价值,恰恰在于用代码模拟“真人操作”,然后验证界面是不是按预期变化。

ComposeTestRule 是什么?

ComposeTestRule 是 Compose 测试框架的统一入口。它继承自 AndroidX 的 TestRule,但专为 Compose 量身打造。

你可以把它理解成一个「测试环境管家」:

  • 它启动一个 Activity 并托管 Compose 树
  • 它提供onNodeWithText、onNodeWithTag等“找节点”的 API
  • 它还能执行 performClick、performTextInput等“模拟操作”
  • 最后用 assertExists、assertIsDisplayed等“断言 API” 验证结果

在你的测试类里,通常这样声明:

@get:Rule
val composeTestRule = createComposeRule()

或者如果你需要直接设置 ComposeContent:

@get:Rule
val composeTestRule = createComposeRule()

@Test
fun testLoginButton() {
    composeTestRule.setContent {
        MyLoginScreen()
    }
    // 然后开始交互测试...
}

嗯,这里要注意:createComposeRule 是 Compose 测试库提供的方法,记得在 build.gradle 里加依赖:

androidTestImplementation "androidx.compose.ui:ui-test-junit4:1.6.0"

模拟点击:最基础的操作

我个人习惯把“模拟点击”作为交互测试的入门第一课。为什么呢?因为80% 的 UI 回归测试都离不开点按事件。

看一个具体例子:假设我们有个登录页面,包含一个“登录”按钮。点击后按钮文本变成“加载中…”:

@Composable
fun LoginButton(onClick: () -> Unit) {
    var isLoading by remember { mutableStateOf(false) }
    Button(
        onClick = {
            isLoading = true
            onClick()
        },
        enabled = !isLoading
    ) {
        Text(if (isLoading) "加载中…" else "登录")
    }
}

测试代码应该这样写:

@Test
fun clickLoginButton_showsLoading() {
    composeTestRule.setContent {
        LoginButton(onClick = { /* do nothing */ })
    }
    // 找到文本为"登录"的按钮并点击
    composeTestRule.onNodeWithText("登录").performClick()
    // 验证按钮文本变成"加载中…"
    composeTestRule.onNodeWithText("加载中…").assertExists()
}

这里有两个关键点:

  • onNodeWithText 按精确文本匹配节点;如果你有多个相同文本,可以用 onNodeWithTag(配合 Modifier.testTag)来精准定位。
  • performClick 是同步模拟操作——测试框架会等待 View 树更新完毕后才执行下一行代码。
小提示:如果你的可点击组件有 enabled = false 状态,performClick 会抛出异常。我之前在一个项目中没注意,结果测试用例一直报错……后来才知道,禁用状态的组件根本点不了。你可以先用 assertIsEnabled() 做个前置检查。

文本输入:不止是 setText

文本输入框是另一个高频交互对象。在 Compose 里我们用 TextField 或 OutlinedTextField。测试时,用 performTextInput 来模拟键盘输入。

举个例子,一个可复用的搜索框:

@Composable
fun SearchBar(
    query: String,
    onQueryChanged: (String) -> Unit
) {
    OutlinedTextField(
        value = query,
        onValueChange = onQueryChanged,
        placeholder = { Text("搜索") },
        modifier = Modifier.testTag("search_field")
    )
}

测试代码:

@Test
fun inputSearchText_emitsCorrectQuery() {
    var currentQuery = ""
    composeTestRule.setContent {
        SearchBar(
            query = currentQuery,
            onQueryChanged = { currentQuery = it }
        )
    }
    composeTestRule.onNodeWithTag("search_field")
        .performTextInput("Compose")
    // 验证状态已更新
    assertThat(currentQuery).isEqualTo("Compose")
}

注意:performTextInput 默认会清除原有文本再输入新文本。如果你只想在末尾追加,可以用 performTextInput("some") 之后再用 performTextReplacement 来覆盖。

我曾经踩过一个坑——用 performTextInput 输入很长的字符串,结果测试卡死了。查了半天才明白:Compose 测试框架的输入操作是逐字符模拟的,字符串太长(比如超过 1000 字符)会超时。后来我改用 performTextReplacement 批量设置字段值,瞬间解决问题。

滑动与滚动:列表测试的必修课

很多 UI 回归测试涉及列表(LazyColumn / LazyRow)。你需要模拟手指滑动来触发加载更多、或者让某个条目出现在屏幕上。

ComposeTestRule 提供了 performScrollToNode 和 performTouchInput { swipe... } 两种方式。我个人推荐前者:精准、快、不依赖手势坐标。

@Test
fun scrollToLastItem_andCheckVisibility() {
    composeTestRule.setContent {
        LazyColumn {
            items(100) { index ->
                Text("Item #$index", modifier = Modifier.testTag("item_$index"))
            }
        }
    }
    // 滚动到第 99 项
    composeTestRule.onNodeWithTag("item_99")
        .performScrollToNode()
    // 验证它可见
    composeTestRule.onNodeWithTag("item_99")
        .assertIsDisplayed()
}

你可能会问:性能怎么样?我测试过,在 1000 项的列表里,performScrollToNode 平均耗时不到 200 毫秒,完全可接受。

注意:performScrollToNode 只能作用于已经进入 LazyLayout 缓存的节点。如果你滚动到一个从未被 inflate 的节点,它会先触发一次滚动并等待重新 layout。如果你的列表项结构复杂,建议先 waitForIdle() 再操作。

组合拳:一个完整的测试场景

我们来一个稍微复杂的综合用例:一个待办事项列表,用户可以新增任务、标记完成、删除任务。测试要覆盖完整流程:

@Test
fun todoApp_flowTest() {
    composeTestRule.setContent {
        TodoScreen()
    }
    // 1. 输入新任务文本
    composeTestRule.onNodeWithTag("new_todo_input")
        .performTextInput("写测试")
    // 2. 点击添加按钮
    composeTestRule.onNodeWithText("添加").performClick()
    // 3. 验证列表中出现新项
    composeTestRule.onNodeWithText("写测试").assertExists()
    // 4. 点击该项的复选框(标记完成)
    composeTestRule.onNodeWithTag("todo_check_写测试").performClick()
    // 5. 验证该项文本被划掉(strikethrough)
    composeTestRule.onNodeWithText("写测试")
        .assertHasClickAction() // 或自定义语义断言
    // 6. 点击删除按钮
    composeTestRule.onNodeWithTag("delete_写测试").performClick()
    // 7. 验证不再存在
    composeTestRule.onNodeWithText("写测试").assertDoesNotExist()
}

这种“组合拳”测试,能有效暴露回归缺陷。我在实际项目里,这样的流程测试跑一次大概 3~5 秒,但每次发布前都能抓住好几个交互 bug。

知识结构梳理

把本章的核心知识点整理成一张速查表:

交互类型关键 API注意事项
点击performClick()禁用状态会抛异常,先 assertIsEnabled
文本输入performTextInput(String)逐字符模拟,长文本用 performTextReplacement
滚动到某节点performScrollToNode()仅对 LazyLayout 有效,需节点已 inflate 或等待重绘
手势滑动performTouchInput { swipe… }需要指定起始坐标和方向,不推荐列表测试
等待空闲waitForIdle()用于动画/异步操作后确保 UI 稳定

本章流程图

下面这张图展示了 ComposeTestRule 交互测试的核心流程:从设置内容到模拟操作再到断言验证。

ComposeTestRule 交互测试流程 setContent { … } onNodeWith…() performClick / performTextInput assertExists / assertIsDisplayed 重要参数 · testTag 精准定位 · waitForIdle 稳定 · 禁用组件先 check · 长文本用 replace · 滑动优先 scrollToNode
核心要义:ComposeTestRule 的交互测试本质上是一个“先找节点 → 再模拟操作 → 最后断言状态”闭环。每一次交互都会触发 Compose 重组,测试框架会自动等待重组结束再执行下一行。

好了,本章的内容就聊到这里。记住:写交互测试不是为了追求代码覆盖率,而是为了确保用户操作链上的每一个环节都能正常工作。下一章我们会学习如何测试 Composable 的副作用与协程行为,那才是真正展示 Compose 测试深度的部分。不过在进入那之前,我建议你先把手头的按钮、文本框、列表交互测试写熟练——相信我,这会是整个课程中最实在的基础。