【用案例学Jetpack ComposeUI设计】第9课 测试与质量保证:从语义树查找器到Hilt集成测试,用真实场景构建可靠的Compose应用
第1课建立了声明式 UI 的基本认知,第2课掌握了布局系统,第3课深入了状态管理与副作用,第4课完成了导航与多页面架构,第5课构建了主题与设计系统,第6课让界面"活"了起来,第7课让界面面向全球用户,第8课让界面流畅运行。第9课聚焦 Compose 的测试与质量保证。
本课用真实 App 场景深入理解:createComposeRule与createAndroidComposeRule的选择、语义树查找器与断言、testTag与contentDescription的测试用法、节点交互与手势操作、同步机制与waitUntil、Hilt 集成测试、KMP 共享代码测试,以及完整的测试策略。
每个案例都按"真实场景 → 设计目标 → 界面拆解 → 完整代码(逐行注释) → UI 设计解读 → 常见陷阱 → 可改进方向(含参考答案) → 关键要点"展开。
课后共 8 道练习,每道练习后紧跟参考答案(逐行注释)和设计解读。
一、为什么测试是 Compose UI 设计的最后一块拼图?
前八课我们做的界面"好看、好动、好用、好访问、好流畅",但有一个关键问题被忽略了:这些界面,正确吗?
你写了一个登录页面,逻辑看起来没问题。但用户输入密码后点击登录,按钮没有反应——因为你在 enabled 条件里写错了变量名。你写了一个商品列表,数据加载后界面不更新——因为你忘了用 mutableStateOf 包装列表。你写了一个导航流程,从首页进入详情页后按返回键却退出了 App——因为返回栈的 popUpTo 配置错了。
这些问题,人工测试也能发现,但成本极高。每改一行代码,就要把所有页面重新点一遍。而 Compose 的测试 API 让你可以自动化验证界面行为:输入文字、点击按钮、断言文字显示、验证导航跳转。改完代码跑一遍测试,几分钟就能确认没有回归。
一个真实的教训
某团队在开发一个电商 App 时,商品详情页的"加入购物车"按钮有一个 bug:当商品库存为 0 时,按钮仍然可点击,点击后弹出"添加成功"的 Toast,但购物车中并没有商品。这个 bug 在人工测试中被遗漏了,因为测试人员总是用有库存的商品测试。上线后,用户投诉"加入购物车没反应"。
如果当时有一个自动化测试:
@Test
fun addToCart_whenStockIsZero_buttonShouldBeDisabled() {
composeTestRule.setContent {
AddToCartButton(stock = 0, onAddToCart = {})
}
composeTestRule.onNodeWithText("加入购物车")
.assertIsNotEnabled()
}
这个 bug 在提交代码时就会被发现。自动化测试的价值不在于"发现 bug",而在于"防止 bug 回归"——每次改代码,跑一遍测试,几分钟就能确认没有破坏已有功能。
Compose 测试的核心:语义树
第7课我们讲过,Compose 的 UI 对 TalkBack 来说是一棵语义树。测试 API 也使用同一棵语义树来查找元素。这是 Compose 测试的核心机制:测试和辅助技术看到的是同一个界面抽象。
语义树(测试看到的)
├── 节点 #1 (Button, Text="登录", Enabled=true)
├── 节点 #2 (TextField, Text="密码", EditableText="123456")
├── 节点 #3 (Text, Text="密码至少 6 位")
└── 节点 #4 (Text, Text="欢迎登录")
测试 API 通过查找器选择节点,通过断言验证属性,通过操作注入用户事件。这与第7课的可访问性语义树是同一个东西——测试和辅助技术共享同一套语义基础设施。这意味着:为可访问性添加的 contentDescription 和 mergeDescendants,同时也是测试的查找依据。做好可访问性就等于为测试打好了基础。
测试的两个规则:createComposeRule vs createAndroidComposeRule
Compose 测试的核心是 ComposeTestRule。有两种创建方式:
| 规则 | 用途 | 适用场景 |
|---|---|---|
createComposeRule() | 不依赖 Activity | 纯 Composable 测试,如单个组件的渲染和交互 |
createAndroidComposeRule<A>() | 依赖 Activity | 集成测试,需要访问 Activity、导航、Hilt |
选择原则: 如果测试只需要验证 Composable 的渲染和交互,用 createComposeRule()。如果需要启动 Activity、测试导航、集成 Hilt,用 createAndroidComposeRule<A>()。
用一个比喻: createComposeRule 像在实验室里测试一个零件,createAndroidComposeRule 像把零件装到整车上测试。前者更快更专注,后者更真实但更慢。
测试的四层金字塔
| 层次 | 测试类型 | 工具 | 运行速度 | 覆盖范围 |
|---|---|---|---|---|
| 单元测试 | ViewModel、Repository | JUnit + MockK | 快(毫秒) | 逻辑正确性 |
| Compose UI 测试 | 单个 Composable | createComposeRule | 中(秒) | 渲染和交互 |
| 集成测试 | 页面 + 导航 + Hilt | createAndroidComposeRule | 慢(秒-分钟) | 端到端流程 |
| 截图测试 | 视觉回归 | Paparazzi/Roborazzi | 中 | 视觉一致性 |
测试金字塔的核心理念: 越底层的测试越多、越快、越稳定;越高层的测试越少、越慢、越脆弱。不要把所有测试都写成端到端测试,那样运行一次要几十分钟,开发效率会急剧下降。
本课案例地图
| 案例 | 核心技术 | 真实场景 |
|---|---|---|
| 一 | createComposeRule + 语义查找器 | 登录表单测试 |
| 二 | testTag + contentDescription | 图标按钮测试 |
| 三 | 节点操作 + 手势 | 列表滚动 + 滑动删除 |
| 四 | 同步机制 + waitUntil | 异步数据加载测试 |
| 五 | Hilt + createAndroidComposeRule | 登录流程集成测试 |
| 六 | 导航测试 | 多页面流程测试 |
| 七 | KMP 共享代码测试 | 跨平台逻辑测试 |
| 八 | 综合案例 + 测试策略 | 完整电商 App 测试 |
案例一:登录表单——用 createComposeRule 和语义查找器测试
1. 真实场景
你在做一个登录页面,包含用户名输入框、密码输入框、登录按钮。你需要测试以下行为:
- 初始状态下,登录按钮禁用。
- 输入用户名和密码后,登录按钮变为可点击。
- 密码少于 6 位时,显示错误提示。
- 点击登录按钮后,回调被触发并携带正确的用户名和密码。
这些测试不需要启动 Activity,用 createComposeRule() 就足够了。
2. 设计目标
- 用
createComposeRule()创建测试规则。 - 用
onNodeWithText和onNodeWithTag查找节点。 - 用
performTextInput、performClick执行操作。 - 用
assertIsDisplayed、assertIsEnabled、assertIsNotEnabled断言。 - 用捕获变量验证回调参数。
3. 界面拆解
- 查找器:
onNodeWithText、onNodeWithTag、onNodeWithContentDescription。 - 操作:
performTextInput、performClick、performTextClearance。 - 断言:
assertIsDisplayed、assertIsEnabled、assertIsNotEnabled、assertTextEquals。
测试的 AAA 模式:
- Arrange:设置测试内容,准备数据。
- Act:执行用户操作(输入、点击)。
- Assert:验证结果(断言状态、捕获回调)。
4. 完整代码(逐行注释)
// ============ 被测组件 ============
@Composable
fun LoginForm( // 登录表单
onLoginClick: (String, String) -> Unit // 登录回调
) {
var username by remember { mutableStateOf("") } // 用户名
var password by remember { mutableStateOf("") } // 密码
var error by remember { mutableStateOf<String?>(null) } // 错误
val canSubmit = username.isNotBlank() && password.length >= 6 // 能否提交
Column( // 纵向布局
modifier = Modifier
.fillMaxSize() // 占满
.padding(16.dp), // 内边距
verticalArrangement = Arrangement.spacedBy(12.dp) // 间距
) {
OutlinedTextField( // 用户名输入框
value = username, // 值
onValueChange = { // 变化
username = it // 更新
error = null // 清空错误
},
label = { Text("用户名") }, // 标签
modifier = Modifier
.fillMaxWidth() // 占满宽度
.testTag("username_field") // 测试标签
)
OutlinedTextField( // 密码输入框
value = password, // 值
onValueChange = { // 变化
password = it // 更新
error = if (it.length in 1..5) "密码至少 6 位" else null // 校验
},
label = { Text("密码") }, // 标签
isError = error != null, // 错误状态
modifier = Modifier
.fillMaxWidth() // 占满宽度
.testTag("password_field") // 测试标签
)
if (error != null) { // 错误提示
Text( // 错误文字
text = error!!, // 内容
color = MaterialTheme.colorScheme.error, // 错误色
modifier = Modifier.testTag("error_text") // 测试标签
)
}
Button( // 登录按钮
onClick = { // 点击
if (username.isBlank()) { // 用户名为空
error = "请输入用户名" // 错误
} else if (password.length < 6) { // 密码太短
error = "密码至少 6 位" // 错误
} else { // 校验通过
onLoginClick(username, password) // 回调
}
},
enabled = canSubmit, // 能否点击
modifier = Modifier
.fillMaxWidth() // 占满宽度
.testTag("login_button") // 测试标签
) {
Text("登录") // 按钮文字
}
}
}
// ============ 测试代码 ============
class LoginFormTest { // 登录表单测试
@get:Rule // 规则
val composeTestRule = createComposeRule() // 创建 Compose 测试规则
@Test // 测试:初始状态按钮禁用
fun loginButton_disabledInitially() { // 初始禁用
// Arrange
composeTestRule.setContent { // 设置内容
LoginForm(onLoginClick = { _, _ -> }) // 登录表单
}
// Assert
composeTestRule.onNodeWithTag("login_button") // 查找按钮
.assertIsNotEnabled() // 断言禁用
}
@Test // 测试:输入后按钮可用
fun loginButton_enabledAfterValidInput() { // 输入有效后按钮可用
// Arrange
composeTestRule.setContent { // 设置内容
LoginForm(onLoginClick = { _, _ -> }) // 登录表单
}
// Act
composeTestRule.onNodeWithTag("username_field") // 查找用户名输入框
.performTextInput("张三") // 输入文字
composeTestRule.onNodeWithTag("password_field") // 查找密码输入框
.performTextInput("123456") // 输入文字
// Assert
composeTestRule.onNodeWithTag("login_button") // 查找按钮
.assertIsEnabled() // 断言可用
}
@Test // 测试:密码太短显示错误
fun passwordTooShort_showsError() { // 密码太短显示错误
// Arrange
composeTestRule.setContent { // 设置内容
LoginForm(onLoginClick = { _, _ -> }) // 登录表单
}
// Act
composeTestRule.onNodeWithTag("password_field") // 查找密码输入框
.performTextInput("123") // 输入文字
// Assert
composeTestRule.onNodeWithTag("error_text") // 查找错误文字
.assertTextEquals("密码至少 6 位") // 断言文字
}
@Test // 测试:点击登录触发回调
fun loginClick_triggersCallback() { // 点击登录触发回调
// Arrange
var capturedUsername = "" // 捕获的用户名
var capturedPassword = "" // 捕获的密码
composeTestRule.setContent { // 设置内容
LoginForm( // 登录表单
onLoginClick = { username, password -> // 回调
capturedUsername = username // 捕获
capturedPassword = password // 捕获
}
)
}
// Act
composeTestRule.onNodeWithTag("username_field") // 用户名
.performTextInput("张三") // 输入
composeTestRule.onNodeWithTag("password_field") // 密码
.performTextInput("123456") // 输入
composeTestRule.onNodeWithTag("login_button") // 按钮
.performClick() // 点击
// Assert
assertEquals("张三", capturedUsername) // 断言用户名
assertEquals("123456", capturedPassword) // 断言密码
}
}
5. UI 设计解读
第一,createComposeRule() 的适用场景。 这个测试不需要启动 Activity,只需要验证 Composable 的渲染和交互。createComposeRule() 提供了 setContent 方法,可以直接设置被测内容。它比 createAndroidComposeRule() 更快,适合测试单个组件。记住:测试越轻量,运行越快,反馈越及时。
第二,testTag 的核心作用。 Modifier.testTag("username_field") 为组件添加一个测试标签,测试中通过 onNodeWithTag 查找。testTag 不会影响生产环境,它只在测试时可用。如果组件有多个相同文本的元素,用 testTag 比用文本更可靠。
第三,语义查找器的选择。 onNodeWithText 适合查找有文字的元素。onNodeWithTag 适合查找有测试标签的元素。onNodeWithContentDescription 适合查找有内容描述的元素(如图标按钮)。优先使用 testTag,因为文本可能变化(如国际化),但 testTag 是稳定的。
第四,AAA 测试模式。 每个测试都遵循 Arrange(准备)、Act(执行)、Assert(断言)三个阶段。这让测试代码结构清晰,一眼就能看出"测试了什么"和"期望什么"。好的测试名应该是"被测行为_条件_期望结果"的格式。
第五,测试的稳定性。 Compose 测试默认在 UI 空闲时执行操作和断言。performTextInput 输入文字后,Compose 会等待重组完成再返回。这保证了测试的稳定性,不需要手动 Thread.sleep。
6. 常见陷阱
- 用
Thread.sleep等待异步操作:应该用waitUntil或IdlingResource。 - 用文本查找元素但文本会变化:应该用
testTag。 - 测试中创建真实的网络请求:应该用 Mock 或 Fake 替代。
- 忘记
debugImplementation("androidx.compose.ui:ui-test-manifest"):createComposeRule()需要这个依赖。 setContent中调用多次:一个测试只能调用一次setContent。- 测试名不清晰:
test1、test2这样的命名无法表达测试意图。
7. 可改进方向(含参考答案)
可改进点
- 用
onAllNodesWithTag测试多个节点。 - 用
performTextClearance测试清空输入。 - 用
assertCountEquals测试列表项数量。
增强版参考答案
@Test // 测试:多个列表项
fun list_containsCorrectItems() { // 列表包含正确项
composeTestRule.setContent { // 设置内容
MyAppTheme { // 主题
ProductList( // 商品列表
products = listOf("商品 A", "商品 B", "商品 C") // 数据
)
}
}
// 断言列表项数量
composeTestRule.onAllNodesWithTag("product_item") // 所有商品项
.assertCountEquals(3) // 断言数量
}
@Test // 测试:清空输入
fun clearTextField() { // 清空输入
composeTestRule.setContent { // 设置内容
LoginForm(onLoginClick = { _, _ -> }) // 登录表单
}
composeTestRule.onNodeWithTag("username_field") // 用户名输入框
.performTextInput("张三") // 输入
composeTestRule.onNodeWithTag("username_field") // 用户名输入框
.performTextClearance() // 清空
composeTestRule.onNodeWithTag("username_field") // 用户名输入框
.assertTextEquals("") // 断言为空
}
改进解读
onAllNodesWithTag 返回一个 SemanticsNodeInteractionCollection,可以对其执行 assertCountEquals、filter 等操作。performTextClearance 清空输入框的内容。这些 API 让测试可以覆盖更复杂的交互场景。建议为每个测试写一个清晰的测试名,说明测试的行为和期望。
8. 关键要点
createComposeRule()适合不需要 Activity 的 Composable 测试。testTag为组件添加稳定的测试标识,不受文本变化影响。performTextInput、performClick执行用户操作。assertIsEnabled、assertTextEquals验证组件状态。- Compose 测试自动等待 UI 空闲,不需要手动延迟。
- 测试命名遵循"被测行为_条件_期望结果"格式。
案例二:图标按钮——用 contentDescription 和 testTag 测试
1. 真实场景
你在做一个工具栏,包含返回按钮、搜索按钮、购物车按钮。每个按钮都是一个 IconButton,没有可见文本。测试这些按钮需要特殊的查找方式。
2. 设计目标
- 用
onNodeWithContentDescription查找图标按钮。 - 用
testTag为图标按钮添加稳定标识。 - 用
assertContentDescriptionEquals验证描述。 - 理解
useUnmergedTree参数的使用场景。
3. 界面拆解
- 图标按钮:
IconButton包含Icon,Icon的contentDescription是测试查找的关键。 useUnmergedTree:默认情况下,Compose 会合并子节点的语义。如果按钮合并了子节点的语义,contentDescription可以在父节点上找到。如果需要访问未合并的树,设置useUnmergedTree = true。printToLog:在测试中打印语义树结构,帮助调试查找器。
关键决策:testTag 还是 contentDescription?
| 维度 | testTag | contentDescription |
|---|---|---|
| 用途 | 测试专用 | 可访问性 + 测试 |
| 是否影响生产 | 否 | 是(TalkBack 会朗读) |
| 稳定性 | 高 | 中(可能随 UI 变化) |
| 推荐 | 优先使用 | 图标按钮必须使用 |
推荐:同时使用两者。 testTag 用于测试查找,contentDescription 用于可访问性。测试中优先用 testTag。
4. 完整代码(逐行注释)
// ============ 被测组件 ============
@Composable
fun AppToolbar( // 应用工具栏
onBackClick: () -> Unit, // 返回回调
onSearchClick: () -> Unit, // 搜索回调
onCartClick: () -> Unit, // 购物车回调
cartCount: Int // 购物车数量
) {
Row( // 横向布局
modifier = Modifier
.fillMaxWidth() // 占满宽度
.padding(16.dp), // 内边距
horizontalArrangement = Arrangement.SpaceBetween // 两端分布
) {
IconButton( // 返回按钮
onClick = onBackClick, // 点击
modifier = Modifier.testTag("back_button") // 测试标签
) {
Icon( // 图标
Icons.AutoMirrored.Filled.ArrowBack, // 自动镜像返回
contentDescription = "返回上一页" // 内容描述
)
}
Row { // 右侧按钮组
IconButton( // 搜索按钮
onClick = onSearchClick, // 点击
modifier = Modifier.testTag("search_button") // 测试标签
) {
Icon( // 图标
Icons.Default.Search, // 搜索图标
contentDescription = "搜索" // 内容描述
)
}
Box { // 购物车按钮容器
IconButton( // 购物车按钮
onClick = onCartClick, // 点击
modifier = Modifier.testTag("cart_button") // 测试标签
) {
Icon( // 图标
Icons.Default.ShoppingCart, // 购物车图标
contentDescription = null // 由父级统一描述
)
}
if (cartCount > 0) { // 有商品时显示角标
Box( // 角标
modifier = Modifier
.align(Alignment.TopEnd) // 右上角
.size(18.dp) // 尺寸
.clip(CircleShape) // 圆形
.background(MaterialTheme.colorScheme.error) // 错误色
) {
Text( // 数量
text = cartCount.toString(), // 数量
color = MaterialTheme.colorScheme.onError, // 对比色
style = MaterialTheme.typography.labelSmall, // 小标签
modifier = Modifier.align(Alignment.Center) // 居中
)
}
}
}
.semantics(mergeDescendants = true) { // 合并语义
contentDescription = "购物车,$cartCount 件商品" // 统一描述
}
}
}
}
// ============ 测试代码 ============
class AppToolbarTest { // 工具栏测试
@get:Rule // 规则
val composeTestRule = createComposeRule() // 测试规则
@Test // 测试:返回按钮有正确的描述
fun backButton_hasCorrectDescription() { // 返回按钮描述
composeTestRule.setContent { // 设置内容
AppToolbar( // 工具栏
onBackClick = {}, // 返回
onSearchClick = {}, // 搜索
onCartClick = {}, // 购物车
cartCount = 0 // 数量
)
}
// 通过 contentDescription 查找
composeTestRule.onNodeWithContentDescription("返回上一页") // 查找节点
.assertExists() // 断言存在
.assertIsDisplayed() // 断言显示
// 通过 testTag 查找
composeTestRule.onNodeWithTag("back_button") // 查找节点
.assertExists() // 断言存在
}
@Test // 测试:购物车按钮有数量描述
fun cartButton_hasCountDescription() { // 购物车按钮数量描述
composeTestRule.setContent { // 设置内容
AppToolbar( // 工具栏
onBackClick = {}, // 返回
onSearchClick = {}, // 搜索
onCartClick = {}, // 购物车
cartCount = 3 // 数量
)
}
// 查找合并后的语义节点
composeTestRule.onNodeWithContentDescription("购物车,3 件商品") // 查找
.assertExists() // 断言存在
}
@Test // 测试:点击搜索按钮触发回调
fun searchButton_triggersCallback() { // 搜索按钮回调
var searchClicked = false // 是否点击
composeTestRule.setContent { // 设置内容
AppToolbar( // 工具栏
onBackClick = {}, // 返回
onSearchClick = { searchClicked = true }, // 搜索
onCartClick = {}, // 购物车
cartCount = 0 // 数量
)
}
// 通过 contentDescription 点击
composeTestRule.onNodeWithContentDescription("搜索") // 查找节点
.performClick() // 点击
assertTrue(searchClicked) // 断言回调被触发
}
@Test // 测试:打印语义树
fun printSemanticsTree() { // 打印语义树
composeTestRule.setContent { // 设置内容
AppToolbar( // 工具栏
onBackClick = {}, // 返回
onSearchClick = {}, // 搜索
onCartClick = {}, // 购物车
cartCount = 3 // 数量
)
}
// 打印语义树到 Logcat
composeTestRule.onRoot().printToLog("ToolbarTree") // 打印
}
}
5. UI 设计解读
第一,onNodeWithContentDescription 的核心作用。 对于没有可见文本的图标按钮,contentDescription 是测试查找的主要方式。composeTestRule.onNodeWithContentDescription("搜索") 会查找 contentDescription 包含"搜索"的节点。
第二,testTag 和 contentDescription 的选择。 testTag 是测试专用的稳定标识,不受 UI 变化影响。contentDescription 是面向辅助技术的描述,测试也可以使用。推荐同时使用两者:testTag 用于测试查找,contentDescription 用于可访问性,测试中优先用 testTag。
第三,useUnmergedTree 参数的使用场景。 当节点合并了子节点的语义时(如 mergeDescendants = true),默认查找器在合并后的树上查找。如果需要访问未合并的树中的子节点,设置 useUnmergedTree = true。购物车按钮的角标和图标被合并后,onNodeWithContentDescription("购物车,3 件商品") 可以在合并节点上找到。
第四,printToLog 的调试作用。 当查找器找不到节点时,用 composeTestRule.onRoot().printToLog("TAG") 打印语义树结构,查看节点的实际层级和属性。这是调试测试失败的最快方式。建议在测试失败时,先打印语义树,再调整查找器。
第五,测试与可访问性的统一。 第7课讲的可访问性语义树和本课的测试语义树是同一个东西。为可访问性添加的 contentDescription 和 mergeDescendants 同时也是测试的查找依据。做好可访问性就等于为测试打好了基础。
6. 常见陷阱
- 图标按钮没有
contentDescription:测试无法通过描述查找,TalkBack 也无法朗读。 contentDescription硬编码:应该用stringResource,方便国际化。- 忘记
mergeDescendants导致查找失败:购物车按钮的图标和角标被合并后,需要在父节点上查找。 - 用
onNodeWithText查找无文本按钮:应该用onNodeWithContentDescription或onNodeWithTag。 useUnmergedTree滥用:只在需要访问未合并子节点时使用,否则可能找到错误的节点。
7. 可改进方向(含参考答案)
可改进点
- 用
assertContentDescriptionEquals精确验证描述。 - 用
onAllNodesWithContentDescription查找多个匹配节点。 - 用
hasContentDescription匹配器组合查找条件。
增强版参考答案
@Test // 测试:精确匹配 contentDescription
fun contentDescription_exactMatch() { // 精确匹配
composeTestRule.setContent { // 设置内容
AppToolbar( // 工具栏
onBackClick = {}, // 返回
onSearchClick = {}, // 搜索
onCartClick = {}, // 购物车
cartCount = 3 // 数量
)
}
// 精确匹配描述
composeTestRule.onNodeWithContentDescription("搜索") // 查找
.assertContentDescriptionEquals("搜索") // 断言描述
}
@Test // 测试:使用匹配器组合查找
fun combinedMatcher() { // 组合匹配器
composeTestRule.setContent { // 设置内容
AppToolbar( // 工具栏
onBackClick = {}, // 返回
onSearchClick = {}, // 搜索
onCartClick = {}, // 购物车
cartCount = 0 // 数量
)
}
// 组合查找:有 testTag 且有 contentDescription
composeTestRule.onNode( // 查找
hasTestTag("back_button") and hasContentDescription("返回上一页") // 组合条件
).assertExists() // 断言存在
}
改进解读
assertContentDescriptionEquals 精确验证 contentDescription 是否等于指定值。hasTestTag 和 hasContentDescription 是 SemanticsMatcher,可以用 and、or 组合。这些匹配器让测试查找更加灵活和精确。当查找器不确定时,用 onNode + 匹配器组合是最灵活的方式。
8. 关键要点
- 图标按钮用
onNodeWithContentDescription或onNodeWithTag查找。 testTag是测试专用的稳定标识,优先使用。useUnmergedTree用于访问未合并的语义树。printToLog打印语义树,帮助调试。- 测试语义树与可访问性语义树是同一个东西。
案例三:列表交互——用节点操作和手势测试
1. 真实场景
你在做一个待办列表,用户可以滑动删除待办项、点击复选框切换完成状态、滚动列表。你需要测试这些手势交互。
2. 设计目标
- 用
performClick点击复选框。 - 用
performTouchInput { swipeLeft() }执行滑动手势。 - 用
performScrollToIndex滚动到指定项。 - 用
assertIsOn、assertIsOff验证复选框状态。
3. 界面拆解
- 点击:
performClick()。 - 滑动:
performTouchInput { swipeLeft() }。 - 滚动:
performScrollToIndex()、performScrollToNode()。 - 复选框状态:
assertIsOn()、assertIsOff()。
核心区别:
| API | 用途 | 适用组件 |
|---|---|---|
performClick | 点击 | Button、Checkbox、IconButton |
performTouchInput | 手势 | 任意组件(滑动、长按、拖拽) |
performScrollToIndex | 滚动到索引 | LazyColumn、LazyRow |
performScrollToNode | 滚动到节点 | LazyColumn、LazyRow |
4. 完整代码(逐行注释)
// ============ 被测组件 ============
@Composable
fun TodoList( // 待办列表
items: List<String>, // 待办项
onDelete: (String) -> Unit // 删除回调
) {
val checkedStates = remember { // 复选框状态
mutableStateMapOf<String, Boolean>() // 可变状态映射
}
LazyColumn( // 列表
modifier = Modifier.testTag("todo_list"), // 测试标签
contentPadding = PaddingValues(16.dp), // 内边距
verticalArrangement = Arrangement.spacedBy(8.dp) // 间距
) {
items(items, key = { it }) { item -> // 遍历
val isChecked = checkedStates[item] ?: false // 是否选中
Card( // 卡片
modifier = Modifier
.fillMaxWidth() // 占满宽度
.testTag("todo_item_$item") // 测试标签
,
shape = MaterialTheme.shapes.medium // 圆角
) {
Row( // 横向布局
modifier = Modifier.padding(12.dp), // 内边距
verticalAlignment = Alignment.CenterVertically // 垂直居中
) {
Checkbox( // 复选框
checked = isChecked, // 状态
onCheckedChange = { checkedStates[item] = it }, // 变化
modifier = Modifier.testTag("checkbox_$item") // 测试标签
)
Spacer(modifier = Modifier.width(8.dp)) // 间距
Text( // 文字
text = item, // 内容
modifier = Modifier.weight(1f) // 占剩余
)
}
}
}
}
}
// ============ 测试代码 ============
class TodoListTest { // 待办列表测试
@get:Rule // 规则
val composeTestRule = createComposeRule() // 测试规则
@Test // 测试:点击复选框切换状态
fun checkbox_togglesState() { // 复选框切换状态
val items = listOf("学习 Compose", "写测试", "发布应用") // 待办项
composeTestRule.setContent { // 设置内容
TodoList(items = items, onDelete = {}) // 待办列表
}
// 初始状态:未选中
composeTestRule.onNodeWithTag("checkbox_学习 Compose") // 查找复选框
.assertIsOff() // 断言未选中
// 点击复选框
composeTestRule.onNodeWithTag("checkbox_学习 Compose") // 查找
.performClick() // 点击
// 断言已选中
composeTestRule.onNodeWithTag("checkbox_学习 Compose") // 查找
.assertIsOn() // 断言选中
}
@Test // 测试:滚动到指定项
fun scrollToItem() { // 滚动到指定项
val items = (1..50).map { "项目 $it" } // 50 个项
composeTestRule.setContent { // 设置内容
TodoList(items = items, onDelete = {}) // 待办列表
}
// 滚动到第 30 项
composeTestRule.onNodeWithTag("todo_list") // 查找列表
.performScrollToIndex(29) // 滚动到索引 29
// 断言第 30 项显示
composeTestRule.onNodeWithTag("todo_item_项目 30") // 查找项
.assertIsDisplayed() // 断言显示
}
@Test // 测试:滑动删除
fun swipeToDelete() { // 滑动删除
var deletedItem: String? = null // 被删除的项
val items = listOf("学习 Compose", "写测试") // 待办项
composeTestRule.setContent { // 设置内容
TodoList( // 待办列表
items = items, // 数据
onDelete = { deletedItem = it } // 删除回调
)
}
// 滑动第一项
composeTestRule.onNodeWithTag("todo_item_学习 Compose") // 查找项
.performTouchInput { // 执行手势
swipeLeft() // 向左滑动
}
// 注意:实际滑动删除需要 SwipeToDismiss 组件
// 这里仅演示手势 API
}
}
5. UI 设计解读
第一,performClick 的核心作用。 点击是最基本的交互。performClick() 在节点中心执行点击。对于 Checkbox、Button、IconButton 等可点击组件,performClick 会触发对应的 onClick。
第二,performTouchInput 的核心作用。 对于滑动、拖拽、长按等手势,用 performTouchInput { } 执行。内部可以调用 swipeLeft()、swipeRight()、swipeUp()、swipeDown()、longClick()、doubleClick() 等方法。手势测试是 Compose 测试中最复杂的部分,需要准确模拟用户的手指动作。
第三,performScrollToIndex 的核心作用。 对于 LazyColumn 等可滚动组件,用 performScrollToIndex(index) 滚动到指定索引。这会自动处理滚动动画,确保目标项可见。这是测试长列表的必备 API。
第四,复选框状态断言。 assertIsOn() 验证复选框已选中。assertIsOff() 验证复选框未选中。这两个断言是 ToggleableState 的便捷方法。
第五,UI 设计意义。 列表交互是移动端最常见的操作。自动化测试可以确保:点击复选框后状态正确切换,滑动删除后列表正确更新,滚动后目标项可见。这些测试覆盖了用户最常使用的交互路径。
6. 常见陷阱
performClick在不可见节点上执行:应该先用performScrollToNode滚动到可见。swipeLeft的距离不够:默认滑动距离可能不足以触发删除,需要指定自定义距离。performScrollToIndex在非 Lazy 列表上使用:只适用于LazyColumn、LazyRow等惰性列表。- 测试中创建真实的手势序列:应该用
performTouchInput的便捷方法,而不是手动模拟down、move、up。 - 忘记
testTag使用动态内容:testTag("todo_item_$item")中的$item如果包含空格或特殊字符,测试可能失败。
7. 可改进方向(含参考答案)
可改进点
- 用
performScrollToNode按条件滚动。 - 用
swipeLeft(startX, endX)指定滑动距离。 - 用
assertIsSelected验证选中状态。
增强版参考答案
@Test // 测试:按条件滚动
fun scrollToNodeByMatcher() { // 按条件滚动
val items = (1..50).map { "项目 $it" } // 50 个项
composeTestRule.setContent { // 设置内容
TodoList(items = items, onDelete = {}) // 待办列表
}
// 滚动到文本为"项目 40"的节点
composeTestRule.onNodeWithTag("todo_list") // 列表
.performScrollToNode(hasText("项目 40")) // 滚动到节点
composeTestRule.onNodeWithText("项目 40") // 目标节点
.assertIsDisplayed() // 断言显示
}
@Test // 测试:指定滑动距离
fun swipeWithDistance() { // 指定滑动距离
composeTestRule.setContent { // 设置内容
TodoList(items = listOf("测试项"), onDelete = {}) // 待办列表
}
composeTestRule.onNodeWithTag("todo_item_测试项") // 项
.performTouchInput { // 手势
swipeLeft(startX = right, endX = left - 200f) // 指定距离
}
}
改进解读
performScrollToNode(hasText("项目 40")) 按文本条件滚动到目标节点,比按索引更灵活。swipeLeft(startX, endX) 可以指定滑动的起止位置,精确控制滑动距离。当测试包含动态内容的列表时,按条件滚动比按索引滚动更稳定。
8. 关键要点
performClick执行点击,performTouchInput执行手势。performScrollToIndex滚动到指定索引,performScrollToNode按条件滚动。assertIsOn、assertIsOff验证复选框状态。- 滑动删除需要
SwipeToDismiss组件配合。 - 测试列表时优先使用按条件滚动。
案例四:异步加载——用同步机制和 waitUntil 测试
1. 真实场景
你在做一个商品列表页面,进入页面后异步加载数据。加载中显示进度条,加载完成后显示列表。你需要测试异步加载的完整流程。
2. 设计目标
- 用
waitUntil等待异步操作完成。 - 用
assertIsDisplayed验证加载中状态。 - 用
assertCountEquals验证加载完成后的列表项数量。 - 理解 Compose 测试的同步机制。
3. 界面拆解
waitUntil:等待条件满足,超时后失败。waitUntilExactlyOneExists:等待恰好一个节点存在。waitUntilDoesNotExist:等待节点不存在。- 同步机制:Compose 测试在 UI 空闲时自动同步,但异步操作(如网络请求)需要手动等待。
Compose 测试的同步机制:
| 操作 | 是否自动同步 | 说明 |
|---|---|---|
| 重组 | 是 | 测试等待重组完成 |
| 动画 | 是 | 测试等待动画完成 |
| 布局 | 是 | 测试等待布局完成 |
| 网络请求 | 否 | 需要 waitUntil 手动等待 |
| 协程延迟 | 否 | 需要 waitUntil 手动等待 |
| 数据库操作 | 否 | 需要 waitUntil 手动等待 |
4. 完整代码(逐行注释)
// ============ 被测组件 ============
@Composable
fun AsyncProductList( // 异步商品列表
loadProducts: suspend () -> List<String> // 加载函数
) {
var products by remember { mutableStateOf<List<String>>(emptyList()) } // 商品
var isLoading by remember { mutableStateOf(true) } // 加载中
LaunchedEffect(Unit) { // 进入时
products = loadProducts() // 加载
isLoading = false // 结束加载
}
if (isLoading) { // 加载中
Box( // 容器
modifier = Modifier
.fillMaxSize() // 占满
.testTag("loading_indicator") // 测试标签
,
contentAlignment = Alignment.Center // 居中
) {
CircularProgressIndicator() // 进度条
}
} else { // 加载完成
LazyColumn( // 列表
modifier = Modifier.testTag("product_list"), // 测试标签
contentPadding = PaddingValues(16.dp) // 内边距
) {
items(products) { product -> // 遍历
Text( // 商品名
text = product, // 内容
modifier = Modifier
.fillMaxWidth() // 占满宽度
.padding(12.dp) // 内边距
.testTag("product_item") // 测试标签
)
}
}
}
}
// ============ 测试代码 ============
class AsyncProductListTest { // 异步商品列表测试
@get:Rule // 规则
val composeTestRule = createComposeRule() // 测试规则
@Test // 测试:加载中显示进度条
fun loading_showsIndicator() { // 加载中显示进度条
composeTestRule.setContent { // 设置内容
AsyncProductList( // 异步商品列表
loadProducts = { // 加载函数
delay(1000) // 模拟延迟
listOf("商品 A", "商品 B") // 返回数据
}
)
}
// 断言加载指示器显示
composeTestRule.onNodeWithTag("loading_indicator") // 查找
.assertIsDisplayed() // 断言显示
}
@Test // 测试:加载完成后显示列表
fun loaded_showsList() { // 加载完成显示列表
composeTestRule.setContent { // 设置内容
AsyncProductList( // 异步商品列表
loadProducts = { // 加载函数
delay(500) // 模拟延迟
listOf("商品 A", "商品 B", "商品 C") // 返回数据
}
)
}
// 等待列表出现
composeTestRule.waitUntil(timeoutMillis = 5000) { // 等待
composeTestRule // 测试规则
.onAllNodesWithTag("product_item") // 所有商品项
.fetchSemanticsNodes() // 获取节点
.isNotEmpty() // 不为空
}
// 断言列表项数量
composeTestRule.onAllNodesWithTag("product_item") // 所有商品项
.assertCountEquals(3) // 断言数量
// 断言加载指示器消失
composeTestRule.onNodeWithTag("loading_indicator") // 查找
.assertDoesNotExist() // 断言不存在
}
@Test // 测试:空列表显示
fun emptyList_showsNothing() { // 空列表
composeTestRule.setContent { // 设置内容
AsyncProductList( // 异步商品列表
loadProducts = { emptyList() } // 空列表
)
}
// 等待加载完成
composeTestRule.waitUntil(timeoutMillis = 3000) { // 等待
composeTestRule // 测试规则
.onAllNodesWithTag("product_item") // 所有商品项
.fetchSemanticsNodes() // 获取节点
.isEmpty() // 为空
}
// 断言没有商品项
composeTestRule.onAllNodesWithTag("product_item") // 所有商品项
.assertCountEquals(0) // 断言数量
}
}
5. UI 设计解读
第一,waitUntil 的核心作用。 Compose 测试默认在 UI 空闲时执行操作和断言。但异步操作(如网络请求)不会让 UI 空闲,因为协程在后台运行。waitUntil 让测试暂停,直到条件满足或超时。timeoutMillis 指定超时时间,默认是 1000ms。
第二,fetchSemanticsNodes 的使用。 onAllNodesWithTag("product_item") 返回一个集合。fetchSemanticsNodes() 获取当前匹配的节点列表。isNotEmpty() 检查是否有匹配的节点。这是 waitUntil 中最常用的组合。
第三,同步机制的重要性。 Compose 测试的同步机制确保测试在 UI 空闲时执行。当有动画、重组、布局、绘制正在进行时,测试会等待。但异步数据加载不是"UI 工作",所以需要 waitUntil 手动等待。
第四,超时设置。 waitUntil 的默认超时是 1000ms。如果异步操作可能需要更长时间(如网络请求),应该设置更大的 timeoutMillis。超时时间应该大于最慢的异步操作耗时。 如果测试在 CI 上运行,网络可能更慢,超时时间应该设置得更宽松。
第五,UI 设计意义。 异步加载是真实 App 的标配。测试需要覆盖:加载中状态、加载完成状态、空列表状态、错误状态。每个状态都应该有对应的测试。
6. 常见陷阱
- 用
Thread.sleep等待异步操作:应该用waitUntil。 waitUntil的超时时间太短:导致测试不稳定,偶发失败。- 忘记断言加载指示器消失:只断言列表出现,没有验证加载状态结束。
- 在
waitUntil中做复杂操作:应该只做简单的条件检查。 - 测试中创建真实的网络请求:应该用 Mock 或 Fake 替代,确保测试快速稳定。
waitUntil条件永远为真:比如检查一个总是存在的节点,导致测试不等待。
7. 可改进方向(含参考答案)
可改进点
- 用
waitUntilExactlyOneExists等待恰好一个节点。 - 用
waitUntilDoesNotExist等待节点消失。 - 用
mainClock.advanceTimeBy控制虚拟时钟。
增强版参考答案
@Test // 测试:等待恰好一个节点
fun waitForExactlyOne() { // 等待恰好一个
composeTestRule.setContent { // 设置内容
AsyncProductList( // 异步商品列表
loadProducts = { // 加载
delay(500) // 延迟
listOf("商品 A") // 一个商品
}
)
}
// 等待恰好一个商品项
composeTestRule.waitUntilExactlyOneExists( // 等待恰好一个
matcher = hasTestTag("product_item"), // 匹配器
timeoutMillis = 5000 // 超时
)
}
@Test // 测试:控制虚拟时钟
fun controlClock() { // 控制时钟
composeTestRule.mainClock.autoAdvance = false // 关闭自动推进
composeTestRule.setContent { // 设置内容
AnimatedVisibility(visible = true) { // 动画可见
Text("Hello") // 文字
}
}
composeTestRule.mainClock.advanceTimeBy(500) // 推进 500ms
composeTestRule.onNodeWithText("Hello") // 查找
.assertIsDisplayed() // 断言显示
}
改进解读
waitUntilExactlyOneExists 等待恰好一个节点存在,比 waitUntil 更精确。mainClock.autoAdvance = false 关闭自动时钟推进,用 advanceTimeBy 手动控制动画时间。这对测试动画的中间状态非常有用。注意:使用 mainClock 时,必须在测试结束前恢复 autoAdvance = true,否则后续测试可能失败。
8. 关键要点
waitUntil等待异步操作完成。fetchSemanticsNodes获取当前匹配的节点列表。- 超时时间应该大于最慢的异步操作耗时。
- 测试中避免真实网络请求,用 Mock 或 Fake 替代。
mainClock可以控制虚拟时钟,测试动画中间状态。
案例五:Hilt 集成测试——用 createAndroidComposeRule 测试依赖注入
1. 真实场景
你在做一个登录流程,ViewModel 通过 Hilt 注入 Repository,Repository 通过 Hilt 注入网络客户端。你需要测试完整的登录流程,包括 ViewModel 和 Hilt 依赖注入。
2. 设计目标
- 用
createAndroidComposeRule<MainActivity>()启动 Activity。 - 用
@HiltAndroidTest启用 Hilt 测试。 - 用
@TestInstallIn替换真实 Repository 为 Fake。 - 测试完整的登录流程。
3. 界面拆解
@HiltAndroidTest:标记 Hilt 测试类。HiltAndroidRule:注入依赖。@TestInstallIn:替换生产环境的模块为测试模块。createAndroidComposeRule<MainActivity>():启动 Activity。
Hilt 测试的三个关键点:
@HiltAndroidTest标记测试类。HiltAndroidRule在@Before中调用inject()。@TestInstallIn替换生产模块为测试模块。
4. 完整代码(逐行注释)
// ============ 生产代码 ============
// Repository 接口
interface AuthRepository { // 认证仓库
suspend fun login(username: String, password: String): Boolean // 登录
}
// 生产实现
class RealAuthRepository @Inject constructor( // 真实认证仓库
private val api: AuthApi // 网络客户端
) : AuthRepository { // 实现
override suspend fun login(username: String, password: String): Boolean { // 登录
return api.login(username, password).success // 调用 API
}
}
// ViewModel
@HiltViewModel // Hilt ViewModel
class LoginViewModel @Inject constructor( // 登录 ViewModel
private val repository: AuthRepository // 注入仓库
) : ViewModel() {
var username by mutableStateOf("") // 用户名
var password by mutableStateOf("") // 密码
var isLoading by mutableStateOf(false) // 加载中
var isLoggedIn by mutableStateOf(false) // 已登录
fun login() { // 登录
viewModelScope.launch { // 启动协程
isLoading = true // 加载中
isLoggedIn = repository.login(username, password) // 调用仓库
isLoading = false // 结束
}
}
}
// 页面
@Composable
fun LoginScreen( // 登录页面
viewModel: LoginViewModel = hiltViewModel() // 注入 ViewModel
) {
Column( // 纵向布局
modifier = Modifier
.fillMaxSize() // 占满
.padding(16.dp), // 内边距
verticalArrangement = Arrangement.Center // 垂直居中
) {
OutlinedTextField( // 用户名
value = viewModel.username, // 值
onValueChange = { viewModel.username = it }, // 变化
label = { Text("用户名") }, // 标签
modifier = Modifier
.fillMaxWidth() // 占满宽度
.testTag("username_field") // 测试标签
)
Spacer(modifier = Modifier.height(12.dp)) // 间距
OutlinedTextField( // 密码
value = viewModel.password, // 值
onValueChange = { viewModel.password = it }, // 变化
label = { Text("密码") }, // 标签
modifier = Modifier
.fillMaxWidth() // 占满宽度
.testTag("password_field") // 测试标签
)
Spacer(modifier = Modifier.height(24.dp)) // 间距
Button( // 登录按钮
onClick = { viewModel.login() }, // 点击
enabled = !viewModel.isLoading, // 能否点击
modifier = Modifier
.fillMaxWidth() // 占满宽度
.testTag("login_button") // 测试标签
) {
if (viewModel.isLoading) { // 加载中
CircularProgressIndicator( // 进度条
modifier = Modifier.size(18.dp), // 尺寸
strokeWidth = 2.dp // 线宽
)
} else { // 非加载
Text("登录") // 文字
}
}
if (viewModel.isLoggedIn) { // 已登录
Text( // 提示
text = "登录成功", // 文字
modifier = Modifier.testTag("success_text"), // 测试标签
color = MaterialTheme.colorScheme.primary // 主题色
)
}
}
}
// ============ 测试代码 ============
// Fake Repository
class FakeAuthRepository : AuthRepository { // 假认证仓库
var shouldSucceed = true // 是否成功
override suspend fun login(username: String, password: String): Boolean { // 登录
delay(100) // 模拟延迟
return shouldSucceed // 返回结果
}
}
// 测试模块
@Module // 模块
@TestInstallIn( // 安装到测试
components = [SingletonComponent::class], // 组件
replaces = [AuthModule::class] // 替换的模块
)
object FakeAuthModule { // 假认证模块
@Provides // 提供
@Singleton // 单例
fun provideAuthRepository(): AuthRepository = FakeAuthRepository() // 假仓库
}
// 测试类
@HiltAndroidTest // Hilt 测试
class LoginFlowTest { // 登录流程测试
@get:Rule(order = 0) // 规则顺序 0
val hiltRule = HiltAndroidRule(this) // Hilt 规则
@get:Rule(order = 1) // 规则顺序 1
val composeTestRule = createAndroidComposeRule<MainActivity>() // Compose 规则
@Inject // 注入
lateinit var authRepository: AuthRepository // 认证仓库
@Before // 前置
fun setup() { // 设置
hiltRule.inject() // 注入依赖
}
@Test // 测试:成功登录
fun login_success() { // 成功登录
(authRepository as FakeAuthRepository).shouldSucceed = true // 设置成功
composeTestRule.setContent { // 设置内容
MyAppTheme { // 主题
LoginScreen() // 登录页面
}
}
// 输入用户名和密码
composeTestRule.onNodeWithTag("username_field") // 用户名
.performTextInput("张三") // 输入
composeTestRule.onNodeWithTag("password_field") // 密码
.performTextInput("123456") // 输入
// 点击登录
composeTestRule.onNodeWithTag("login_button") // 按钮
.performClick() // 点击
// 等待登录完成
composeTestRule.waitUntil(timeoutMillis = 3000) { // 等待
composeTestRule // 规则
.onAllNodesWithTag("success_text") // 成功文字
.fetchSemanticsNodes() // 获取节点
.isNotEmpty() // 不为空
}
// 断言成功文字显示
composeTestRule.onNodeWithTag("success_text") // 查找
.assertTextEquals("登录成功") // 断言文字
}
@Test // 测试:失败登录
fun login_failure() { // 失败登录
(authRepository as FakeAuthRepository).shouldSucceed = false // 设置失败
composeTestRule.setContent { // 设置内容
MyAppTheme { // 主题
LoginScreen() // 登录页面
}
}
// 输入
composeTestRule.onNodeWithTag("username_field") // 用户名
.performTextInput("张三") // 输入
composeTestRule.onNodeWithTag("password_field") // 密码
.performTextInput("123456") // 输入
// 点击
composeTestRule.onNodeWithTag("login_button") // 按钮
.performClick() // 点击
// 等待
composeTestRule.waitUntil(timeoutMillis = 3000) { // 等待
composeTestRule // 规则
.onAllNodesWithTag("success_text") // 成功文字
.fetchSemanticsNodes() // 获取节点
.isEmpty() // 为空
}
// 断言没有成功文字
composeTestRule.onNodeWithTag("success_text") // 查找
.assertDoesNotExist() // 断言不存在
}
}
5. UI 设计解读
第一,@HiltAndroidTest 和 HiltAndroidRule 的核心作用。 @HiltAndroidTest 标记测试类使用 Hilt。HiltAndroidRule 负责注入依赖,必须在 @Before 中调用 hiltRule.inject()。@get:Rule(order = 0) 保证 Hilt 规则在 Compose 规则之前执行。
第二,@TestInstallIn 替换生产模块。 @TestInstallIn(components = [SingletonComponent::class], replaces = [AuthModule::class]) 告诉 Hilt 在测试中用 FakeAuthModule 替换 AuthModule。这样测试中注入的是 FakeAuthRepository,而不是真实的网络请求。
第三,createAndroidComposeRule<MainActivity>() 的适用场景。 这个测试需要启动 Activity 并注入 Hilt 依赖,所以用 createAndroidComposeRule。它比 createComposeRule 更重,但支持 Activity 级别的集成测试。
第四,Fake Repository 的优势。 FakeAuthRepository 实现了 AuthRepository 接口,但用 delay(100) 模拟延迟,用 shouldSucceed 控制成功/失败。Fake 比 Mock 更适合集成测试,因为 Fake 有真实的行为逻辑,而 Mock 只验证调用。
第五,UI 设计意义。 Hilt 集成测试覆盖了从 UI 到 ViewModel 到 Repository 的完整链路。它确保依赖注入配置正确,ViewModel 和 Repository 的交互符合预期。这是最接近真实用户场景的测试。
6. 常见陷阱
- 忘记
hiltRule.inject():依赖不会被注入,测试中的lateinit var会崩溃。 @get:Rule(order = 0)顺序错误:Hilt 规则必须在 Compose 规则之前。@TestInstallIn的replaces配置错误:没有替换生产模块,测试中仍然使用真实网络请求。- Fake Repository 没有模拟延迟:测试太快,可能无法验证加载状态。
createAndroidComposeRule中setContent与 Activity 冲突:如果 Activity 已经设置了内容,不应该再调用setContent。- 在测试中直接修改
shouldSucceed后忘记等待:修改后需要waitUntil等待状态更新。
7. 可改进方向(含参考答案)
可改进点
- 用
@UninstallModules卸载特定模块。 - 用
@BindValue绑定测试值。 - 用
runTest测试 ViewModel 逻辑。
增强版参考答案
@HiltAndroidTest // Hilt 测试
class LoginFlowTestEnhanced { // 增强版登录流程测试
@get:Rule(order = 0) // Hilt 规则
val hiltRule = HiltAndroidRule(this) // Hilt
@get:Rule(order = 1) // Compose 规则
val composeTestRule = createAndroidComposeRule<MainActivity>() // Compose
@BindValue // 绑定值
@JvmField // JVM 字段
val fakeRepository: AuthRepository = FakeAuthRepository() // 假仓库
@Before // 前置
fun setup() { // 设置
hiltRule.inject() // 注入
}
}
改进解读
@BindValue 直接将一个实例绑定到 Hilt 图中,不需要 @TestInstallIn 和 @Module。这更简洁,适合只需要替换一个依赖的场景。@BindValue 的优点是测试代码更少,缺点是每次只能绑定一个依赖。
8. 关键要点
@HiltAndroidTest和HiltAndroidRule启用 Hilt 测试。@TestInstallIn替换生产模块为测试模块。createAndroidComposeRule<MainActivity>()用于 Activity 级别的集成测试。- Fake Repository 比 Mock 更适合集成测试。
@get:Rule(order = 0)保证 Hilt 规则在 Compose 规则之前。
案例六:导航测试——用 createAndroidComposeRule 测试多页面流程
1. 真实场景
你在做一个多页面 App,从首页点击按钮进入详情页,详情页点击返回回到首页。你需要测试完整的导航流程。
2. 设计目标
- 用
createAndroidComposeRule启动包含导航的 Activity。 - 用
onNodeWithTag查找导航目标页面的元素。 - 用
performClick触发导航。 - 用
waitUntil等待导航完成。
3. 界面拆解
- 首页:显示"首页"文字和一个"进入详情"按钮。
- 详情页:显示"详情页"文字和一个"返回"按钮。
- 测试:点击"进入详情"后断言"详情页"显示;点击"返回"后断言"首页"显示。
导航测试的核心挑战: 导航涉及异步的页面切换。点击按钮后,Compose 需要执行导航操作,然后重组新页面。测试需要等待新页面出现。不能用 Thread.sleep,必须用 waitUntil。
4. 完整代码(逐行注释)
// ============ 被测组件 ============
@Serializable object HomeRoute // 首页路由
@Serializable object DetailRoute // 详情路由
@Composable
fun NavApp() { // 导航应用
val navController = rememberNavController() // 导航控制器
NavHost( // 导航容器
navController = navController, // 绑定
startDestination = HomeRoute // 起始页
) {
composable<HomeRoute> { // 首页
Column( // 纵向布局
modifier = Modifier
.fillMaxSize() // 占满
.testTag("home_screen") // 测试标签
,
verticalArrangement = Arrangement.Center // 垂直居中
) {
Text("首页", style = MaterialTheme.typography.headlineMedium) // 文字
Button( // 按钮
onClick = { navController.navigate(DetailRoute) }, // 导航
modifier = Modifier.testTag("go_detail_button") // 测试标签
) {
Text("进入详情") // 文字
}
}
}
composable<DetailRoute> { // 详情页
Column( // 纵向布局
modifier = Modifier
.fillMaxSize() // 占满
.testTag("detail_screen") // 测试标签
,
verticalArrangement = Arrangement.Center // 垂直居中
) {
Text("详情页", style = MaterialTheme.typography.headlineMedium) // 文字
Button( // 返回按钮
onClick = { navController.popBackStack() }, // 返回
modifier = Modifier.testTag("go_back_button") // 测试标签
) {
Text("返回") // 文字
}
}
}
}
}
// ============ 测试代码 ============
@RunWith(AndroidJUnit4::class) // AndroidJUnit4
class NavAppTest { // 导航测试
@get:Rule // 规则
val composeTestRule = createAndroidComposeRule<MainActivity>() // 启动 Activity
@Test // 测试:首页到详情页
fun navigate_homeToDetail() { // 首页到详情
// 断言首页显示
composeTestRule.onNodeWithTag("home_screen") // 首页
.assertIsDisplayed() // 断言显示
// 点击进入详情
composeTestRule.onNodeWithTag("go_detail_button") // 按钮
.performClick() // 点击
// 等待详情页出现
composeTestRule.waitUntil(timeoutMillis = 3000) { // 等待
composeTestRule // 规则
.onAllNodesWithTag("detail_screen") // 详情页
.fetchSemanticsNodes() // 获取节点
.isNotEmpty() // 不为空
}
// 断言详情页显示
composeTestRule.onNodeWithTag("detail_screen") // 详情页
.assertIsDisplayed() // 断言显示
}
@Test // 测试:详情页返回首页
fun navigate_detailToHome() { // 详情到首页
// 先进入详情页
composeTestRule.onNodeWithTag("go_detail_button") // 按钮
.performClick() // 点击
composeTestRule.waitUntil(timeoutMillis = 3000) { // 等待
composeTestRule // 规则
.onAllNodesWithTag("detail_screen") // 详情页
.fetchSemanticsNodes() // 获取
.isNotEmpty() // 不为空
}
// 点击返回
composeTestRule.onNodeWithTag("go_back_button") // 返回按钮
.performClick() // 点击
// 等待首页出现
composeTestRule.waitUntil(timeoutMillis = 3000) { // 等待
composeTestRule // 规则
.onAllNodesWithTag("home_screen") // 首页
.fetchSemanticsNodes() // 获取
.isNotEmpty() // 不为空
}
// 断言首页显示
composeTestRule.onNodeWithTag("home_screen") // 首页
.assertIsDisplayed() // 断言显示
}
}
5. UI 设计解读
第一,导航测试的核心挑战。 导航涉及异步的页面切换。点击按钮后,Compose 需要执行导航操作,然后重组新页面。测试需要等待新页面出现,用 waitUntil 等待目标页面的节点出现。
第二,testTag 在导航测试中的重要性。 首页和详情页都有"按钮"和"文字",如果没有 testTag,测试无法区分当前在哪个页面。home_screen 和 detail_screen 标签让测试可以明确断言当前页面。这是导航测试最关键的设计。
第三,createAndroidComposeRule 的使用。 导航测试需要启动 Activity,因为 NavHost 通常放在 Activity 的 setContent 中。createAndroidComposeRule<MainActivity>() 启动 Activity 并设置内容。
第四,UI 设计意义。 导航是 App 的核心功能。测试确保:点击按钮后正确跳转,返回键正确工作,返回栈行为符合预期。导航 bug 是最影响用户体验的 bug 之一——用户点了一个按钮,App 却跳到了错误的地方。
6. 常见陷阱
- 在导航测试中用
createComposeRule():需要启动 Activity,应该用createAndroidComposeRule。 waitUntil超时时间太短:导航动画可能需要几百毫秒。- 忘记断言起始页面:应该先断言起始页面显示,再执行导航。
- 测试导航时没有
testTag:不同页面的元素可能冲突,无法区分。 - 在
NavHost外调用setContent:如果 Activity 已经设置了NavHost,测试中不应该再调用setContent。
7. 可改进方向(含参考答案)
可改进点
- 测试嵌套导航图。
- 测试导航参数传递。
- 测试返回键行为。
增强版参考答案
@Test // 测试:导航参数传递
fun navigate_withParameter() { // 导航参数
// 假设详情页接收 productId 参数
composeTestRule.onNodeWithTag("go_detail_button") // 按钮
.performClick() // 点击
composeTestRule.waitUntil(timeoutMillis = 3000) { // 等待
composeTestRule // 规则
.onAllNodesWithTag("detail_screen") // 详情页
.fetchSemanticsNodes() // 获取
.isNotEmpty() // 不为空
}
// 断言详情页显示了正确的商品 ID
composeTestRule.onNodeWithText("商品 ID: 123") // 文字
.assertIsDisplayed() // 断言显示
}
改进解读
导航参数测试验证了路由参数是否正确传递到目标页面。在详情页中显示商品 ID 的文本,测试通过 onNodeWithText 查找并断言。建议为每个导航路径都编写测试,覆盖前进和返回两个方向。
8. 关键要点
- 导航测试用
createAndroidComposeRule。 waitUntil等待目标页面出现。testTag用于区分不同页面的元素。- 测试覆盖前进和返回两个方向。
- 导航参数传递需要通过断言验证。
案例七:KMP 共享代码测试——跨平台逻辑测试
1. 真实场景
你在做一个 Kotlin Multiplatform 项目,commonMain 中有共享的业务逻辑(如价格计算、数据验证)。你需要测试这些共享逻辑在 Android 和 iOS 上都正确工作。
2. 设计目标
- 在
commonTest中编写共享逻辑的单元测试。 - 用
kotlin.test提供的断言 API。 - 理解 KMP 测试的目录结构和运行方式。
3. 界面拆解
commonMain:共享业务逻辑。commonTest:共享测试代码。androidTest:Android 平台特定测试。iosTest:iOS 平台特定测试。
KMP 测试的核心优势: commonTest 中的测试在 Android 和 iOS 上都会运行。你只需要写一次测试,就能验证共享逻辑在所有平台上都正确。这大大减少了重复测试的工作量。
4. 完整代码(逐行注释)
// ============ commonMain 中的共享逻辑 ============
// commonMain/kotlin/com/example/shared/PriceCalculator.kt
class PriceCalculator { // 价格计算器
fun calculateTotal( // 计算总价
price: Double, // 单价
quantity: Int, // 数量
taxRate: Double = 0.1 // 税率
): Double {
require(price >= 0) { "价格不能为负" } // 验证价格
require(quantity >= 0) { "数量不能为负" } // 验证数量
require(taxRate in 0.0..1.0) { "税率必须在 0 到 1 之间" } // 验证税率
val subtotal = price * quantity // 小计
return subtotal * (1 + taxRate) // 含税总价
}
}
// commonMain/kotlin/com/example/shared/Validator.kt
object Validator { // 验证器
fun isValidEmail(email: String): Boolean { // 验证邮箱
return email.contains("@") && email.contains(".") // 简单验证
}
fun isValidPassword(password: String): Boolean { // 验证密码
return password.length >= 6 // 至少 6 位
}
}
// ============ commonTest 中的测试 ============
// commonTest/kotlin/com/example/shared/PriceCalculatorTest.kt
import kotlin.test.Test // 测试
import kotlin.test.assertEquals // 断言相等
import kotlin.test.assertFailsWith // 断言异常
class PriceCalculatorTest { // 价格计算器测试
private val calculator = PriceCalculator() // 计算器
@Test // 测试:正常计算
fun calculateTotal_normalCase() { // 正常计算
val result = calculator.calculateTotal( // 计算
price = 100.0, // 单价
quantity = 2, // 数量
taxRate = 0.1 // 税率
)
assertEquals(220.0, result, 0.01) // 断言结果
}
@Test // 测试:零数量
fun calculateTotal_zeroQuantity() { // 零数量
val result = calculator.calculateTotal( // 计算
price = 100.0, // 单价
quantity = 0 // 数量
)
assertEquals(0.0, result, 0.01) // 断言结果
}
@Test // 测试:负价格抛异常
fun calculateTotal_negativePrice_throws() { // 负价格
assertFailsWith<IllegalArgumentException> { // 断言异常
calculator.calculateTotal(price = -1.0, quantity = 1) // 计算
}
}
@Test // 测试:无税计算
fun calculateTotal_noTax() { // 无税
val result = calculator.calculateTotal( // 计算
price = 50.0, // 单价
quantity = 3, // 数量
taxRate = 0.0 // 无税
)
assertEquals(150.0, result, 0.01) // 断言结果
}
}
// commonTest/kotlin/com/example/shared/ValidatorTest.kt
class ValidatorTest { // 验证器测试
@Test // 测试:有效邮箱
fun validEmail() { // 有效邮箱
assertTrue(Validator.isValidEmail("test@example.com")) // 断言为真
}
@Test // 测试:无效邮箱
fun invalidEmail() { // 无效邮箱
assertFalse(Validator.isValidEmail("invalid")) // 断言为假
}
@Test // 测试:有效密码
fun validPassword() { // 有效密码
assertTrue(Validator.isValidPassword("123456")) // 断言为真
}
@Test // 测试:密码太短
fun shortPassword() { // 密码太短
assertFalse(Validator.isValidPassword("123")) // 断言为假
}
}
5. UI 设计解读
第一,KMP 测试的核心优势。 commonTest 中的测试在 Android 和 iOS 上都会运行。你只需要写一次测试,就能验证共享逻辑在所有平台上都正确。这大大减少了重复测试的工作量。
第二,kotlin.test 的 API。 KMP 使用 kotlin.test 提供的断言 API:assertEquals、assertTrue、assertFalse、assertFailsWith。这些 API 在所有平台上都可用。
第三,KMP 测试的运行方式。 在 Android 上,commonTest 的测试作为 JVM 单元测试运行。在 iOS 上,它们作为 Kotlin/Native 测试运行。测试代码在 commonTest 中,运行环境由各个平台提供。
第四,UI 设计意义。 如果你的 App 使用 Compose Multiplatform 共享 UI,commonTest 中的测试覆盖了共享逻辑,而各个平台的测试覆盖了平台特定的 UI 行为。共享逻辑测试是跨平台项目质量保证的第一道防线。
6. 常见陷阱
- 在
commonTest中使用平台特定 API:应该用expect/actual声明,在平台特定测试中实现。 - 忘记添加
kotlin.test依赖:需要在commonTest中添加implementation(kotlin("test"))。 - 在共享测试中测试 UI:
commonTest适合逻辑测试,UI 测试应该在平台特定测试中。 - 测试依赖平台特定的时间或随机数:应该注入可控的依赖。
assertEquals浮点数比较忘记指定精度:应该用assertEquals(expected, actual, absoluteTolerance)。
7. 可改进方向(含参考答案)
可改进点
- 用
expect/actual测试平台特定逻辑。 - 用
runTest测试协程逻辑。 - 用
MockK或Fake测试依赖注入。
增强版参考答案
// commonMain/kotlin/com/example/shared/Clock.kt
expect fun currentTimeMillis(): Long // 当前时间
// commonTest/kotlin/com/example/shared/ClockTest.kt
class ClockTest { // 时钟测试
@Test // 测试:时间递增
fun timeIncreases() { // 时间递增
val time1 = currentTimeMillis() // 第一次
val time2 = currentTimeMillis() // 第二次
assertTrue(time2 >= time1) // 断言递增
}
}
改进解读
expect/actual 声明跨平台 API。commonTest 中测试共享逻辑,平台特定测试中验证 actual 实现。runTest 用于测试协程逻辑,确保协程在测试中正确完成。
8. 关键要点
commonTest中的测试在 Android 和 iOS 上都会运行。- 用
kotlin.test的断言 API。 expect/actual用于平台特定逻辑。- 共享逻辑测试是跨平台项目的第一道防线。
案例八:综合案例——完整电商 App 测试策略
1. 真实场景
结合本课所有知识,为一个电商 App 制定完整的测试策略,覆盖单元测试、Compose UI 测试、Hilt 集成测试和导航测试。
2. 设计目标
- 为 ViewModel 编写单元测试。
- 为 Composable 编写 UI 测试。
- 为登录流程编写 Hilt 集成测试。
- 为导航流程编写测试。
- 建立测试金字塔策略。
3. 测试金字塔
| 层次 | 数量 | 运行速度 | 覆盖内容 |
|---|---|---|---|
| 单元测试 | 最多 | 快(毫秒) | ViewModel、Repository、工具类 |
| Compose UI 测试 | 中等 | 中(秒) | 单个 Composable |
| 集成测试 | 较少 | 慢(秒-分钟) | 页面 + 导航 + Hilt |
4. 完整代码(逐行注释)
// ============ 单元测试:ViewModel ============
class CartViewModelTest { // 购物车 ViewModel 测试
private lateinit var viewModel: CartViewModel // ViewModel
private lateinit var fakeRepository: FakeCartRepository // 假仓库
@Before // 前置
fun setup() { // 设置
fakeRepository = FakeCartRepository() // 创建假仓库
viewModel = CartViewModel(fakeRepository) // 创建 ViewModel
}
@Test // 测试:添加商品
fun addProduct_increasesCount() = runTest { // 添加商品
viewModel.addProduct(Product(1, "测试商品", 99.0)) // 添加
advanceUntilIdle() // 等待协程完成
assertEquals(1, viewModel.uiState.value.items.size) // 断言数量
}
@Test // 测试:删除商品
fun removeProduct_decreasesCount() = runTest { // 删除商品
viewModel.addProduct(Product(1, "测试商品", 99.0)) // 添加
advanceUntilIdle() // 等待
viewModel.removeProduct(1) // 删除
advanceUntilIdle() // 等待
assertEquals(0, viewModel.uiState.value.items.size) // 断言数量
}
@Test // 测试:计算总价
fun calculateTotal_correctPrice() = runTest { // 计算总价
viewModel.addProduct(Product(1, "商品 A", 50.0)) // 添加
viewModel.addProduct(Product(2, "商品 B", 30.0)) // 添加
advanceUntilIdle() // 等待
assertEquals(80.0, viewModel.uiState.value.totalPrice, 0.01) // 断言总价
}
}
// ============ Compose UI 测试 ============
class CartScreenTest { // 购物车页面测试
@get:Rule // 规则
val composeTestRule = createComposeRule() // 测试规则
@Test // 测试:显示商品列表
fun showProductList() { // 显示商品列表
val items = listOf( // 商品
CartItem(1, "商品 A", 50.0, 1), // 商品 A
CartItem(2, "商品 B", 30.0, 2) // 商品 B
)
composeTestRule.setContent { // 设置内容
CartScreen( // 购物车页面
items = items, // 数据
onRemove = {} // 删除回调
)
}
composeTestRule.onAllNodesWithTag("cart_item") // 所有商品项
.assertCountEquals(2) // 断言数量
}
@Test // 测试:点击删除按钮
fun removeButton_triggersCallback() { // 删除按钮
var removedId: Long? = null // 删除的 ID
composeTestRule.setContent { // 设置内容
CartScreen( // 购物车页面
items = listOf(CartItem(1, "商品 A", 50.0, 1)), // 数据
onRemove = { removedId = it } // 删除回调
)
}
composeTestRule.onNodeWithTag("remove_button_1") // 删除按钮
.performClick() // 点击
assertEquals(1L, removedId) // 断言 ID
}
}
// ============ Hilt 集成测试 ============
@HiltAndroidTest // Hilt 测试
class CheckoutFlowTest { // 结算流程测试
@get:Rule(order = 0) // Hilt 规则
val hiltRule = HiltAndroidRule(this) // Hilt
@get:Rule(order = 1) // Compose 规则
val composeTestRule = createAndroidComposeRule<MainActivity>() // Compose
@BindValue // 绑定值
@JvmField // JVM
val fakeRepository: CartRepository = FakeCartRepository() // 假仓库
@Before // 前置
fun setup() { // 设置
hiltRule.inject() // 注入
}
@Test // 测试:结算成功
fun checkout_success() { // 结算成功
composeTestRule.setContent { // 设置内容
MyAppTheme { // 主题
CheckoutScreen() // 结算页面
}
}
// 点击结算按钮
composeTestRule.onNodeWithTag("checkout_button") // 结算
.performClick() // 点击
// 等待成功
composeTestRule.waitUntil(timeoutMillis = 5000) { // 等待
composeTestRule // 规则
.onAllNodesWithTag("success_dialog") // 成功对话框
.fetchSemanticsNodes() // 获取
.isNotEmpty() // 不为空
}
// 断言成功
composeTestRule.onNodeWithTag("success_dialog") // 成功对话框
.assertIsDisplayed() // 断言显示
}
}
5. UI 设计解读
第一,测试金字塔的实践。 单元测试数量最多,覆盖 ViewModel 和 Repository 的逻辑。Compose UI 测试数量中等,覆盖单个组件的渲染和交互。集成测试数量较少,覆盖完整的用户流程。这是 Google 推荐的测试策略。
第二,单元测试用 runTest。 runTest 是 kotlinx-coroutines-test 提供的测试协程的 API。advanceUntilIdle() 等待所有挂起的协程完成。MainDispatcherRule 用于替换主调度器,让测试在 JVM 上运行。
第三,集成测试用 @BindValue。 @BindValue 直接将 Fake Repository 绑定到 Hilt 图中,比 @TestInstallIn 更简洁。适合只需要替换一个依赖的场景。
第四,UI 设计意义。 完整的测试策略确保:业务逻辑正确(单元测试),组件渲染正确(UI 测试),端到端流程正确(集成测试)。测试覆盖率不是目标,覆盖关键用户路径才是。
6. 常见陷阱
- 测试只覆盖 Happy Path:应该覆盖错误、空状态、边界条件。
- 集成测试中创建真实网络请求:应该用 Fake 或 Mock。
- 测试之间共享状态:每个测试应该独立,不依赖其他测试的结果。
- 测试命名不清晰:测试名应该说明"测试什么"和"期望什么"。
- 忘记在 CI 中运行测试:测试应该在每次提交时自动运行。
7. 关键要点
- 单元测试覆盖 ViewModel 和 Repository 逻辑。
- Compose UI 测试覆盖组件渲染和交互。
- 集成测试覆盖端到端流程。
- 用
runTest测试协程,用@BindValue替换依赖。 - 测试应该覆盖 Happy Path、错误路径和边界条件。
从案例中提炼的 Compose 测试与质量保证原则
-
用
createComposeRule()测试单个 Composable,用createAndroidComposeRule()测试集成流程。 选择取决于是否需要 Activity。 -
用
testTag为组件添加稳定的测试标识。 不要依赖可能变化的文本。 -
用
onNodeWithText、onNodeWithTag、onNodeWithContentDescription查找节点。 优先用testTag。 -
用
performClick、performTextInput、performTouchInput执行用户操作。 -
用
assertIsDisplayed、assertIsEnabled、assertTextEquals验证组件状态。 -
用
waitUntil等待异步操作完成。 不要用Thread.sleep。 -
用
useUnmergedTree = true访问未合并的语义树。 只在需要时使用。 -
用
printToLog打印语义树,调试查找器。 -
用
@HiltAndroidTest+HiltAndroidRule+@TestInstallIn测试 Hilt 集成。 用 Fake 替代真实网络请求。 -
用
@get:Rule(order = 0)保证 Hilt 规则在 Compose 规则之前。 -
导航测试用
createAndroidComposeRule,用waitUntil等待目标页面出现。 -
KMP 项目在
commonTest中测试共享逻辑。 用kotlin.test的断言 API。 -
遵循测试金字塔:单元测试最多,UI 测试中等,集成测试较少。
-
测试覆盖 Happy Path、错误路径、空状态和边界条件。
-
测试语义树与可访问性语义树是同一个东西。 做好可访问性就等于为测试打好了基础。
课后练习与参考答案
练习 1:测试登录表单
要求:一个登录表单,包含用户名和密码输入框、登录按钮。测试:输入有效数据后按钮可用,点击后回调被触发。
参考答案:
@Composable
fun LoginForm(onLogin: (String, String) -> Unit) { // 登录表单
var username by remember { mutableStateOf("") } // 用户名
var password by remember { mutableStateOf("") } // 密码
Column( // 纵向布局
modifier = Modifier.padding(16.dp) // 内边距
) {
OutlinedTextField( // 用户名
value = username, // 值
onValueChange = { username = it }, // 变化
label = { Text("用户名") }, // 标签
modifier = Modifier.testTag("username") // 测试标签
)
OutlinedTextField( // 密码
value = password, // 值
onValueChange = { password = it }, // 变化
label = { Text("密码") }, // 标签
modifier = Modifier.testTag("password") // 测试标签
)
Button( // 登录
onClick = { onLogin(username, password) }, // 点击
enabled = username.isNotBlank() && password.length >= 6, // 启用
modifier = Modifier.testTag("login") // 测试标签
) {
Text("登录") // 文字
}
}
}
class LoginFormTest { // 测试
@get:Rule // 规则
val composeTestRule = createComposeRule() // 规则
@Test // 测试:按钮启用
fun button_enabledAfterInput() { // 按钮启用
composeTestRule.setContent { // 设置内容
LoginForm(onLogin = { _, _ -> }) // 表单
}
composeTestRule.onNodeWithTag("login").assertIsNotEnabled() // 初始禁用
composeTestRule.onNodeWithTag("username").performTextInput("张三") // 输入
composeTestRule.onNodeWithTag("password").performTextInput("123456") // 输入
composeTestRule.onNodeWithTag("login").assertIsEnabled() // 断言启用
}
@Test // 测试:回调触发
fun callback_triggered() { // 回调
var captured = false // 捕获
composeTestRule.setContent { // 设置内容
LoginForm(onLogin = { _, _ -> captured = true }) // 表单
}
composeTestRule.onNodeWithTag("username").performTextInput("张三") // 输入
composeTestRule.onNodeWithTag("password").performTextInput("123456") // 输入
composeTestRule.onNodeWithTag("login").performClick() // 点击
assertTrue(captured) // 断言
}
}
解读: 用 testTag 查找输入框和按钮。assertIsNotEnabled 和 assertIsEnabled 验证按钮状态。performTextInput 输入文字,performClick 点击按钮。回调通过捕获变量验证。
练习 2:测试图标按钮
要求:一个工具栏,包含返回、搜索、购物车三个图标按钮。测试每个按钮的 contentDescription 和点击回调。
参考答案:
@Composable
fun Toolbar(onBack: () -> Unit, onSearch: () -> Unit, onCart: () -> Unit) { // 工具栏
Row { // 横向
IconButton(onClick = onBack) { // 返回
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回") // 图标
}
IconButton(onClick = onSearch) { // 搜索
Icon(Icons.Default.Search, contentDescription = "搜索") // 图标
}
IconButton(onClick = onCart) { // 购物车
Icon(Icons.Default.ShoppingCart, contentDescription = "购物车") // 图标
}
}
}
class ToolbarTest { // 工具栏测试
@get:Rule // 规则
val composeTestRule = createComposeRule() // 规则
@Test // 测试:返回按钮
fun backButton_hasDescription() { // 返回按钮
composeTestRule.setContent { // 设置内容
Toolbar(onBack = {}, onSearch = {}, onCart = {}) // 工具栏
}
composeTestRule.onNodeWithContentDescription("返回") // 查找
.assertExists() // 断言存在
}
@Test // 测试:搜索按钮点击
fun searchButton_triggersCallback() { // 搜索按钮
var clicked = false // 点击
composeTestRule.setContent { // 设置内容
Toolbar(onBack = {}, onSearch = { clicked = true }, onCart = {}) // 工具栏
}
composeTestRule.onNodeWithContentDescription("搜索") // 查找
.performClick() // 点击
assertTrue(clicked) // 断言
}
}
解读: 用 onNodeWithContentDescription 查找图标按钮。performClick 执行点击。回调通过捕获变量验证。
练习 3:测试列表滚动
要求:一个 50 项的列表,测试滚动到第 30 项并断言其显示。
参考答案:
@Composable
fun LongList(items: List<String>) { // 长列表
LazyColumn(modifier = Modifier.testTag("list")) { // 列表
items(items) { item -> // 遍历
Text(item, modifier = Modifier
.fillMaxWidth() // 占满宽度
.padding(16.dp) // 内边距
.testTag("item_$item") // 测试标签
)
}
}
}
class LongListTest { // 长列表测试
@get:Rule // 规则
val composeTestRule = createComposeRule() // 规则
@Test // 测试:滚动到第 30 项
fun scrollToItem30() { // 滚动到第 30 项
val items = (1..50).map { "项目 $it" } // 数据
composeTestRule.setContent { // 设置内容
LongList(items) // 列表
}
composeTestRule.onNodeWithTag("list") // 列表
.performScrollToIndex(29) // 滚动到索引 29
composeTestRule.onNodeWithTag("item_项目 30") // 第 30 项
.assertIsDisplayed() // 断言显示
}
}
解读: performScrollToIndex 滚动到指定索引。assertIsDisplayed 验证目标项可见。索引从 0 开始,第 30 项的索引是 29。
练习 4:测试异步加载
要求:一个异步加载数据的页面,测试加载中显示进度条,加载完成后显示列表。
参考答案:
@Composable
fun AsyncScreen(load: suspend () -> List<String>) { // 异步页面
var items by remember { mutableStateOf<List<String>>(emptyList()) } // 数据
var loading by remember { mutableStateOf(true) } // 加载中
LaunchedEffect(Unit) { // 进入时
items = load() // 加载
loading = false // 结束
}
if (loading) { // 加载中
CircularProgressIndicator(modifier = Modifier.testTag("loading")) // 进度条
} else { // 加载完成
LazyColumn(modifier = Modifier.testTag("list")) { // 列表
items(items) { item -> // 遍历
Text(item, modifier = Modifier.testTag("item")) // 文字
}
}
}
}
class AsyncScreenTest { // 异步页面测试
@get:Rule // 规则
val composeTestRule = createComposeRule() // 规则
@Test // 测试:加载中显示进度条
fun loading_showsIndicator() { // 加载中
composeTestRule.setContent { // 设置内容
AsyncScreen(load = { delay(1000); listOf("A") }) // 异步
}
composeTestRule.onNodeWithTag("loading").assertIsDisplayed() // 断言
}
@Test // 测试:加载完成显示列表
fun loaded_showsList() { // 加载完成
composeTestRule.setContent { // 设置内容
AsyncScreen(load = { listOf("A", "B", "C") }) // 异步
}
composeTestRule.waitUntil(timeoutMillis = 3000) { // 等待
composeTestRule.onAllNodesWithTag("item").fetchSemanticsNodes().isNotEmpty() // 条件
}
composeTestRule.onAllNodesWithTag("item").assertCountEquals(3) // 断言数量
}
}
解读: waitUntil 等待异步加载完成。fetchSemanticsNodes 获取当前匹配的节点。assertCountEquals 验证列表项数量。
练习 5:Hilt 集成测试
要求:测试一个通过 Hilt 注入 Repository 的 ViewModel。
参考答案:
@HiltAndroidTest // Hilt 测试
class HiltIntegrationTest { // Hilt 集成测试
@get:Rule(order = 0) // Hilt 规则
val hiltRule = HiltAndroidRule(this) // Hilt
@get:Rule(order = 1) // Compose 规则
val composeTestRule = createAndroidComposeRule<MainActivity>() // Compose
@BindValue // 绑定值
@JvmField // JVM
val repository: MyRepository = FakeMyRepository() // 假仓库
@Before // 前置
fun setup() { // 设置
hiltRule.inject() // 注入
}
@Test // 测试:加载数据
fun loadData_showsContent() { // 加载数据
composeTestRule.setContent { // 设置内容
MyScreen() // 页面
}
composeTestRule.waitUntil(timeoutMillis = 3000) { // 等待
composeTestRule.onAllNodesWithTag("content").fetchSemanticsNodes().isNotEmpty() // 条件
}
}
}
解读: @HiltAndroidTest 和 HiltAndroidRule 启用 Hilt 测试。@BindValue 绑定 Fake Repository。hiltRule.inject() 在 @Before 中调用。createAndroidComposeRule 启动 Activity。
练习 6:导航测试
要求:测试从首页进入详情页,再从详情页返回首页。
参考答案:
@RunWith(AndroidJUnit4::class) // AndroidJUnit4
class NavTest { // 导航测试
@get:Rule // 规则
val composeTestRule = createAndroidComposeRule<MainActivity>() // 规则
@Test // 测试:前进和返回
fun navigate_forwardAndBack() { // 前进和返回
// 断言首页
composeTestRule.onNodeWithTag("home").assertIsDisplayed() // 首页
// 进入详情
composeTestRule.onNodeWithTag("go_detail").performClick() // 点击
composeTestRule.waitUntil(timeoutMillis = 3000) { // 等待
composeTestRule.onAllNodesWithTag("detail").fetchSemanticsNodes().isNotEmpty() // 条件
}
composeTestRule.onNodeWithTag("detail").assertIsDisplayed() // 详情
// 返回首页
composeTestRule.onNodeWithTag("go_back").performClick() // 点击
composeTestRule.waitUntil(timeoutMillis = 3000) { // 等待
composeTestRule.onAllNodesWithTag("home").fetchSemanticsNodes().isNotEmpty() // 条件
}
composeTestRule.onNodeWithTag("home").assertIsDisplayed() // 首页
}
}
解读: 用 testTag 标记首页和详情页。performClick 触发导航。waitUntil 等待目标页面出现。
练习 7:KMP 共享逻辑测试
要求:在 commonTest 中测试一个价格计算函数。
参考答案:
// commonMain/kotlin/com/example/shared/PriceCalculator.kt
class PriceCalculator { // 价格计算器
fun calculateTotal(price: Double, quantity: Int): Double { // 计算
require(price >= 0) { "价格不能为负" } // 验证
require(quantity >= 0) { "数量不能为负" } // 验证
return price * quantity // 计算
}
}
// commonTest/kotlin/com/example/shared/PriceCalculatorTest.kt
class PriceCalculatorTest { // 测试
private val calculator = PriceCalculator() // 计算器
@Test // 测试:正常计算
fun calculateTotal_normalCase() { // 正常
assertEquals(200.0, calculator.calculateTotal(100.0, 2), 0.01) // 断言
}
@Test // 测试:零数量
fun calculateTotal_zeroQuantity() { // 零数量
assertEquals(0.0, calculator.calculateTotal(100.0, 0), 0.01) // 断言
}
@Test // 测试:负价格
fun calculateTotal_negativePrice_throws() { // 负价格
assertFailsWith<IllegalArgumentException> { // 断言异常
calculator.calculateTotal(-1.0, 1) // 计算
}
}
}
解读: commonTest 中的测试在 Android 和 iOS 上都会运行。用 kotlin.test 的 assertEquals、assertFailsWith 等 API。
练习 8:综合运用——完整测试策略
要求:为一个商品列表页面制定测试策略,包括单元测试、UI 测试和集成测试。
参考答案:
// 单元测试:ViewModel
class ProductViewModelTest { // ViewModel 测试
@Test // 测试:加载商品
fun loadProducts_updatesState() = runTest { // 加载
val repository = FakeProductRepository() // 假仓库
val viewModel = ProductViewModel(repository) // ViewModel
advanceUntilIdle() // 等待
assertEquals(3, viewModel.uiState.value.products.size) // 断言
}
}
// UI 测试:Composable
class ProductListTest { // UI 测试
@get:Rule // 规则
val composeTestRule = createComposeRule() // 规则
@Test // 测试:显示商品
fun showProducts() { // 显示商品
composeTestRule.setContent { // 设置内容
ProductList(products = listOf(Product(1, "A", 99.0))) // 列表
}
composeTestRule.onAllNodesWithTag("product").assertCountEquals(1) // 断言
}
}
// 集成测试:Hilt
@HiltAndroidTest // Hilt
class ProductFlowTest { // 集成测试
@get:Rule(order = 0) // Hilt 规则
val hiltRule = HiltAndroidRule(this) // Hilt
@get:Rule(order = 1) // Compose 规则
val composeTestRule = createAndroidComposeRule<MainActivity>() // Compose
@BindValue // 绑定
@JvmField // JVM
val repository: ProductRepository = FakeProductRepository() // 假仓库
@Before // 前置
fun setup() { hiltRule.inject() } // 注入
@Test // 测试:完整流程
fun productFlow() { // 完整流程
composeTestRule.waitUntil(timeoutMillis = 5000) { // 等待
composeTestRule.onAllNodesWithTag("product").fetchSemanticsNodes().isNotEmpty() // 条件
}
composeTestRule.onAllNodesWithTag("product").assertCountEquals(3) // 断言
}
}
解读: 完整的测试策略覆盖三层:单元测试验证 ViewModel 逻辑,UI 测试验证 Composable 渲染,集成测试验证 Hilt 注入和端到端流程。
本课总结
第9课聚焦 Compose 测试与质量保证,用八个真实案例深入理解了:
createComposeRule()vscreateAndroidComposeRule():前者用于纯 Composable 测试,后者用于 Activity 级别集成测试。- 语义查找器:
onNodeWithText、onNodeWithTag、onNodeWithContentDescription。 testTag:为组件添加稳定的测试标识,不受文本变化影响。- 节点操作:
performClick、performTextInput、performTouchInput。 - 断言:
assertIsDisplayed、assertIsEnabled、assertTextEquals。 - 同步机制:
waitUntil等待异步操作完成,不要用Thread.sleep。 - Hilt 集成测试:
@HiltAndroidTest+HiltAndroidRule+@TestInstallIn。 - 导航测试:用
createAndroidComposeRule+waitUntil测试页面跳转。 - KMP 共享逻辑测试:在
commonTest中用kotlin.test测试跨平台逻辑。 - 测试金字塔:单元测试最多,UI 测试中等,集成测试较少。
你需要记住:
- 测试语义树与可访问性语义树是同一个东西。做好可访问性就等于为测试打好了基础。
- 用
testTag为组件添加稳定的测试标识。 - 用
waitUntil等待异步操作,不要用Thread.sleep。 - Hilt 测试用
@HiltAndroidTest+HiltAndroidRule+@TestInstallIn。 - 测试覆盖 Happy Path、错误路径、空状态和边界条件。
- 每个案例后面都列出了常见陷阱,写代码时对照检查。
至此,《用案例学Jetpack Compose UI设计》系列已经完成了九课。从第1课的声明式 UI 认知,到第2课的布局系统,第3课的状态管理,第4课的导航架构,第5课的主题设计系统,第6课的动画过渡,第7课的可访问性与国际化,第8课的性能优化,再到第9课的测试与质量保证——你已经走完了一个 Compose 开发者从入门到精通的全路径。
下一步,建议你选择一个真实项目,把本系列学到的知识应用到实践中。每完成一个页面,都问自己五个问题:这个界面的信息层级是什么?状态在哪里?如果屏幕变宽或变窄,它会怎样变化?这个界面流畅吗?我该怎么测试它?这五个问题,就是 Compose UI 设计的完整闭环。
更多推荐


所有评论(0)