对比度不足、缺 alt、缺 label:把 axe-core 挂进 Playwright,用 CI 门禁拦下 WCAG 违规

举报
霍格沃兹测试学社 发表于 2026/09/18 19:05:03 2026/09/18
【摘要】 本文详解如何将 axe-core 无障碍扫描集成到 Playwright 测试中,利用浏览器原生 accessibility tree 自动检测 WCAG 违规(如对比度不足、缺 alt/label、标题跳级等),并通过 CI 基线门禁拦截新增问题,让无障碍从“上线后被投诉”变为“合并前被拦截”。

打开 Playwright 官方文档,你会发现一个多数团队都用错的能力:它把无障碍树(accessibility tree)当作定位与断言的一等公民。getByRolegetByLabel 这些定位器,走的正是浏览器为辅助技术构建的那棵 a11y 树——这也是 Playwright MCP 选择用无障碍树而非像素做输入的原因。

可现实里,绝大多数团队只用这棵树来「点得准」:拿 getByRole('button') 定位一个按钮,点它,断言页面跳转了。没人意识到,同一棵 a11y 树,本身就是一份现成的无障碍合规数据源。 于是无障碍问题总是这么被发现——上线后,视障用户的读屏软件念不出图片含义、表单没有可朗读的 label、正文对比度低到弱视用户看不清,一纸投诉或监管点名,团队才连夜补救。

本篇讲一件事:怎么把 axe-core(Dequelabs 的开源无障碍规则引擎)挂进 Playwright 用例,自动扫出对比度不足、缺 alt、缺 label、标题层级跳级这些 WCAG 违规,再用 CI 把「违规数超基线」做成一道会拦合并的门禁。让无障碍从「上线后被投诉」变成「合并前就被拦下」。

一、a11y 树不只是用来定位的,它天生可断言

先给结论:你已经有的那棵无障碍树,缺的不是数据,而是把它当合规资产来断言的意识。

浏览器为每个页面构建的 accessibility tree,记录了每个元素的语义角色(role)、可访问名称(name)、状态。Playwright 用它做定位,是因为「一个能被读屏软件正确识别的按钮」本来就该是测试的稳定锚点。但反过来看:如果一个元素在 a11y 树里根本没有可访问名称、角色错乱、或者压根没进树,那它对辅助技术用户就是不可见的——这正是无障碍缺陷的本质。

axe-core 做的事,就是遍历这棵树,用一整套 WCAG 规则去检查每个节点:图片有没有 alt、表单控件有没有关联 label、标题层级有没有跳级(h1 直接跳到 h4)、文本对比度够不够。它返回一份结构化的 violations 列表。把这些 violations 断言成 Playwright 用例里的 expect,无障碍就从「人工走查的主观判断」变成了「机器可复现的红绿」。

二、把 axe-core 挂进 Playwright:注入、扫描、断言

官方提供的 @axe-core/playwright 让接入只需三行:拿到 page、注入 axe、scan。下面是一条可运行的用例:

// a11y.spec.ts —— 用 axe-core 给页面做无障碍合规扫描
// 依赖:npm i -D @playwright/test @axe-core/playwright
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test('首页不得存在 WCAG 违规', async ({ page }) => {
  await page.goto('https://staging.example.com/');

  const results = await new AxeBuilder({ page })
    .withTags(['wcag2a', 'wcag2aa'])   // 只跑 WCAG 2.1 A/AA 级规则(合规常用基线)
    .analyze();

  // 把 violations 断言成 expect:有任何违规,这条用例就红
  expect(
    results.violations.map(v => ({ id: v.id, impact: v.impact, nodes: v.nodes.length }))
  ).toEqual([]);
});

为什么这么写、踩过什么坑。 第一个坑是不加 withTags 全量扫描,结果一堆 impact: minor 的实验性规则也报红,团队被噪音淹没,最后干脆把用例注掉。用 withTags(['wcag2a', 'wcag2aa']) 锁定合规真正关心的 A/AA 级,信号才干净。第二个坑是直接 expect(results.violations).toEqual([])——一旦红,报错信息是一坨巨大的原始对象,根本看不出哪违规了;先 map{id, impact, nodes} 的精简结构再断言,红灯时一眼就知道「是 color-contrast 违规、影响 3 个节点」。第三个坑是拿生产域名扫,合规扫描应该在 staging 上跑,避免脏数据和真实用户会话互相干扰。

还有一个更隐蔽、专属于单页应用(SPA)的坑:扫描时机。page.goto 返回时,前端框架的异步渲染、懒加载的模块、接口回来后才填充的列表往往还没落到 DOM 上。这时立刻 analyze(),axe 扫的只是半个页面——首屏骨架合规、真正承载内容的区域还没渲染出来,于是漏扫,门禁假绿。正确的做法是先等页面「稳定」再扫:用 await page.waitForLoadState('networkidle') 等网络空闲,或者更稳妥地 await expect(page.getByRole('main')).toBeVisible() 显式等到关键内容区出现,再触发扫描。对含弹窗、下拉、折叠面板的交互态,还要在 click 展开之后单独扫一次——因为这些元素只有激活时才进 a11y 树,收起状态下的缺 label 问题是扫不出来的。把「扫描时机」当成和「断言内容」同等重要的事,才不会让一道本该拦住违规的门禁,因为扫早了而形同虚设。

三、三类最高频违规:axe-core 到底在替你盯什么

把 axe-core 接进来之前,最好先知道它最常报的是哪几类,这样红灯时你不会一头雾水。实际项目里,八成违规集中在三类。

第一类是对比度不足(color-contrast)。 浅灰字配白底、橙色按钮上压深橙文字,看着「高级」,弱视用户却根本分不清。axe 会算前景色与背景色的对比度比值,低于 WCAG AA 要求的阈值就报。这也是设计稿阶段最容易被忽略、上线后最容易被投诉的一类。

第二类是缺可访问名称(缺 alt / 缺 label)。 图片没写 alt,读屏软件就只能念文件名或者直接跳过;<input> 没有关联的 <label>,视障用户聚焦到输入框时听不到「这里该填什么」。这一类恰好和你用 getByLabel 定位表单是同一棵 a11y 树——如果 axe 报某个 input 缺 label,那你的 getByLabel 多半也定位不到它,无障碍缺陷和定位困难其实是同一个根因。

第三类是结构问题(标题层级跳级、landmark 缺失)。 h1 直接跳到 h4、页面没有 main/navigation 这些语义地标,读屏用户就没法靠标题快速跳转、只能一行行硬听。这类违规肉眼几乎看不出来(页面渲染完全正常),恰恰是纯人工走查最容易漏、而 axe 遍历 a11y 树一抓一个准的地方。

明白这三类,你就懂了 axe-core 的定位:它不是替你做审美判断,而是把「辅助技术用户会遇到什么障碍」翻译成了机器可枚举的规则。

四、CI 门禁:违规数超基线就 exit 1

单条用例能扫出违规,但真正让它「拦得住」的是 CI。无障碍回归的实用策略不是「零违规才算过」——存量老页面往往有一堆历史违规,一刀切会让门禁永远红、最后被绕过。可落地的做法是基线比对:把当前认可的违规数固化成基线,只对「新增违规」拦合并。

# .github/workflows/a11y.yml —— 无障碍回归门禁
name: a11y-regression
on: [pull_request]
jobs:
  axe:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npx playwright install --with-deps
      - name: Run axe-core a11y scan
        run: npx playwright test a11y.spec.ts --reporter=json > a11y-report.json
      - name: Fail on NEW violations over baseline
        run: node scripts/check-a11y-baseline.js   # 新增违规 > 基线 => exit 1
      - name: Upload a11y report as artifact
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: a11y-report
          path: a11y-report.json

配套的基线校验脚本,核心就是「本次违规数不得超过基线,超了就 exit 1」:

// scripts/check-a11y-baseline.js —— 只对新增违规拦合并
const fs = require('fs');
const BASELINE = Number(process.env.A11Y_BASELINE ?? 0);  // 基线违规数(本文示例,按存量实际设)
const report = JSON.parse(fs.readFileSync('a11y-report.json', 'utf8'));

// 从 playwright json 报告里汇总 axe violations 总数(结构按你的报告解析)
const violations = collectViolations(report);
console.log(`本次违规 ${violations.length} 条,基线 ${BASELINE}`);

if (violations.length > BASELINE) {
  console.error(`新增无障碍违规!超基线 ${violations.length - BASELINE} 条,拦下本次合并:`);
  violations.forEach(v => console.error(`  - [${v.impact}] ${v.id} (${v.nodes} 处)`));
  process.exit(1);   // 非 0 退出 => GitHub Actions 该 step 失败 => PR 被拦
}
console.log('无障碍回归通过:未超基线');

function collectViolations(report) {
  // 解析 playwright json,抽出每条 axe violation(示例实现,按实际报告字段调整)
  const out = [];
  for (const suite of report.suites ?? []) {
    for (const spec of suite.specs ?? []) {
      for (const test of spec.tests ?? []) {
        for (const res of test.results ?? []) {
          if (res.status !== 'passed' && res.attachments) {
            out.push(...(res.a11yViolations ?? []));
          }
        }
      }
    }
  }
  return out;
}

为什么用基线而不是一刀切零违规。 存量系统一上来就要求零违规,门禁会长期飘红,团队很快就会 --no-verify 绕过它,门禁形同虚设。基线策略的精髓是「冻结存量、拦截增量」:老违规记进基线慢慢还债,但任何一条新违规都过不了合并if: always() 保证即使门禁失败,a11y 报告 artifact 也照样上传——因为红灯时那份报告恰恰是修复最需要的证据,不能因为 step 失败就丢掉。基线数值本身随存量债务清理逐步下调,最终收敛到 0。

image.png

五、人工走查 vs axe-core 进 CI:五个维度看清差别

把两种做法放到五个维度上对照,为什么「别再等上线后被投诉」就很清楚了:

维度 靠人工走查 / 上线后被投诉 axe-core 进 Playwright + CI 回归
覆盖面 抽查几个页面,靠人眼和经验 每个 PR 全量扫 a11y 树,规则一致
可复现 主观判断,换个人结论就变 violations 结构化,同一页面同一结果
拦合并时机 上线后被投诉才补救 合并前门禁 exit 1,增量违规进不来
修复成本 线上事故级返工,牵连发版 PR 阶段就报出,改一行 alt 即可
合规可追溯 无记录,说不清何时达标 artifact 留存每轮报告,可审计

差别不在于 axe-core 有多先进,而在于它把无障碍从「上线后靠投诉驱动的被动补救」变成了「合并前由门禁驱动的主动拦截」——同一棵你早就在用的 a11y 树,只是终于被拿来当合规资产断言了。

六、落地建议:先挑一条核心链路跑成闭环

别一上来就要求全站零违规。先挑一条最核心的链路(比如登录或下单页),把 axe-core 扫描、violations 断言、CI 基线门禁跑通,形成第一版基线;跑顺了再逐页复制,基线随存量清理逐步下调。无障碍合规这件事,难的不是接入 axe-core,而是让每次变更都被同一套规则挡住——只要有一条链路先跑成闭环,团队就会开始信这道门禁,剩下的页面自然愿意接进来。

本篇不讨论视觉像素比对与截图基线矩阵,那是视觉回归的切面;我们只解决一个很窄的问题:怎么把无障碍违规做成一条会拦合并的回归流水线。

你用来定位按钮的那棵无障碍树,本就是现成的合规数据源——差的只是把它断言成一条会失败的用例。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。