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