前端无障碍的自动化测试管道: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: () => Promise<void>;
/** 组件级无障碍检查(指定选择器) */
checkA11y: (selector?: string) => Promise<void>;
}>({
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-page="ready"]');
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[type="submit"]');
// 等待验证错误出现
await page.waitForSelector('[role="alert"]');
// 检查错误信息是否与对应输入框关联
const errorId = await page.getAttribute('[role="alert"]', 'id');
const describedBy = await page.getAttribute('input[name="email"]', '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/checkout@v4
– uses: actions/setup-node@v4
– 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/checkout@v4
– uses: actions/setup-node@v4
– run: npm ci
# 组件级 axe 测试
– name: Component A11y Tests
run: npx playwright test –grep "a11y"
# 保存测试报告
– uses: actions/upload-artifact@v4
if: failure()
with:
name: a11y-report
path: test-results/
四、无障碍自动化的三个盲区
盲区一:键盘焦点流。axe 能检测按钮有没有 label,但无法检测"Tab 键按下后,焦点是否跳到合理的位置"。焦点管理需要专门编写 E2E 测试——模拟 Tab/Shift+Tab 操作,断言焦点落在预期的元素上。
盲区二:动态内容的无障碍通知。搜索提示、加载状态、错误提示——这些动态变化的区域需要通过 aria-live 或 role="alert" 通知屏幕阅读器。axe 只能检测属性是否存在,不能检测"实际通知行为是否正确"。
盲区三:色彩对比度检测需要上下文。axe 的 color-contrast 规则只能检测元素的 color 和 background-color computed value。但如果背景是渐变、图片或半透明叠加,computed value 是不准确的。需要截图 + 像素级分析做补充。
五、总结
前端无障碍的自动化测试管道分三层:
不是追求"0 violation",而是确保 P0 规则(button-name, image-alt, label, color-contrast)100% 通过。六条规则的门禁,能拦截 80% 的实际无障碍问题。

