可访问性与多语言测试:被忽略的两个"全员体验"

可访问性(a11y)和多语言(i18n)是自动化体系里最容易被排到最后的两块——“我们用户又不用读屏”“我们只面向中文”。但事实是:a11y 问题 80% 同时也是普通用户的问题(焦点管理坏的页面,键盘用户和触屏用户都遭殃),而"只面向中文"的系统,文案写死在代码里,出海那天全都要重写。这篇讲怎么把这两块变成可自动化的常规测试。

一、可访问性:从"合规项"到"质量项"

1. 核心问题清单(按发生频率排序)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
1. 焦点管理(40%)
- 弹窗打开后焦点没进去(Tab 还在底层页面)
- 弹窗关闭后焦点没回来
- 单页应用路由切换后焦点回到 <body>(读屏用户"迷失")
2. 可访问名称缺失(25%)
- 纯图标按钮没有 aria-label("这个按钮是干嘛的?")
- 图片没有 alt / 装饰图没有 alt=""
3. 对比度不足(15%)
- 灰字灰底(WCAG AA 要求正文 4.5:1)
- 深色模式下的"设计稿看着行"
4. 语义结构(10%)
- 用 <div> 假装按钮(点不了、读不了)
- 标题层级跳级(h1 → h3)
5. 动效(10%)
- 闪烁内容(光敏癫痫风险)
- 没尊重 prefers-reduced-motion

2. 自动化:axe-core 进回归

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Playwright + @axe-core/playwright(每条 UI 用例的收尾动作)
from playwright_axe import Ax

@pytest.fixture
def axe(page):
yield Ax(page)

@pytest.mark.a11y
def test_login_page_a11y(page, axe):
page.goto("/login")
results = axe.run() # 全规则扫描
# 分级处理:
critical = [v for v in results.violations if v.impact in ("critical", "serious")]
assert not critical, format_violations(critical) # 门禁:critical 必须 0
# moderate/minor 进报告不阻塞(治理清单)

落地策略

  • 门禁只卡 critical/serious(焦点陷阱、不可名按钮这类"功能级"问题),minor 进治理看板——一上来卡全部规则,报告红成一片,没人看;
  • 每条 UI 用例自动带 a11y 扫描(不只"专门的 a11y 用例")——页面在用例里被真实操作过,扫描的是"交互后的状态"(弹窗打开时、表单报错时),比单独访问页面扫得多;
  • 基线豁免(allowlist):历史问题登记进基线(带 issue 号 + 期限),基线只能减不能增——新页面必须 a11y 干净,老页面逐步消化。

3. 键盘走查:自动化测不到的 20%

axe 扫不出"交互逻辑"问题,键盘走查脚本补位:

1
2
3
4
5
6
7
8
9
10
11
12
def test_modal_focus_trap(page):
page.goto("/order")
page.get_by_role("button", name="详情").click() # 打开弹窗
# 焦点应在弹窗内
assert page.evaluate("document.activeElement.closest('[role=dialog]')") is not None
# Tab 循环测试:按 N 次 Tab 焦点始终在弹窗内(N = 弹窗内可聚焦元素数 + 5)
for _ in range(20):
page.keyboard.press("Tab")
assert in_dialog(page)
# ESC 关闭后焦点回到触发按钮
page.keyboard.press("Escape")
assert page.evaluate("document.activeElement.textContent") == "详情"

二、多语言:把"文案"从代码里赶出去

1. i18n 问题清单

1
2
3
4
5
6
7
8
9
10
11
12
1. 文案硬编码(60%)
"订单已取消" 写死在组件里 → 翻译无法生效
检测:i18n lint —— 源码中的中文字符串必须走 t("key")
2. 断言写死文案(25%)
assert text == "提交成功" → 切英文必挂
解法:断言用 data-testid / role,不用文案;必须断言文案时从 i18n 资源取期望值
3. 布局溢出(10%)
中文 4 字的按钮,德语 12 字符 → 截断/换行/溢出
解法:长文案压测(取各语言 Top 长度文案渲染截图对比)
4. 数字/日期/货币格式(5%)
1,000.00 vs 1.000,00 vs 1 000.00
解法:ICU 格式化 + 各 locale 快照

2. 自动化三件套

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 1) i18n lint(CI 静态检查)
# - 源码裸中文字符串(字符串字面量/属性值)→ fail
# - i18n key 不存在 → fail
# - 有 key 无某语言译文(en/ja/ko 缺)→ warn

# 2) 语言切换回归(UI 层)
@pytest.mark.parametrize("locale", ["zh-CN", "en-US", "ja-JP", "de-DE"])
def test_core_flow_multilang(page, locale):
page.set_default_locale(locale)
page.goto("/order")
# 关键:断言用 role/testid,不用文案
assert page.get_by_role("button", name=expected_name("submit", locale)).is_visible()
# 布局溢出检测:元素 scrollWidth > clientWidth(文案被截断)
for el in page.locator("[data-i18n]"):
assert el.evaluate("el => el.scrollWidth <= el.clientWidth + 2")

# 3) 文案截图对比(视觉回归)
# 各 locale 下核心页面截图 → 像素 diff
# 专门抓"德语把按钮撑破"这类问题

语言矩阵的取舍:不是所有语言都全量跑——en-US 全量(出海主市场),ja-JP/de-DE/... 跑核心流程 + 截图对比。语言是数据,不是环境:同一个 UI 套件 × N 个 locale 参数,成本是线性的。

3. 深色模式 × 语言:组合爆炸的正确处理

深色/浅色 × 中/英/日 = 6 个视觉态。策略:

  • 截图对比只在"发版前"跑全组合(6 态 × 核心 10 页面 = 60 张,可接受);
  • 日常 CI 只跑默认态(浅色中文)——组合态的日常回归性价比太低;
  • 用例前置固定系统态(主题/语言写进 fixture),杜绝"用例依赖本机设置"(这是 flaky 的暗源之一)。

三、和体系的其他部分怎么接

1
2
3
4
5
a11y/i18n 在测试体系中的位置:
- 用例设计:可访问性是"环境维度"的一部分(键盘用户 = 一类"设备")
- 风险评级:a11y critical 缺陷 = 功能级缺陷(不是体验建议),按 P1 走
- 缺陷管理:a11y 问题单带"影响人群"字段(读屏用户/键盘用户/弱视用户)
- 度量:a11y 基线收敛速度 + i18n 覆盖语言数,进质量看板

四、小结

两块"被忽略的体验"的落地路径:a11y = axe 门禁(critical 为 0)+ 焦点走查脚本 + 基线只减不增i18n = lint 赶文案 + role 化断言 + 语言矩阵参数化 + 发版前全组合截图。核心认知:a11y 问题 80% 是普通用户问题(修它就是修质量),i18n 债务拖到出海那天是重写成本(修它就是修架构)。