
去年我们经历了一次印象深刻的“全绿上线”功能回归全部通过发布当晚收到用户报告——屏幕阅读器用户无法完成结算。按钮明明在页面上自动化脚本的click也能点中但键盘用户按Tab根本走不到那个按钮。那次事故之后我把可访问性测试从“加分项”挪到了核心回归清单里也搭建了一条真正能跑的端到端工具链。这篇东西就是我自己在搭建、落地这条工具链时的完整记录写给同样在软件测试岗位、想认真对待无障碍质量但又不知道从哪下手的从业者。1. 一次“全绿上线”之后的事故可访问性为什么必须进回归1.1 那次事故的现场复盘先说清楚当时发生了什么。我们是一个电商结算链路页面结构不复杂商品列表、优惠券选择、订单确认、支付按钮。常规自动化测试脚本用page.click(#checkout-submit)点结算按钮验证弹窗出现、验证跳转全部通过回归报告一片绿。事故出在一个很小但致命的设计上。结算页有一个优惠券推荐浮层浮层用遮罩层挡住了主页面。鼠标用户可以点到遮罩上的内容屏幕阅读器用户的虚拟焦点却被困在了遮罩背后朗读顺序直接跳到了页面底部支付按钮根本读不到。按照 WCAG 2.1 的标准这同时违反了 2.1.1 键盘、2.1.2 无键盘陷阱、2.4.3 焦点顺序三条成功标准。而我们的功能测试一条都没测出来因为自动化点击并不关心“这个元素能不能被键盘聚焦”。那次复盘我总结了一个结论功能测试验证的是“操作结果”可访问性测试验证的是“操作路径”。只要路径不同结果全绿也不能代表质量。这个差异是工具链设计的出发点。1.2 自动化功能测试为什么兜不住这类问题顺着复盘往深挖我当时问了团队一个问题为什么没人想到要测键盘路径答案很现实——测试用例设计是从“用户故事”拆出来的而自动化脚本模拟的是鼠标点击。两者在绝大多数场景下共享同一个 DOM 操作层但键盘可达性、焦点顺序、辅助技术朗读顺序这些维度在传统功能测试的“输入-断言”模型里根本没有对应的断言位置。你可以把这件事类比成测试一个电梯只验证了按下楼层按钮电梯会移动但没验证盲人能不能找到按钮、按钮有没有盲文、灯光报警有没有声音。功能上电梯是好的体验上它拒绝了一类乘客。所以可访问性测试要解决的是三条功能测试很少覆盖的路径键盘路径Tab 能不能按逻辑顺序走、焦点能不能被看见、会不会掉进某个区域出不来语义路径屏幕阅读器读出来的内容顺序是否合理、按钮名称是否清晰、表单标签是否关联感知路径颜色对比度是否足够、文字是否有替代形式、动态内容是否有提示这三条路径就是可访问性测试工具链的覆盖目标。工具链不是找一个“全站扫描器”扫一遍 URL而是针对这三类路径分别建设能力最后形成一条从代码编写到生产环境的端到端防线。2. 把工具分成三层而不是找一个“万能扫描器”刚开始我也犯过“工具焦虑”的毛病看到市面上有什么 axe、Lighthouse、pa11y 就全都装上跑出来一堆报告开发根本不知道哪条对应哪段代码。后来我放弃了“一个工具扫全站”的思路改成了分层工具链。每一层负责一类问题、一种反馈时长对应一个开发阶段。2.1 第一层代码阶段的静态扫描把问题堵在编译之前这一层我最常用的是eslint-plugin-jsx-a11y。它的定位很明确在写代码的阶段就把能静态判断的规范问题找出来。比如img标签缺alt、按钮用div实现、aria属性写错前缀、表单控件缺关联标签这类问题不需要运行页面就能判断。配置很简单在.eslintrc里加上推荐规则即可{ extends: [plugin:jsx-a11y/recommended] }实测下来这一层能拦截掉大约三成问题而且拦截时机最早。开发在 IDE 里写完代码立刻看到红线修复成本几乎为零。它的局限性在于完全不了解运行时状态——一个aria-hidden写对了的弹窗它看不出弹窗打开时焦点有没有移进去。这不是缺陷而是分层工具链刻意保留的边界。2.2 第二层组件级断言把规则写进单测静态扫描之后是组件级测试。我在组件测试里接入jest-axe在组件渲染完成后对 DOM 做一次规则扫描。这样做的好处是“断言紧贴组件上下文”同一个按钮在禁用态、加载态、错误态下分别断言一次而不是只扫一次常态。举一个实际用法import { render, screen } from testing-library/react; import { axe } from jest-axe; import { CheckoutButton } from ./CheckoutButton; test(结算按钮在加载态下没有可访问性违规, async () { const { container } render(CheckoutButton loading /); const results await axe(container); expect(results).toHaveNoViolations(); });这一层跑的是单个组件速度快、结果直观。更重要的是组件测试天然要求你写“断言上下文”这就逼着开发去思考“我这个组件在键盘聚焦、屏幕阅读器朗读时是什么状态”而不只是“它有没有渲染出来”。我见过不少团队跳过这一层直接上端到端扫描结果问题定位慢因为端到端报告给的是一个 URL 和元素选择器组件变更后选择器就失效了。2.3 第三层端到端扫描验证真实用户路径前两层覆盖了代码和组件但用户真实走进页面、完成任务的路径只有端到端层能验证。这一层我选的是axe-core/playwright在 Playwright 的测试里跑 axe 规则集扫描。它是目前社区集成度最高、规则更新最快的组合。端到端层的意义在于它看到了“运行时”状态路由切换之后的 DOM、异步加载出来的表单、弹窗打开后的焦点状态、shadow DOM 里的元素。这些在组件测试里都是孤立的但真实用户每次都会经历完整的组合过程。2.4 三层的关系与反馈闭环这三层经常被误解为“同一个工作的三种实现”实际上不是。我用一张表说明它们的差异层级工具反馈速度主要覆盖问题最佳执行时机静态扫描eslint-plugin-jsx-a11y秒级alt 缺失、aria 属性错误、错误标签嵌套开发写代码时组件断言jest-axe秒到分钟级组件在不同状态下的规范违规每次单测运行端到端扫描axe-core/playwright分钟级运行时动态渲染、焦点管理、真实交互路径合并前和发布前三层的反馈闭环是这样的静态层负责“第一时间拦下来”组件层负责“把状态细节测清楚”端到端层负责“在真实路径上做总扫尾”。少了任何一层问题都会向下一层积压最终变成生产事故。这也是我为什么强调这是“工具链”而不是“一个工具”。3. 端到端工具链的落地axe-core Playwright 的搭配3.1 为什么选 Playwright而不是 Selenium 或 Cypress我知道很多人第一反应是 Selenium毕竟它是老牌框架。但我在可访问性测试这个具体场景下Playwright 有几个优势是决定性的。首先是等待机制。可访问性扫描对时机极敏感——扫早了懒加载内容还没渲染漏报扫晚了测试时长拉长。Playwright 的自动等待内置了可访问性相关状态元素可见、稳定、可交互比 Selenium 里靠time.sleep或轮询is_displayed要可靠得多。其次是对现代前端特性的支持。shadow DOM 和跨 iframe 场景在 Selenium 里要写一堆额外逻辑Playwright 的原生 selector 可以直接穿透 shadow DOMpage.frames()遍历 iframe 也很顺手。这张护城河在扫描第三方组件时特别值钱。Cypress 也不错但它在多标签页和跨 iframe 场景上历史上走得比较慢。在“可访问性端到端测试”这个需求组合下Playwright 和axe-core/playwright的配合是最顺手的。3.2 核心代码从页面扫描到断言下面这套代码是我在真实项目里用的模式你可以直接抄作业。以结算页完整流程为例const { test, expect } require(playwright/test); const AxeBuilder require(axe-core/playwright).default; test(结算流程关键页面无 severe 以上可访问性问题, async ({ page }) { // 进入购物车页面 await page.goto(/cart); await page.locator(.cart-item).first().waitFor(); // 第一次扫描购物车列表 const cartResults await new AxeBuilder({ page }) .withTags([wcag2a, wcag2aa, wcag21aa]) .analyze(); expect(cartResults.violations.filter(v v.impact serious || v.impact critical)).toEqual([]); // 点击进入结算 await page.getByRole(button, { name: 去结算 }).click(); await page.locator(#checkout-form).waitFor(); // 第二次扫描结算表单 const checkoutResults await new AxeBuilder({ page }) .withTags([wcag2a, wcag2aa, wcag21aa]) .analyze(); expect(checkoutResults.violations.filter(v v.impact serious || v.impact critical)).toEqual([]); });注意第二次扫描前面有getByRole定位按钮。这就是 Playwright 和可访问性测试联动的好处getByRole本身就是基于可访问性树定位元素等价于问“屏幕阅读器用户看到的这个按钮叫什么名字”。用这个 API 写出来的步骤天然更接近真实用户的感知路径。如果页面里有弹窗这类动态区域我建议用include限定扫描范围而不是每次都扫整页// 打开优惠券弹窗后只扫描弹窗区域 await page.getByRole(button, { name: 选择优惠券 }).click(); await page.locator(.coupon-modal).waitFor(); const modalResults await new AxeBuilder({ page }) .include(.coupon-modal) .analyze(); expect(modalResults.violations).toEqual([]);include不只是在提性能它更是在定义“断言上下文”扫整个页面问题报告会混在一起限定到弹窗断言语义就是“这个弹窗必须无严重违规”这才是组件级思维在端到端里的延续。3.3 拦截规则与严重级别阈值axe 的规则集非常庞大全量启用会产生大量噪音。我的经验是分两步收敛先按标签收敛再按严重级别收敛。按标签收敛指的是只跑你当前承诺遵守的 WCAG 版本和等级。我用的是wcag2a、wcag2aa、wcag21aa三组标签对应 WCAG 2.1 的 A 和 AA 级。不跑wcag21aaa一方面是因为 AAA 级的标准在很多业务场景下会要求过高的对比度比如 7:1 的文字对比度产品设计上很难无差别满足另一方面是制定内部标准要循序渐进从 AA 起步已经能挡住绝大多数真实损伤。按严重级别收敛是指别一上来就要求“零违规”。axe 会把违规分为 minor、moderate、serious、critical 四级。我把门禁设成serious 和 critical 必须为零moderate 允许绿灯但必须记录minor 不做门禁。这样团队不会因为无穷无尽的 minor 级问题而疲惫把有限的精力集中在真正伤人的问题上。这里有一条个人教训不要把disableRules用滥。确实有颜色对比度这类规则容易误报但每一次disableRules都应该配一个代码注释说明为什么并且定期复查。我在后面专门写一段讲误报处理。3.4 WCAG 级别怎么选从 AA 开始别一上来就追求 AAA上面提到了 AAA这里单独说我的选择逻辑。AAA 级里有很多标准不是“改代码”就能达成的比如“纯装饰性内容必须彻底移除辅助技术呈现”“法律承诺和金融功能有专门的可理解性要求”这些在很多场景下需要产品形态的配合不是测试端能推动的。我在团队里把标准定为“AA 起步AAA 作为产品迭代目标”。测试门禁卡在 AA 上发现问题时会额外标注“这条影响了 AAA 达成”让产品负责人和技术负责人决策是否专项处理。这样既不会让测试沦为形式也不会让开发觉得无障碍是“永远够不到的标准”。4. 接入 CI让每个合并请求都背一遍“可访问性清单”工具链搭建得再完整如果只在手工测试阶段跑价值就大打折扣。我的原则是能把检查拉到左边就不要等到右边。所以流水线里要按层级分别设置触发时机。4.1 流水线里各层该跑多少很多团队一上来就把全站可访问性扫描挂到 PR 上结果每次 PR 跑二十分钟开发体验极差最后测试被人为跳过。正确的做法是分层设置PR 阶段跑静态扫描和组件断言。这两层反馈快能拦截大部分低层问题。端到端扫描太重不适合每个 PR 都跑。合并到主干后跑端到端扫描覆盖所有关键用户路径。这个阶段的失败会阻断发布所以报告必须具有可操作性。每周跑一次全站深度扫描作为对长尾页面的补充。如果你用的是 monorepo 或者大型项目可以在触发条件上做精准控制改动src/components下的组件时只跑组件层和静态层改动某个业务模块的路由时只跑该模块的端到端用例。这样既保住了覆盖率又不会让 CI 排队排到地老天荒。4.2 GitHub Actions 实例PR 检查的完整配置下面是一个可用的 GitHub Actions 配置专门跑可访问性端到端测试。注意两个细节一是用paths限定触发范围二是playwright install的时候一定要带浏览器依赖否则 CI 环境上跑不起来。name: a11y-e2e-check on: pull_request: paths: - src/** - playwright.config.ts - package.json jobs: a11y-e2e: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - uses: actions/setup-nodev4 with: node-version: 20 - run: npm ci - run: npx playwright install --with-deps chromium - run: npm run build - run: npx playwright test --grep a11y - name: 上传 axe 报告 if: always() uses: actions/upload-artifactv4 with: name: axe-results path: test-results/ retention-days: 30if: always()在这里很关键。可访问性测试失败时如果不上传报告开发只能看到一句“测试失败”还得本地复现。上传报告之后可以直接从 artifact 里拉 JSON 和 HTML 报告看具体是哪个元素、哪条规则、哪个页面排障效率完全不一样。4.3 报告与失败策略让红灯更有信息量CI 红灯最大的问题不是“失败”而是“为什么失败”信息不足。axe 返回的violations是结构化 JSON包含 ruleId、impact、description、nodes 等字段。以我常用的做法会写一个极简的封装脚本把违规汇总成一段 PR 评论// scripts/format-axe-results.js const fs require(fs); const results JSON.parse(fs.readFileSync(axe-results.json, utf8)); const summary results.violations.map(v ({ 规则: v.id, 级别: v.impact, 页面: v.nodes[0]?.target?.join( ) ?? 未知, 元素: v.nodes[0]?.html ?? 未知, 建议: v.help })); fs.writeFileSync(axe-summary.md, summary.map(s - **${s.级别}**: ${s.规则} → ${s.页面} ${s.元素}${s.建议} ).join(\n));这样 PR 评论里展示的就是人能直接读懂的清单而不是一份几千行的 JSON。测试失败没关系关键是失败信息要能直接引导修复。我见过太多团队因为报告难以阅读最后直接关掉了可访问性测试任务这是最可惜的一种死法。5. 误报、漏报和那些“扫描测不出来”的问题工具链建起来之后真正的考验才刚开始——你会收到大量看不懂的规则报警也会发现一些“明明有问题但扫描器一声不吭”的场景。这一章我把自己踩过的坑和最终的判断方法完整写出来。5.1 误报三连渐变背景、覆盖层与延迟渲染color-contrast规则是最容易误报的。axe 计算对比度时取的是当前计算样式里的前景色和背景色。但现实页面里背景通常是渐变色、背景图或者半透明叠加层axe 只能取它解析出来的纯色值结果就是两种误报要么把实际对比很差的元素判成通过漏报要么把视觉上完全正常的元素判成失败误报。我处理这类问题的顺序是先不看规则报告直接截图用肉眼确认那个区域的实际视觉效果。如果肉眼看着对比度是够的再检查是不是渐变背景导致的解析偏差这时可以考虑局部disableRules([color-contrast])。但记住——如果肉眼都已经觉得“灰底浅灰字看不清”那这根本不是误报是真实缺陷。第二个高频误报场景是浮层。弹窗打开后页面上同时存在遮罩层和底层页面axe 在扫描时会把遮罩层背景当作底层文字的背景来计算对比度。这类问题我通常用include限定弹窗范围来规避而不是直接关掉规则。第三个是延迟渲染。SPA 页面里路由切换后组件还没挂载完axe 就开始扫描结果漏掉一大批元素。Playwright 的waitFor只能保证“元素出现”不能保证“整个页面加载完”。我现在的做法是在每次扫描前显式等待关键区块全部出现后再执行分析宁可等一下也不要扫一个半成品页面。5.2 自动化最容易漏掉的三类问题自动化扫描本质上是“规则匹配”规则没有覆盖到的维度它就是盲的。我总结了三类“测不出来”但真实影响用户的问题焦点顺序问题。WCAG 2.4.3 要求焦点顺序要能维持含义和操作性但 axe 的规则只能检测“某些情况下焦点顺序错误”无法判断“逻辑上是否合理”。比如一个侧边栏在 DOM 里排在主内容后面视觉上却显示在左边Tab 走完主内容才到侧边栏——扫描器不会报但键盘用户会崩溃。焦点陷阱。弹窗打开后Tab 焦点应该被限制在弹窗内弹窗关闭后焦点必须归还给触发它的按钮。这两条在很多组件库里都没做对。我实测过一些知名 UI 库的弹窗实现有的能 trap 住焦点但关闭后焦点丢失到 body有的压根没 trap。这类问题必须靠手动键盘走查。屏幕阅读器的实际朗读顺序。多个标签拼接出来后NVDA 读出来的是“输入框 请输入密码 必填 密码”还是“密码 输入框 必填”这种顺序问题 axe 判断不了因为规则约束的是“结构上有标签”而不是“朗读时是否通顺”。所以我的结论是自动化的目标不是“替代人工走查”而是“把人工走查从繁琐的规则筛选中解放出来聚焦在语义、顺序和体验上”。5.3 手动键盘路径测试最后一道保险推荐一个我可以稳定复现的手动测试清单不需要辅助技术只需要一个键盘从页面顶部开始按 Tab 键依次走查所有可交互元素观察焦点环是否清晰可见如果发现某处outline: none吞掉了焦点环立即记录元素和优先级打开所有弹窗、下拉菜单检查焦点是否进入容器内继续按 Tab 是否会被困住按 Esc 关闭弹窗检查焦点是否回到刚才触发的按钮对下拉菜单、滑块这类组件试试方向键操作是否符合预期这套走查每次大概 15 分钟但我每次都能找到自动扫描发现不了的问题。我把它排进发布检查单作为 CI 之外的人工兜底。至于真正用屏幕阅读器NVDA、VoiceOver的深度走查每周做一次就够了重点覆盖用户任务最重的几个页面。6. 我在团队里推这套工具链的几点实际体会工具链本身不复杂复杂的是让团队真正用起来。我踩过几次坑之后有几个很深的体会。第一规则要“够得着”。如果团队第一次接入时发现满屏红灯没人会继续用。我一开始就把标准定成“serious 以上必须清零”并且先带着核心页面修完一轮再上 CI而不是把一堆历史债直接丢给开发。第二误报先别急着关规则。有一次对比度规则大量报警我差点把color-contrast全局屏蔽。后来仔细排查发现是一个公共组件里用了一个接近品牌色的浅色背景视觉上确实很难看清。那不是一个误报而是一个真实的设计缺陷。规则报警的时候先怀疑自己的实现再怀疑工具。第三把可访问性写进评审清单比任何工具都有效。工具链只能发现“已经写出来的代码”里的问题没法发现“设计时就没考虑键盘用户”的问题。后来我在前端评审模板里加了几条固定检查项这个弹窗打开时焦点在哪关闭时焦点去哪这个自定义下拉菜单支持方向键吗只要评审时多问了这几个问题后面少住的坑远比工具扫出来的多。最后分享一个小技巧每周挑一个核心页面我雷打不动地用 Tab 键从左上角走到右下角同时打开声音反馈听朗读顺序。这个动作只需要十几分钟但它发现的问题比任何自动化扫描都更接近真实用户每天经历的现场。工具链负责守住底线这条人工走查守住的是质感——两者缺一不可。