前端无障碍的自动化测试管道:axe-core 集成与 CI 门禁策略

📅 2026/7/17 16:55:59
前端无障碍的自动化测试管道:axe-core 集成与 CI 门禁策略
前端无障碍的自动化测试管道axe-core 集成与 CI 门禁策略一、这页面我用不了——一个来自视障用户的反馈客服转来一条用户反馈你们的注册页我用 VoiceOver 读了三遍都没找到获取验证码按钮。排查后发现按钮被装饰性的span包裹「获取验证码」四个字是 CSS::after伪元素写进去的。对屏幕阅读器而言这个按钮不存在。这不是态度问题是流程问题。团队里没人故意忽视无障碍但在功能优先的开发节奏下无障碍是第一个被妥协的。要解决不能靠每个工程师都记住 WCAG 规范而要靠自动化门禁让不通过无障碍检查的代码根本合不进来。二、无障碍自动化测试的分层架构三层递进静态检查最快毫秒级捕获代码层面的问题缺少 alt、缺少 label组件级检查捕获组件级别的无障碍缺陷ARIA 属性不完整页面级检查捕获页面级问题焦点流、色彩对比度。每一层都是前一层的补充不是替代。三、生产级实现// a11y-testing/setup-axe.ts // axe-core 测试工具配置 // 统一管理无障碍检查规则集支持按项目需求定制 import { configureAxe, checkA11y } from axe-playwright; import { test as base, expect } from playwright/test; import type { AxeResults, Result } from axe-core; /** * 无障碍检查配置 * * 设计意图不追求0 violation而是有选择地启用规则 * WCAG 2.1 AA 有 50 条规则全部启用会导致大量误报 * 这里选择 P0 规则严重影响可用性的问题作为门禁 * P1/P2 规则作为建议仅告警不阻断构建 */ /** P0 规则必须通过否则构建失败 */ const CRITICAL_RULES [ button-name, // 按钮必须有可访问名称 image-alt, // 图片必须有 alt 属性 label, // 表单控件必须有 label link-name, // 链接必须有可访问名称 color-contrast, // 色彩对比度必须达标 html-has-lang, // html 标签必须有 lang 属性 document-title, // 页面必须有 title frame-title, // iframe 必须有 title ]; /** P1 规则建议通过仅告警 */ const WARNING_RULES [ heading-order, // 标题层级应正确 landmark-one-main, // 应有 main landmark region, // 页面应有 region 标记 aria-roles, // ARIA role 应合法 valid-lang, // lang 属性应合法 ]; /** P2 规则可选仅记录 */ const SUGGESTION_RULES [ meta-viewport, // viewport meta 标签 skip-link, // 应有跳过导航链接 ]; /** * 过滤 axe 检查结果 * 只关心启用的规则忽略其他 */ function filterViolations( results: AxeResults, enabledRules: string[] ): Result[] { return results.violations.filter((v) enabledRules.includes(v.id)); } /** * 将 axe 违规转换为人类可读的检查报告 */ function formatViolationReport(violations: Result[]): string { if (violations.length 0) return 无障碍检查通过; const lines: string[] [ 发现 ${violations.length} 个无障碍问题, --- ]; for (const violation of violations) { lines.push(## ${violation.id}: ${violation.help}); lines.push(- 影响级别: ${violation.impact}); lines.push(- 参考: ${violation.helpUrl}); lines.push(- 影响元素 (${violation.nodes.length}):); for (const node of violation.nodes.slice(0, 3)) { // 最多展示 3 个 lines.push( - \${node.html}\); lines.push( 选择器: ${node.target.join( )}); // 提供修复建议 if (node.failureSummary) { lines.push( 修复: ${node.failureSummary.split(\n)[0]}); } } lines.push(); } return lines.join(\n); } // ---- Playwright 测试夹具 ---- /** * 扩展 Playwright 的 test fixture注入无障碍检查能力 */ export const test base.extend{ /** 页面级无障碍检查 */ checkPageA11y: () Promisevoid; /** 组件级无障碍检查指定选择器 */ checkA11y: (selector?: string) Promisevoid; }({ checkPageA11y: async ({ page }, use) { const checker async () { const results await checkA11y(page, undefined, { detailedReport: true, detailedReportOptions: { html: true }, }); const criticalViolations filterViolations( results, CRITICAL_RULES ); expect(criticalViolations, formatViolationReport(criticalViolations)) .toHaveLength(0); }; await use(checker); }, checkA11y: async ({ page }, use) { const checker async (selector?: string) { const results await checkA11y(page, selector, { detailedReport: true, detailedReportOptions: { html: true }, }); const criticalViolations filterViolations( results, CRITICAL_RULES ); // 组件级测试允许更多规则通过 const allViolations filterViolations(results, [ ...CRITICAL_RULES, ...WARNING_RULES ]); expect( criticalViolations, formatViolationReport(criticalViolations) ).toHaveLength(0); }; await use(checker); } });// a11y-testing/a11y.e2e.spec.ts // 页面级无障碍检查 E2E 测试用例 import { test } from ./setup-axe; test.describe(页面无障碍检查, () { test(首页无障碍检查, async ({ page, checkPageA11y }) { await page.goto(/); // 等待页面内容渲染完成 await page.waitForSelector([data-pageready]); await checkPageA11y(); }); test(登录页无障碍检查, async ({ page, checkPageA11y }) { await page.goto(/login); await page.waitForSelector(form); // 验证焦点管理打开页面时焦点应在第一个输入框 const focused await page.evaluate(() document.activeElement?.tagName); expect(focused).toBe(INPUT); await checkPageA11y(); }); test(表格页无障碍检查, async ({ page, checkPageA11y }) { await page.goto(/users); await page.waitForSelector(table); // 验证表格的 ARIA 标注 const tableRole await page.getAttribute(table, role); expect(tableRole).toBe(table); // 或 grid await checkPageA11y(); }); test(表单页无障碍检查, async ({ page, checkA11y }) { await page.goto(/form); // 仅检查表单区域缩小范围提高准确性 await checkA11y(form); // 验证错误状态的 ARIA 关联 await page.click(button[typesubmit]); // 等待验证错误出现 await page.waitForSelector([rolealert]); // 检查错误信息是否与对应输入框关联 const errorId await page.getAttribute([rolealert], id); const describedBy await page.getAttribute(input[nameemail], aria-describedby); expect(describedBy).toContain(errorId); }); });// .github/workflows/a11y-check.yml // CI 管道中的无障碍检查门禁 name: Accessibility Check on: [pull_request] jobs: a11y-static: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - uses: actions/setup-nodev4 - run: npm ci # 静态检查eslint-plugin-jsx-a11y - name: JSX A11y Lint run: npx eslint . --ext .tsx,.jsx --rule jsx-a11y/alt-text: error # HTMLHint 模板检查 - name: Template A11y Check run: npx htmlhint src/**/*.html --config .htmlhintrc-a11y a11y-component: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - uses: actions/setup-nodev4 - run: npm ci # 组件级 axe 测试 - name: Component A11y Tests run: npx playwright test --grep a11y # 保存测试报告 - uses: actions/upload-artifactv4 if: failure() with: name: a11y-report path: test-results/四、无障碍自动化的三个盲区盲区一键盘焦点流。axe 能检测按钮有没有 label但无法检测Tab 键按下后焦点是否跳到合理的位置。焦点管理需要专门编写 E2E 测试——模拟 Tab/ShiftTab 操作断言焦点落在预期的元素上。盲区二动态内容的无障碍通知。搜索提示、加载状态、错误提示——这些动态变化的区域需要通过aria-live或rolealert通知屏幕阅读器。axe 只能检测属性是否存在不能检测实际通知行为是否正确。盲区三色彩对比度检测需要上下文。axe 的color-contrast规则只能检测元素的color和background-colorcomputed value。但如果背景是渐变、图片或半透明叠加computed value 是不准确的。需要截图 像素级分析做补充。五、总结前端无障碍的自动化测试管道分三层静态层build 时eslint-plugin-jsx-a11y 扫描 JSX捕获缺失的 alt/label/role——零运行时成本组件层单元测试时axe-core Testing Library检查组件渲染后的 DOM 无障碍属性页面层E2E 时Playwright axe检查完整页面的无障碍合规性 焦点流验证不是追求0 violation而是确保 P0 规则button-name, image-alt, label, color-contrast100% 通过。六条规则的门禁能拦截 80% 的实际无障碍问题。