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
注意:不要只测contentDescription。我遇到过组件描述对了,但role设成了Image而不是Button,导致TalkBack读成「图片,双击即可激活」。用户根本不知道这是个可点击的按钮。

无障碍操作测试:验证交互是否可用

语义对了,操作也得通。无障碍操作测试,就是模拟辅助功能用户的操作路径。

Compose提供了performAccessibilityAction方法:

@Test
fun buyButton_accessibilityClick_works() {
    var clicked = false
    composeTestRule.setContent {
        BuyButton(onClick = { clicked = true })
    }

    composeTestRule
        .onNodeWithText("购买")
        .performAccessibilityAction(AccessibilityAction.Click)

    assert(clicked) { "点击事件应该被触发" }
}

这里有个坑。我曾经以为performClickperformAccessibilityAction是一样的,结果发现前者走的是普通点击路径,后者走的是无障碍事件路径。如果你的组件对无障碍事件做了特殊处理,一定要用performAccessibilityAction来测。

完整的无障碍测试流程

我一般会按这个流程来写无障碍测试:

  1. 先测语义——确保每个可交互组件都有正确的contentDescription和role
  2. 再测操作——模拟无障碍点击、长按、滑动等操作
  3. 最后测焦点——验证焦点顺序是否合理,有没有丢失焦点的情况

焦点测试比较容易被忽略。举个例子:

@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 无障碍测试核心流程 无障碍测试 语义属性测试 无障碍操作测试 contentDescription 验证 role 角色验证 stateDescription 状态验证 performAccessibilityAction 焦点顺序验证 焦点分组验证

自动化无障碍扫描

除了手动写测试用例,Compose还提供了SemanticsNodeInteractionsProvider的扫描能力。你可以写一个通用的扫描函数,跑遍所有页面:

@Test
fun allNodes_haveContentDescription() {
    composeTestRule.setContent {
        MyScreen()
    }

    composeTestRule.onAllNodes(
        hasClickAction() and !hasContentDescription()
    ).assertCountEquals(0) { "所有可点击节点必须有contentDescription" }
}

这个扫描函数,我一般放在CI里跑。每次PR合并前,自动扫描一遍所有页面,确保没有遗漏语义属性。

核心原则:无障碍测试不是「加分项」,而是「必选项」。每次新增一个可交互组件,都要问自己:这个组件的语义对吗?操作路径通吗?焦点顺序合理吗?

避坑指南

最后分享几个我踩过的坑:

  • 不要依赖默认语义——Compose会自动生成一些语义,但往往不够准确。比如一个图片按钮,默认可能只读成「图片」,你得手动设置contentDescription。
  • 注意自定义组件的语义——如果你用Canvas画了一个自定义按钮,记得手动加上semantics修饰符。我曾经漏掉这个,结果那个按钮在无障碍模式下完全不可见。
  • 测试要覆盖边界情况——比如列表为空时,有没有提示文字?加载中时,有没有进度指示器的语义?这些细节很容易被忽略。

嗯,无障碍测试其实不难,难的是养成习惯。每次写UI测试时,顺手加上语义和操作的断言,慢慢就变成肌肉记忆了。

公众号:蓝海资料掘金营,微信deep3321