17、无障碍测试:语义属性测试、无障碍操作测试
说实话,无障碍测试在UI测试里经常被忽略。我见过太多团队,功能测试跑得飞起,一打开TalkBack就彻底崩了。按钮读不出来,操作焦点乱跳,用户根本没法用。
你想想看,如果你的App连视障用户都用不了,那还谈什么用户体验?今天我们就来聊聊Compose里的无障碍测试,重点是两个方向:语义属性测试和无障碍操作测试。
为什么无障碍测试这么重要?
先说说我的一个教训。几年前我负责一个电商App,上线前跑了一遍无障碍扫描,结果发现「加入购物车」按钮的语义标签是空的。TalkBack读出来就是「按钮,双击即可激活」。用户根本不知道这个按钮是干嘛的。
后来我养成了一个习惯:每个可交互组件,都必须有明确的语义描述。这不是为了应付审核,而是真正为用户负责。
Compose的无障碍测试,核心就是验证两件事:
- 语义属性对不对——组件能不能被正确识别和描述
- 无障碍操作灵不灵——用户能不能通过辅助功能完成操作
语义属性测试:让组件「说人话」
语义属性,说白了就是组件告诉无障碍服务「我是谁、我能干什么」。在Compose里,我们通过semantics修饰符来设置。
举个例子:
@Composable
fun BuyButton(onClick: () -> Unit) {
Button(
onClick = onClick,
modifier = Modifier.semantics {
contentDescription = "加入购物车"
role = Role.Button
}
) {
Text("购买")
}
}
那怎么测试这个语义属性呢?用SemanticsMatcher:
@Test
fun buyButton_hasCorrectSemantics() {
composeTestRule.setContent {
BuyButton(onClick = {})
}
composeTestRule
.onNodeWithText("购买")
.assertSemanticsMatcher("contentDescription应为'加入购物车'") {
it.config[SemanticsProperties.ContentDescription].contains("加入购物车")
}
}
我个人习惯把常用的语义匹配器封装成扩展函数,这样测试代码更干净:
fun SemanticsNodeInteraction.assertContentDescription(expected: String) {
assertSemanticsMatcher("ContentDescription应为'$expected'") {
it.config[SemanticsProperties.ContentDescription] == expected
}
}
// 使用
composeTestRule
.onNodeWithText("购买")
.assertContentDescription("加入购物车")
常见的语义属性检查点
| 属性 | 说明 | 测试方法 |
|---|---|---|
| contentDescription | 组件的文字描述 | assertContentDescription |
| role | 组件的角色(按钮、图片等) | assertSemanticsMatcher + Role |
| stateDescription | 状态描述(如「已选中」) | assertSemanticsMatcher + StateDescription |
| isTraversalGroup | 是否作为焦点组 | assertIsTraversalGroup |
无障碍操作测试:验证交互是否可用
语义对了,操作也得通。无障碍操作测试,就是模拟辅助功能用户的操作路径。
Compose提供了performAccessibilityAction方法:
@Test
fun buyButton_accessibilityClick_works() {
var clicked = false
composeTestRule.setContent {
BuyButton(onClick = { clicked = true })
}
composeTestRule
.onNodeWithText("购买")
.performAccessibilityAction(AccessibilityAction.Click)
assert(clicked) { "点击事件应该被触发" }
}
这里有个坑。我曾经以为performClick和performAccessibilityAction是一样的,结果发现前者走的是普通点击路径,后者走的是无障碍事件路径。如果你的组件对无障碍事件做了特殊处理,一定要用performAccessibilityAction来测。
完整的无障碍测试流程
我一般会按这个流程来写无障碍测试:
- 先测语义——确保每个可交互组件都有正确的contentDescription和role
- 再测操作——模拟无障碍点击、长按、滑动等操作
- 最后测焦点——验证焦点顺序是否合理,有没有丢失焦点的情况
焦点测试比较容易被忽略。举个例子:
@Test
fun focusOrder_isCorrect() {
composeTestRule.setContent {
Column {
TextField(value = "", onValueChange = {}, modifier = Modifier.semantics { contentDescription = "用户名" })
TextField(value = "", onValueChange = {}, modifier = Modifier.semantics { contentDescription = "密码" })
Button(onClick = {}, modifier = Modifier.semantics { contentDescription = "登录" }) {
Text("登录")
}
}
}
// 验证焦点顺序
composeTestRule.onNodeWithContentDescription("用户名").assertIsFocused()
composeTestRule.onNodeWithContentDescription("密码").performAccessibilityAction(AccessibilityAction.Next)
composeTestRule.onNodeWithContentDescription("密码").assertIsFocused()
}
assertIsTraversalGroup来验证复杂布局的焦点分组是否正确。比如一个卡片组件,应该作为一个整体被焦点遍历,而不是内部每个小元素都单独获取焦点。
用SVG梳理无障碍测试知识体系
下面这张图,是我自己整理的无障碍测试核心逻辑:
自动化无障碍扫描
除了手动写测试用例,Compose还提供了SemanticsNodeInteractionsProvider的扫描能力。你可以写一个通用的扫描函数,跑遍所有页面:
@Test
fun allNodes_haveContentDescription() {
composeTestRule.setContent {
MyScreen()
}
composeTestRule.onAllNodes(
hasClickAction() and !hasContentDescription()
).assertCountEquals(0) { "所有可点击节点必须有contentDescription" }
}
这个扫描函数,我一般放在CI里跑。每次PR合并前,自动扫描一遍所有页面,确保没有遗漏语义属性。
避坑指南
最后分享几个我踩过的坑:
- 不要依赖默认语义——Compose会自动生成一些语义,但往往不够准确。比如一个图片按钮,默认可能只读成「图片」,你得手动设置contentDescription。
- 注意自定义组件的语义——如果你用Canvas画了一个自定义按钮,记得手动加上
semantics修饰符。我曾经漏掉这个,结果那个按钮在无障碍模式下完全不可见。 - 测试要覆盖边界情况——比如列表为空时,有没有提示文字?加载中时,有没有进度指示器的语义?这些细节很容易被忽略。
嗯,无障碍测试其实不难,难的是养成习惯。每次写UI测试时,顺手加上语义和操作的断言,慢慢就变成肌肉记忆了。