53 KiB
无障碍测试
使用时机:每个项目都适用。无障碍不是一项特性——而是质量基线。将自动化检查(axe-core)集成到每个测试套件中,并辅以关键流程的手动键盘/屏幕阅读器验证。 前置条件:core/configuration.md、core/locators.md
快速参考
// 安装:npm install -D @axe-core/playwright
import AxeBuilder from "@axe-core/playwright"
// 全页面扫描
const results = await new AxeBuilder({ page }).analyze()
expect(results.violations).toEqual([])
// 限定范围扫描——仅主内容区域
const results = await new AxeBuilder({ page }).include("#main-content").analyze()
// 仅 WCAG AA
const results = await new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa"]).analyze()
// 迁移期间排除已知问题
const results = await new AxeBuilder({ page }).disableRules(["color-contrast"]).analyze()
模式
axe-core/playwright 集成
适用场景:你希望对任何页面或组件进行自动化 WCAG 违规检测。这是你的第一道防线,应在每个测试套件中运行。 避免场景:你需要验证主观的 UX 质量(阅读顺序、认知负荷、通俗语言)。axe-core 能捕获结构层面的违规,而非可用性问题。
axe-core 能自动检测大约 30–40% 的 WCAG 问题。这 30–40% 包括最常见和最严重的违规:缺少 alt 文本、标签关联错误、无效的 ARIA 和对比度失败。自动捕获这些问题能让你将手动精力投入到更难的问题上。
TypeScript
import { test, expect } from "@playwright/test"
import AxeBuilder from "@axe-core/playwright"
test.describe("accessibility", () => {
test("home page has no accessibility violations", async ({ page }) => {
await page.goto("/")
const results = await new AxeBuilder({ page }).analyze()
expect(results.violations).toEqual([])
})
test("dashboard has no accessibility violations after login", async ({ page }) => {
await page.goto("/login")
await page.getByLabel("Email").fill("user@example.com")
await page.getByLabel("Password").fill("password123")
await page.getByRole("button", { name: "Sign in" }).click()
await page.waitForURL("/dashboard")
// 页面完全可交互后进行扫描
const results = await new AxeBuilder({ page }).analyze()
expect(results.violations).toEqual([])
})
test("report violations with helpful details on failure", async ({ page }) => {
await page.goto("/products")
const results = await new AxeBuilder({ page }).analyze()
// 格式化违规信息以便输出可读的测试结果
const violationSummary = results.violations.map((v) => ({
rule: v.id,
impact: v.impact,
description: v.description,
nodes: v.nodes.length,
help: v.helpUrl,
}))
expect(results.violations, JSON.stringify(violationSummary, null, 2)).toEqual([])
})
})
JavaScript
const { test, expect } = require("@playwright/test")
const AxeBuilder = require("@axe-core/playwright").default
test.describe("accessibility", () => {
test("home page has no accessibility violations", async ({ page }) => {
await page.goto("/")
const results = await new AxeBuilder({ page }).analyze()
expect(results.violations).toEqual([])
})
test("report violations with helpful details on failure", async ({ page }) => {
await page.goto("/products")
const results = await new AxeBuilder({ page }).analyze()
const violationSummary = results.violations.map((v) => ({
rule: v.id,
impact: v.impact,
description: v.description,
nodes: v.nodes.length,
help: v.helpUrl,
}))
expect(results.violations, JSON.stringify(violationSummary, null, 2)).toEqual([])
})
})
扫描特定区域
适用场景:你希望将 axe-core 聚焦于特定组件(新功能、重新设计的区域),或排除你无法控制的部分(第三方小部件、广告、嵌入的 iframe)。 避免场景:你需要全页面的基线扫描。先扫描全部,再缩小范围。
TypeScript
import { test, expect } from "@playwright/test"
import AxeBuilder from "@axe-core/playwright"
test.describe("scoped accessibility scans", () => {
test("scan only the checkout form", async ({ page }) => {
await page.goto("/checkout")
const results = await new AxeBuilder({ page }).include("#checkout-form").analyze()
expect(results.violations).toEqual([])
})
test("scan page excluding third-party chat widget", async ({ page }) => {
await page.goto("/support")
const results = await new AxeBuilder({ page })
.exclude("#intercom-widget")
.exclude(".third-party-ads")
.analyze()
expect(results.violations).toEqual([])
})
test("scan multiple specific regions", async ({ page }) => {
await page.goto("/dashboard")
// 包含多个区域——每个独立扫描
const results = await new AxeBuilder({ page })
.include("#navigation")
.include("#main-content")
.include("#footer")
.exclude(".ad-banner")
.analyze()
expect(results.violations).toEqual([])
})
test("scan a modal after it opens", async ({ page }) => {
await page.goto("/settings")
await page.getByRole("button", { name: "Delete account" }).click()
// 等待弹窗完全渲染
await expect(page.getByRole("dialog", { name: "Confirm deletion" })).toBeVisible()
const results = await new AxeBuilder({ page }).include('[role="dialog"]').analyze()
expect(results.violations).toEqual([])
})
})
JavaScript
const { test, expect } = require("@playwright/test")
const AxeBuilder = require("@axe-core/playwright").default
test.describe("scoped accessibility scans", () => {
test("scan only the checkout form", async ({ page }) => {
await page.goto("/checkout")
const results = await new AxeBuilder({ page }).include("#checkout-form").analyze()
expect(results.violations).toEqual([])
})
test("scan page excluding third-party chat widget", async ({ page }) => {
await page.goto("/support")
const results = await new AxeBuilder({ page })
.exclude("#intercom-widget")
.exclude(".third-party-ads")
.analyze()
expect(results.violations).toEqual([])
})
test("scan a modal after it opens", async ({ page }) => {
await page.goto("/settings")
await page.getByRole("button", { name: "Delete account" }).click()
await expect(page.getByRole("dialog", { name: "Confirm deletion" })).toBeVisible()
const results = await new AxeBuilder({ page }).include('[role="dialog"]').analyze()
expect(results.violations).toEqual([])
})
})
WCAG 合规等级
适用场景:你的项目针对特定的 WCAG 合规等级(大多数针对 AA)。使用标签将 axe-core 限制为与你的合规要求相关的规则。
避免场景:你想要最广泛的扫描。省略 withTags() 会运行所有规则,包括超出 WCAG 范围的最佳实践。
标签参考:
wcag2a— WCAG 2.0 A 级(最低)wcag2aa— WCAG 2.0 AA 级(大多数组织的标准目标)wcag2aaa— WCAG 2.0 AAA 级(严格;很少要求)wcag21a、wcag21aa、wcag21aaa— WCAG 2.1 新增wcag22aa— WCAG 2.2 新增best-practice— 非 WCAG,但属于推荐模式
TypeScript
import { test, expect } from "@playwright/test"
import AxeBuilder from "@axe-core/playwright"
test.describe("WCAG compliance levels", () => {
test("meets WCAG 2.1 AA (standard compliance target)", async ({ page }) => {
await page.goto("/")
const results = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze()
expect(results.violations).toEqual([])
})
test("meets WCAG 2.2 AA (latest standard)", async ({ page }) => {
await page.goto("/")
const results = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"])
.analyze()
expect(results.violations).toEqual([])
})
test("meets WCAG AAA (strict — use for government or healthcare)", async ({ page }) => {
await page.goto("/")
const results = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag2aaa", "wcag21a", "wcag21aa", "wcag21aaa"])
.analyze()
expect(results.violations).toEqual([])
})
test("best practices beyond WCAG", async ({ page }) => {
await page.goto("/")
const results = await new AxeBuilder({ page }).withTags(["best-practice"]).analyze()
// 使用软断言——最佳实践是建议性的,非阻断性的
expect.soft(results.violations).toEqual([])
})
})
JavaScript
const { test, expect } = require("@playwright/test")
const AxeBuilder = require("@axe-core/playwright").default
test.describe("WCAG compliance levels", () => {
test("meets WCAG 2.1 AA (standard compliance target)", async ({ page }) => {
await page.goto("/")
const results = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze()
expect(results.violations).toEqual([])
})
test("meets WCAG 2.2 AA (latest standard)", async ({ page }) => {
await page.goto("/")
const results = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"])
.analyze()
expect(results.violations).toEqual([])
})
})
禁用特定规则
适用场景:渐进式地将遗留应用迁移至无障碍合规。你已经在追踪系统中记录了已知违规,并希望测试套件能捕获新的回归,而不会在现有的已知问题上失败。 避免场景:隐藏你并不打算修复的违规。每个禁用的规则都应有对应的追踪工单。
TypeScript
import { test, expect } from "@playwright/test"
import AxeBuilder from "@axe-core/playwright"
// 集中管理已知异常——使其可见且可追踪
const KNOWN_ISSUES = {
// JIRA-1234:遗留头部组件,计划在 Q2 重新设计
rules: ["color-contrast"],
// JIRA-1235:第三方日期选择器没有标签关联
selectors: ["#legacy-datepicker"],
}
test.describe("accessibility with known exceptions", () => {
test("no new violations (excluding tracked known issues)", async ({ page }) => {
await page.goto("/dashboard")
const results = await new AxeBuilder({ page })
.disableRules(KNOWN_ISSUES.rules)
.exclude(KNOWN_ISSUES.selectors[0])
.analyze()
expect(results.violations).toEqual([])
})
test("verify known issues still exist (remove when fixed)", async ({ page }) => {
await page.goto("/dashboard")
// 仅扫描已知问题以确认其仍然存在
// 当此测试失败(违规消失)时,移除该异常
const results = await new AxeBuilder({ page }).withRules(KNOWN_ISSUES.rules).analyze()
if (results.violations.length === 0) {
console.warn(
"Known accessibility issues appear to be fixed. " +
"Remove exceptions from KNOWN_ISSUES and close tracking tickets."
)
}
})
})
JavaScript
const { test, expect } = require("@playwright/test")
const AxeBuilder = require("@axe-core/playwright").default
const KNOWN_ISSUES = {
rules: ["color-contrast"],
selectors: ["#legacy-datepicker"],
}
test.describe("accessibility with known exceptions", () => {
test("no new violations (excluding tracked known issues)", async ({ page }) => {
await page.goto("/dashboard")
const results = await new AxeBuilder({ page })
.disableRules(KNOWN_ISSUES.rules)
.exclude(KNOWN_ISSUES.selectors[0])
.analyze()
expect(results.violations).toEqual([])
})
test("verify known issues still exist (remove when fixed)", async ({ page }) => {
await page.goto("/dashboard")
const results = await new AxeBuilder({ page }).withRules(KNOWN_ISSUES.rules).analyze()
if (results.violations.length === 0) {
console.warn(
"Known accessibility issues appear to be fixed. " +
"Remove exceptions from KNOWN_ISSUES and close tracking tickets."
)
}
})
})
键盘导航测试
适用场景:验证所有交互元素是否可通过键盘独立到达和操作。这对运动障碍用户和习惯无鼠标导航的高级用户至关重要。 避免场景:永远不要跳过此项。自动化工具无法完全验证键盘导航——这需要行为测试。
TypeScript
import { test, expect } from "@playwright/test"
test.describe("keyboard navigation", () => {
test("tab order follows logical reading order", async ({ page }) => {
await page.goto("/login")
// 依次 Tab 浏览交互元素并验证焦点顺序
await page.keyboard.press("Tab")
await expect(page.getByLabel("Email")).toBeFocused()
await page.keyboard.press("Tab")
await expect(page.getByLabel("Password")).toBeFocused()
await page.keyboard.press("Tab")
await expect(page.getByRole("link", { name: "Forgot password?" })).toBeFocused()
await page.keyboard.press("Tab")
await expect(page.getByRole("button", { name: "Sign in" })).toBeFocused()
// 验证 Enter 能激活聚焦的按钮
await page.getByLabel("Email").fill("user@example.com")
await page.getByLabel("Password").fill("password123")
await page.getByRole("button", { name: "Sign in" }).focus()
await page.keyboard.press("Enter")
await page.waitForURL("/dashboard")
})
test("skip navigation link moves focus to main content", async ({ page }) => {
await page.goto("/")
// 第一次 Tab 应落在跳转链接上(获得焦点前视觉隐藏)
await page.keyboard.press("Tab")
const skipLink = page.getByRole("link", { name: "Skip to main content" })
await expect(skipLink).toBeFocused()
// 激活跳转链接可将焦点移过导航
await page.keyboard.press("Enter")
await expect(page.locator("#main-content")).toBeFocused()
})
test("dropdown menu operates with keyboard", async ({ page }) => {
await page.goto("/dashboard")
const menuButton = page.getByRole("button", { name: "User menu" })
await menuButton.focus()
// 用 Enter 或 Space 打开菜单
await page.keyboard.press("Enter")
const menu = page.getByRole("menu")
await expect(menu).toBeVisible()
// 方向键浏览菜单项
await page.keyboard.press("ArrowDown")
await expect(page.getByRole("menuitem", { name: "Profile" })).toBeFocused()
await page.keyboard.press("ArrowDown")
await expect(page.getByRole("menuitem", { name: "Settings" })).toBeFocused()
// Escape 关闭菜单并将焦点返回给触发器
await page.keyboard.press("Escape")
await expect(menu).not.toBeVisible()
await expect(menuButton).toBeFocused()
})
test("keyboard shortcuts work correctly", async ({ page }) => {
await page.goto("/editor")
// Ctrl+S / Cmd+S 触发保存
const modifier = process.platform === "darwin" ? "Meta" : "Control"
const saveResponse = page.waitForResponse("**/api/save")
await page.keyboard.press(`${modifier}+s`)
await saveResponse
await expect(page.getByText("Saved")).toBeVisible()
})
test("no keyboard traps in form navigation", async ({ page }) => {
await page.goto("/complex-form")
// 依次 Tab 浏览每个字段——焦点不应卡住
const interactiveElements = page.locator(
'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
const count = await interactiveElements.count()
for (let i = 0; i < count; i++) {
await page.keyboard.press("Tab")
// 验证有元素获得焦点(焦点没有被困住或丢失)
const focused = page.locator(":focus")
await expect(focused).toBeAttached()
}
})
})
JavaScript
const { test, expect } = require("@playwright/test")
test.describe("keyboard navigation", () => {
test("tab order follows logical reading order", async ({ page }) => {
await page.goto("/login")
await page.keyboard.press("Tab")
await expect(page.getByLabel("Email")).toBeFocused()
await page.keyboard.press("Tab")
await expect(page.getByLabel("Password")).toBeFocused()
await page.keyboard.press("Tab")
await expect(page.getByRole("link", { name: "Forgot password?" })).toBeFocused()
await page.keyboard.press("Tab")
await expect(page.getByRole("button", { name: "Sign in" })).toBeFocused()
})
test("dropdown menu operates with keyboard", async ({ page }) => {
await page.goto("/dashboard")
const menuButton = page.getByRole("button", { name: "User menu" })
await menuButton.focus()
await page.keyboard.press("Enter")
const menu = page.getByRole("menu")
await expect(menu).toBeVisible()
await page.keyboard.press("ArrowDown")
await expect(page.getByRole("menuitem", { name: "Profile" })).toBeFocused()
await page.keyboard.press("Escape")
await expect(menu).not.toBeVisible()
await expect(menuButton).toBeFocused()
})
test("no keyboard traps in form navigation", async ({ page }) => {
await page.goto("/complex-form")
const interactiveElements = page.locator(
'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
const count = await interactiveElements.count()
for (let i = 0; i < count; i++) {
await page.keyboard.press("Tab")
const focused = page.locator(":focus")
await expect(focused).toBeAttached()
}
})
})
屏幕阅读器测试模式
适用场景:验证 ARIA 属性、实时区域和角色是否提供了正确的无障碍体验。你无法在 CI 中运行真实的屏幕阅读器,但可以验证屏幕阅读器所依赖的语义结构。 避免场景:你想测试实际的屏幕阅读器输出(请使用 NVDA/VoiceOver 进行手动测试)。
TypeScript
import { test, expect } from "@playwright/test"
test.describe("screen reader semantics", () => {
test("ARIA labels provide meaningful context", async ({ page }) => {
await page.goto("/dashboard")
// 导航地标必须有明确的标签
const mainNav = page.getByRole("navigation", { name: "Main" })
const footerNav = page.getByRole("navigation", { name: "Footer" })
await expect(mainNav).toBeVisible()
await expect(footerNav).toBeVisible()
// 区域应有可访问的名称
const mainRegion = page.getByRole("main")
await expect(mainRegion).toBeAttached()
// 带图标的按钮必须有可访问的名称
const closeButton = page.getByRole("button", { name: "Close" })
await expect(closeButton).toBeAttached()
// 图片必须有 alt 文本(getByRole('img') 仅匹配有可访问名称的元素)
const logo = page.getByRole("img", { name: "Company logo" })
await expect(logo).toBeVisible()
})
test("live regions announce dynamic content changes", async ({ page }) => {
await page.goto("/notifications")
// 在触发内容更新前验证实时区域存在
const statusRegion = page.locator('[aria-live="polite"]')
await expect(statusRegion).toBeAttached()
// 触发一个会更新实时区域的操作
await page.getByRole("button", { name: "Save" }).click()
// 验证实时区域收到了更新
await expect(statusRegion).toHaveText("Changes saved successfully")
})
test("alert live region announces errors immediately", async ({ page }) => {
await page.goto("/checkout")
// aria-live="assertive" 或 role="alert" 会中断屏幕阅读器
await page.getByRole("button", { name: "Place order" }).click()
const alert = page.getByRole("alert")
await expect(alert).toBeVisible()
await expect(alert).toHaveText("Payment method is required")
})
test("expandable sections announce their state", async ({ page }) => {
await page.goto("/faq")
const faqButton = page.getByRole("button", { name: "How do I reset my password?" })
// aria-expanded 应反映当前状态
await expect(faqButton).toHaveAttribute("aria-expanded", "false")
await faqButton.click()
await expect(faqButton).toHaveAttribute("aria-expanded", "true")
// 受控面板应可见
const panel = page.locator(`#${await faqButton.getAttribute("aria-controls")}`)
await expect(panel).toBeVisible()
})
test("page headings form a logical hierarchy", async ({ page }) => {
await page.goto("/about")
// 应该只有一个 h1
await expect(page.getByRole("heading", { level: 1 })).toHaveCount(1)
// 标题级别不应跳跃(h1 -> h3 跳过 h2 是违规)
const headings = page.getByRole("heading")
const count = await headings.count()
let previousLevel = 0
for (let i = 0; i < count; i++) {
const heading = headings.nth(i)
const tagName = await heading.evaluate((el) => el.tagName.toLowerCase())
const level = parseInt(tagName.replace("h", ""), 10)
// 级别可以升 1 或降到任意更低级别,但绝不能向前跳跃
if (level > previousLevel + 1 && previousLevel !== 0) {
throw new Error(
`Heading hierarchy skipped from h${previousLevel} to h${level}: "${await heading.textContent()}"`
)
}
previousLevel = level
}
})
test("table has proper headers and caption", async ({ page }) => {
await page.goto("/reports")
const table = page.getByRole("table", { name: "Monthly revenue" })
await expect(table).toBeVisible()
// 列标题
const columnHeaders = table.getByRole("columnheader")
await expect(columnHeaders).toHaveCount(4)
await expect(columnHeaders.first()).toHaveText("Month")
// 行标题(如适用)
const rowHeaders = table.getByRole("rowheader")
await expect(rowHeaders.first()).toHaveText("January")
})
})
JavaScript
const { test, expect } = require("@playwright/test")
test.describe("screen reader semantics", () => {
test("ARIA labels provide meaningful context", async ({ page }) => {
await page.goto("/dashboard")
const mainNav = page.getByRole("navigation", { name: "Main" })
const footerNav = page.getByRole("navigation", { name: "Footer" })
await expect(mainNav).toBeVisible()
await expect(footerNav).toBeVisible()
const closeButton = page.getByRole("button", { name: "Close" })
await expect(closeButton).toBeAttached()
const logo = page.getByRole("img", { name: "Company logo" })
await expect(logo).toBeVisible()
})
test("live regions announce dynamic content changes", async ({ page }) => {
await page.goto("/notifications")
const statusRegion = page.locator('[aria-live="polite"]')
await expect(statusRegion).toBeAttached()
await page.getByRole("button", { name: "Save" }).click()
await expect(statusRegion).toHaveText("Changes saved successfully")
})
test("expandable sections announce their state", async ({ page }) => {
await page.goto("/faq")
const faqButton = page.getByRole("button", { name: "How do I reset my password?" })
await expect(faqButton).toHaveAttribute("aria-expanded", "false")
await faqButton.click()
await expect(faqButton).toHaveAttribute("aria-expanded", "true")
})
})
颜色对比度验证
适用场景:确保文本和 UI 组件满足 WCAG 对比度要求。axe-core 会自动检查对比度,但对于动态主题、暗黑模式或品牌颜色变更,你可能需要显式检查。 避免场景:axe-core 内置的对比度规则已覆盖你的需求。仅对 axe-core 在单次扫描中无法观测的动态颜色变更添加显式检查。
TypeScript
import { test, expect } from "@playwright/test"
import AxeBuilder from "@axe-core/playwright"
test.describe("color contrast", () => {
test("light theme meets contrast requirements", async ({ page }) => {
await page.goto("/")
const results = await new AxeBuilder({ page }).withRules(["color-contrast"]).analyze()
expect(results.violations).toEqual([])
})
test("dark theme meets contrast requirements", async ({ page }) => {
await page.goto("/")
// 激活暗黑模式
await page.getByRole("button", { name: "Toggle dark mode" }).click()
// 等待主题切换完成
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark")
const results = await new AxeBuilder({ page }).withRules(["color-contrast"]).analyze()
expect(results.violations).toEqual([])
})
test("high contrast mode meets AAA contrast requirements", async ({ page }) => {
await page.goto("/settings/display")
await page.getByRole("checkbox", { name: "High contrast" }).check()
// AAA 要求普通文本 7:1,大文本 4.5:1
const results = await new AxeBuilder({ page })
.withTags(["wcag2aaa"])
.withRules(["color-contrast"])
.analyze()
expect(results.violations).toEqual([])
})
test("focus indicators are visible", async ({ page }) => {
await page.goto("/")
// Tab 到一个元素并验证焦点轮廓有足够的对比度
await page.keyboard.press("Tab")
const focusedElement = page.locator(":focus")
// 验证轮廓不是透明或零宽度的
const outline = await focusedElement.evaluate((el) => {
const styles = window.getComputedStyle(el)
return {
outlineStyle: styles.outlineStyle,
outlineWidth: styles.outlineWidth,
outlineColor: styles.outlineColor,
boxShadow: styles.boxShadow,
}
})
// 焦点必须可见——要么有轮廓,要么有 box-shadow
const hasVisibleFocus =
(outline.outlineStyle !== "none" && outline.outlineWidth !== "0px") ||
outline.boxShadow !== "none"
expect(hasVisibleFocus, "Focused element must have a visible focus indicator").toBe(true)
})
})
JavaScript
const { test, expect } = require("@playwright/test")
const AxeBuilder = require("@axe-core/playwright").default
test.describe("color contrast", () => {
test("light theme meets contrast requirements", async ({ page }) => {
await page.goto("/")
const results = await new AxeBuilder({ page }).withRules(["color-contrast"]).analyze()
expect(results.violations).toEqual([])
})
test("dark theme meets contrast requirements", async ({ page }) => {
await page.goto("/")
await page.getByRole("button", { name: "Toggle dark mode" }).click()
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark")
const results = await new AxeBuilder({ page }).withRules(["color-contrast"]).analyze()
expect(results.violations).toEqual([])
})
test("focus indicators are visible", async ({ page }) => {
await page.goto("/")
await page.keyboard.press("Tab")
const focusedElement = page.locator(":focus")
const outline = await focusedElement.evaluate((el) => {
const styles = window.getComputedStyle(el)
return {
outlineStyle: styles.outlineStyle,
outlineWidth: styles.outlineWidth,
boxShadow: styles.boxShadow,
}
})
const hasVisibleFocus =
(outline.outlineStyle !== "none" && outline.outlineWidth !== "0px") ||
outline.boxShadow !== "none"
expect(hasVisibleFocus, "Focused element must have a visible focus indicator").toBe(true)
})
})
焦点陷阱测试
适用场景:测试弹窗、对话框、下拉菜单、滑出面板以及任何必须在自身内部保持焦点的覆盖层,以防止用户意外与背景内容交互。 避免场景:组件不覆盖内容时(内联可展开部分不需要焦点陷阱)。
TypeScript
import { test, expect } from "@playwright/test"
test.describe("focus trap", () => {
test("modal traps focus within itself", async ({ page }) => {
await page.goto("/settings")
// 打开弹窗
await page.getByRole("button", { name: "Delete account" }).click()
const dialog = page.getByRole("dialog", { name: "Confirm deletion" })
await expect(dialog).toBeVisible()
// 焦点应自动移入对话框
const firstFocusable = dialog.getByRole("button", { name: "Cancel" })
await expect(firstFocusable).toBeFocused()
// Tab 应在对话框内循环
await page.keyboard.press("Tab")
await expect(dialog.getByRole("button", { name: "Delete" })).toBeFocused()
// 再次 Tab 应回到第一个可聚焦元素
await page.keyboard.press("Tab")
await expect(dialog.getByRole("button", { name: "Cancel" })).toBeFocused()
// Shift+Tab 应回到最后一个可聚焦元素
await page.keyboard.press("Shift+Tab")
await expect(dialog.getByRole("button", { name: "Delete" })).toBeFocused()
// Escape 关闭对话框
await page.keyboard.press("Escape")
await expect(dialog).not.toBeVisible()
// 焦点返回触发器元素
await expect(page.getByRole("button", { name: "Delete account" })).toBeFocused()
})
test("dropdown menu traps focus and returns it on close", async ({ page }) => {
await page.goto("/dashboard")
const trigger = page.getByRole("button", { name: "Actions" })
await trigger.click()
const menu = page.getByRole("menu")
await expect(menu).toBeVisible()
// 第一个菜单项获得焦点
await expect(page.getByRole("menuitem").first()).toBeFocused()
// ArrowDown 在项目间移动
await page.keyboard.press("ArrowDown")
await expect(page.getByRole("menuitem").nth(1)).toBeFocused()
// Escape 关闭并返回焦点
await page.keyboard.press("Escape")
await expect(menu).not.toBeVisible()
await expect(trigger).toBeFocused()
})
test("background content is inert when modal is open", async ({ page }) => {
await page.goto("/settings")
await page.getByRole("button", { name: "Delete account" }).click()
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
// 背景内容应有 aria-hidden="true" 或为 inert
const mainContent = page.locator("main")
const isHidden = await mainContent.evaluate((el) => {
return el.getAttribute("aria-hidden") === "true" || el.hasAttribute("inert")
})
expect(isHidden, "Background content must be hidden from assistive technology").toBe(true)
})
})
JavaScript
const { test, expect } = require("@playwright/test")
test.describe("focus trap", () => {
test("modal traps focus within itself", async ({ page }) => {
await page.goto("/settings")
await page.getByRole("button", { name: "Delete account" }).click()
const dialog = page.getByRole("dialog", { name: "Confirm deletion" })
await expect(dialog).toBeVisible()
const firstFocusable = dialog.getByRole("button", { name: "Cancel" })
await expect(firstFocusable).toBeFocused()
await page.keyboard.press("Tab")
await expect(dialog.getByRole("button", { name: "Delete" })).toBeFocused()
await page.keyboard.press("Tab")
await expect(dialog.getByRole("button", { name: "Cancel" })).toBeFocused()
await page.keyboard.press("Escape")
await expect(dialog).not.toBeVisible()
await expect(page.getByRole("button", { name: "Delete account" })).toBeFocused()
})
test("background content is inert when modal is open", async ({ page }) => {
await page.goto("/settings")
await page.getByRole("button", { name: "Delete account" }).click()
await expect(page.getByRole("dialog")).toBeVisible()
const mainContent = page.locator("main")
const isHidden = await mainContent.evaluate((el) => {
return el.getAttribute("aria-hidden") === "true" || el.hasAttribute("inert")
})
expect(isHidden, "Background content must be hidden from assistive technology").toBe(true)
})
})
无障碍表单
适用场景:测试表单是否可被辅助技术使用。每个表单字段必须有相关的标签,错误消息必须以编程方式关联,必填字段必须被朗读出来。 避免场景:任何表单都绝不要跳过此项。
TypeScript
import { test, expect } from "@playwright/test"
import AxeBuilder from "@axe-core/playwright"
test.describe("accessible forms", () => {
test("all form fields have associated labels", async ({ page }) => {
await page.goto("/register")
// 每个输入都应能通过 getByLabel 到达(证明标签关联存在)
await expect(page.getByLabel("First name")).toBeVisible()
await expect(page.getByLabel("Last name")).toBeVisible()
await expect(page.getByLabel("Email")).toBeVisible()
await expect(page.getByLabel("Password")).toBeVisible()
// 运行 axe 以捕获我们遗漏的任何问题
const results = await new AxeBuilder({ page })
.include("form")
.withRules(["label", "label-title-only"])
.analyze()
expect(results.violations).toEqual([])
})
test("required fields are announced to screen readers", async ({ page }) => {
await page.goto("/register")
// 必填字段必须有 aria-required="true" 或 required 属性
const emailField = page.getByLabel("Email")
const hasRequired = await emailField.evaluate((el) => {
return el.hasAttribute("required") || el.getAttribute("aria-required") === "true"
})
expect(hasRequired, "Email field must be marked as required").toBe(true)
})
test("error messages are linked to their fields via aria-describedby", async ({ page }) => {
await page.goto("/register")
// 提交空表单以触发验证
await page.getByRole("button", { name: "Create account" }).click()
// 错误消息应可见
const errorMessage = page.getByText("Email is required")
await expect(errorMessage).toBeVisible()
// 错误必须通过 aria-describedby 链接到字段
const emailField = page.getByLabel("Email")
const describedBy = await emailField.getAttribute("aria-describedby")
expect(describedBy).toBeTruthy()
// 错误消息的 id 与 aria-describedby 的值匹配
const errorId = await errorMessage.getAttribute("id")
expect(describedBy).toContain(errorId)
// 字段也应指示无效状态
await expect(emailField).toHaveAttribute("aria-invalid", "true")
})
test("form error summary is announced and links to fields", async ({ page }) => {
await page.goto("/register")
await page.getByRole("button", { name: "Create account" }).click()
// 错误摘要应以 role="alert" 出现以便即时朗读
const errorSummary = page.getByRole("alert")
await expect(errorSummary).toBeVisible()
await expect(errorSummary).toContainText("Please fix the following errors")
// 错误摘要中的链接应将焦点移到对应字段
await errorSummary.getByRole("link", { name: "Email is required" }).click()
await expect(page.getByLabel("Email")).toBeFocused()
})
test("autocomplete attributes are set for common fields", async ({ page }) => {
await page.goto("/checkout")
// autocomplete 帮助密码管理器和辅助技术填写表单
await expect(page.getByLabel("Full name")).toHaveAttribute("autocomplete", "name")
await expect(page.getByLabel("Email")).toHaveAttribute("autocomplete", "email")
await expect(page.getByLabel("Street address")).toHaveAttribute(
"autocomplete",
"street-address"
)
await expect(page.getByLabel("Postal code")).toHaveAttribute("autocomplete", "postal-code")
})
test("fieldsets group related fields with legends", async ({ page }) => {
await page.goto("/checkout")
// 相关字段应分组在带 legend 的 fieldset 中
const shippingGroup = page.getByRole("group", { name: "Shipping address" })
await expect(shippingGroup).toBeVisible()
await expect(shippingGroup.getByLabel("Street address")).toBeVisible()
const billingGroup = page.getByRole("group", { name: "Billing address" })
await expect(billingGroup).toBeVisible()
})
})
JavaScript
const { test, expect } = require("@playwright/test")
const AxeBuilder = require("@axe-core/playwright").default
test.describe("accessible forms", () => {
test("all form fields have associated labels", async ({ page }) => {
await page.goto("/register")
await expect(page.getByLabel("First name")).toBeVisible()
await expect(page.getByLabel("Last name")).toBeVisible()
await expect(page.getByLabel("Email")).toBeVisible()
await expect(page.getByLabel("Password")).toBeVisible()
const results = await new AxeBuilder({ page })
.include("form")
.withRules(["label", "label-title-only"])
.analyze()
expect(results.violations).toEqual([])
})
test("error messages are linked to their fields via aria-describedby", async ({ page }) => {
await page.goto("/register")
await page.getByRole("button", { name: "Create account" }).click()
const errorMessage = page.getByText("Email is required")
await expect(errorMessage).toBeVisible()
const emailField = page.getByLabel("Email")
const describedBy = await emailField.getAttribute("aria-describedby")
expect(describedBy).toBeTruthy()
const errorId = await errorMessage.getAttribute("id")
expect(describedBy).toContain(errorId)
await expect(emailField).toHaveAttribute("aria-invalid", "true")
})
test("autocomplete attributes are set for common fields", async ({ page }) => {
await page.goto("/checkout")
await expect(page.getByLabel("Full name")).toHaveAttribute("autocomplete", "name")
await expect(page.getByLabel("Email")).toHaveAttribute("autocomplete", "email")
await expect(page.getByLabel("Street address")).toHaveAttribute(
"autocomplete",
"street-address"
)
})
})
CI 中的无障碍测试
适用场景:你希望无障碍违规导致构建失败,防止回归问题进入生产环境。每个团队都应在 CI 流水线上设置无障碍门槛。 避免场景:绝不要。如果你只在本地运行无障碍检查,它们就会被跳过。
TypeScript
// playwright.config.ts — 专用的无障碍项目
import { defineConfig } from "@playwright/test"
export default defineConfig({
projects: [
{
name: "accessibility",
testMatch: "**/*.a11y.spec.ts",
use: {
browserName: "chromium", // axe-core 与 Chromium 配合最佳
},
},
{
name: "e2e-chromium",
testMatch: "**/*.spec.ts",
testIgnore: "**/*.a11y.spec.ts",
use: { browserName: "chromium" },
},
],
})
// tests/pages.a11y.spec.ts — 扫描所有关键页面
import { test, expect } from "@playwright/test"
import AxeBuilder from "@axe-core/playwright"
const PAGES_TO_SCAN = [
{ name: "Home", path: "/" },
{ name: "Login", path: "/login" },
{ name: "Register", path: "/register" },
{ name: "Dashboard", path: "/dashboard" },
{ name: "Products", path: "/products" },
{ name: "Checkout", path: "/checkout" },
{ name: "Contact", path: "/contact" },
]
for (const { name, path } of PAGES_TO_SCAN) {
test(`${name} page (${path}) has no WCAG AA violations`, async ({ page }) => {
await page.goto(path)
const results = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze()
// 将违规详情附加到测试报告
await test.info().attach("accessibility-scan-results", {
body: JSON.stringify(results.violations, null, 2),
contentType: "application/json",
})
expect(results.violations).toEqual([])
})
}
// tests/helpers/a11y-fixture.ts — 可复用的 axe-core 夹具
import { test as base, expect } from "@playwright/test"
import AxeBuilder from "@axe-core/playwright"
type A11yFixtures = {
makeAxeBuilder: () => AxeBuilder
}
export const test = base.extend<A11yFixtures>({
makeAxeBuilder: async ({ page }, use) => {
const makeAxeBuilder = () =>
new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
await use(makeAxeBuilder)
},
})
export { expect }
// tests/dashboard.a11y.spec.ts — 使用夹具
import { test, expect } from "./helpers/a11y-fixture"
test("dashboard has no violations after data loads", async ({ page, makeAxeBuilder }) => {
await page.goto("/dashboard")
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
const results = await makeAxeBuilder().analyze()
await test.info().attach("a11y-results", {
body: JSON.stringify(results.violations, null, 2),
contentType: "application/json",
})
expect(results.violations).toEqual([])
})
JavaScript
// playwright.config.js
const { defineConfig } = require("@playwright/test")
module.exports = defineConfig({
projects: [
{
name: "accessibility",
testMatch: "**/*.a11y.spec.js",
use: { browserName: "chromium" },
},
{
name: "e2e-chromium",
testMatch: "**/*.spec.js",
testIgnore: "**/*.a11y.spec.js",
use: { browserName: "chromium" },
},
],
})
// tests/pages.a11y.spec.js
const { test, expect } = require("@playwright/test")
const AxeBuilder = require("@axe-core/playwright").default
const PAGES_TO_SCAN = [
{ name: "Home", path: "/" },
{ name: "Login", path: "/login" },
{ name: "Dashboard", path: "/dashboard" },
{ name: "Products", path: "/products" },
]
for (const { name, path } of PAGES_TO_SCAN) {
test(`${name} page (${path}) has no WCAG AA violations`, async ({ page }) => {
await page.goto(path)
const results = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze()
await test.info().attach("accessibility-scan-results", {
body: JSON.stringify(results.violations, null, 2),
contentType: "application/json",
})
expect(results.violations).toEqual([])
})
}
GitHub Actions 集成:
# .github/workflows/accessibility.yml
name: Accessibility Tests
on: [push, pull_request]
jobs:
accessibility:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npx playwright install --with-deps chromium
- name: Run accessibility tests
run: npx playwright test --project=accessibility
- name: Upload accessibility report
if: always()
uses: actions/upload-artifact@v4
with:
name: accessibility-report
path: playwright-report/
retention-days: 30
决策指南
| 检查项目 | 自动化(axe-core) | 手动(键盘/屏幕阅读器) | 原因 |
|---|---|---|---|
| 缺少 alt 文本 | 是 | 否 | axe-core 能可靠检测 |
| 颜色对比度比例 | 是 | 否 | 从 CSS 自动计算 |
| 缺少表单标签 | 是 | 否 | 检测缺少的 <label> 和 aria 关联 |
| 无效的 ARIA 属性 | 是 | 否 | 根据 WAI-ARIA 规范验证 |
| 重复 ID | 是 | 否 | DOM 分析 |
| Tab 顺序 / 逻辑流程 | 否 | 是 | 需要理解页面布局和用户意图 |
| 弹窗中的焦点管理 | 部分(axe 检查 aria-hidden) |
是 | 焦点陷阱行为需要行为测试 |
| 屏幕阅读器 UX 质量 | 否 | 是 | 朗读是否有帮助是主观的 |
| 认知负荷 / 可读性 | 否 | 是 | 无法自动化——需要人工判断 |
| 触摸目标大小(移动端) | 是(WCAG 2.2) | 是 | axe 检查最小尺寸;真实设备体验需要手动测试 |
| 动态内容朗读 | 否 | 是 | 实时区域行为取决于时序和屏幕阅读器 |
| 键盘快捷键冲突 | 否 | 是 | 需要了解操作系统/浏览器/辅助技术快捷键 |
| 阅读顺序 vs 视觉顺序 | 否 | 是 | CSS 重排(flexbox order、grid)可能破坏此顺序 |
| 错误恢复流程 | 否 | 是 | 错误指导是否可理解需要人工判断 |
| 视频字幕 / 音频描述 | 部分(检查 <track>) |
是 | 字幕质量必须手动验证 |
经验法则:自动化所有 axe-core 能捕获的内容(在 CI 中对每个页面运行),然后将你的手动测试预算投入到最关键用户流程的键盘导航、焦点管理和屏幕阅读器朗读上。
反模式
| 不要这样做 | 问题 | 应该这样做 |
|---|---|---|
| 仅用 axe-core 测试 | 只能捕获约 30–40% 的 WCAG 问题。遗漏键盘导航、焦点管理、阅读顺序和 UX 质量。 | 将 axe-core 与键盘导航测试及定期的屏幕阅读器人工审计相结合 |
| 忽略键盘导航 | 约 8% 的用户依赖纯键盘导航。不可访问的键盘 UX 会阻挡真实用户。 | 对每个交互组件测试 Tab 顺序、Enter/Space 激活、Escape 关闭和方向键导航 |
| 跳过弹窗中的焦点管理 | 用户 Tab 到弹窗后的背景内容中,丢失上下文,或无法关闭对话框。 | 测试焦点陷阱、Escape 关闭以及焦点返回到触发器元素 |
| 仅测试一次无障碍 | 每次 PR 都可能引入回归。今天的通过审计不能代表下一个迭代。 | 在 CI 中每个构建都运行 axe-core;每季度安排一次手动审计 |
使用 tabindex > 0 |
覆盖自然 Tab 顺序,造成不可预测的导航。极难维护。 | 使用 tabindex="0" 使元素可聚焦,tabindex="-1" 用于程序化聚焦;永远不要使用正值 |
依赖 title 属性实现无障碍 |
屏幕阅读器对 title 的处理不一致——许多会忽略它。 |
使用 aria-label、aria-labelledby 或可见文本标签 |
给 <div> 添加 role="button" 但不支持键盘 |
创建了一个仅能用鼠标操作的"按钮"。屏幕阅读器会朗读为按钮,但 Enter/Space 无反应。 | 使用 <button> 元素。如果必须使用 <div>,则添加 tabindex="0"、role="button" 以及 Enter 和 Space 的 keydown 处理程序 |
用 display: none 隐藏内容却期望屏幕阅读器朗读 |
display: none 对所有用户隐藏内容,包括辅助技术。 |
使用 .sr-only / 视觉隐藏 CSS 模式,在视觉上隐藏的同时保持内容可访问 |
在非交互式 <div> 或 <span> 上使用 aria-label |
在没有角色(role)的元素上,aria-label 会被忽略。屏幕阅读器不会朗读它。 |
添加适当的角色(如 role="region")或使用 aria-labelledby 指向可见标题 |
| 仅测试快乐路径 | 遗漏了可能具有不同无障碍特性的错误状态、空状态和加载状态。 | 测试带有验证错误的表单、空搜索结果、加载骨架屏和超时状态 |
永久禁用 color-contrast 规则 |
低视力用户无法阅读你的内容。 | 修复对比度。如果是迁移中,用工单追踪异常并设定截止日期 |
故障排除
axe-core 报告无违规,但屏幕阅读器体验很差
原因:axe-core 测试的是 HTML/ARIA 结构的正确性,而非用户体验的质量。一个元素可以有技术上有效的 aria-label,但实际毫无帮助(如 "button"、"click here"、"x")。
修复:用真实的屏幕阅读器审计你最关键的流程(macOS 上为 VoiceOver:Cmd+F5;Windows 上为 NVDA:免费下载)。听一遍朗读的内容,然后问:一个看不见屏幕的用户能理解该做什么吗?
看起来正常的元素出现 "color-contrast" 违规
原因:axe-core 根据实际背景计算对比度,这可能涉及重叠元素、渐变或背景图片。计算出的背景可能与你看到的不同。
修复:
- 在 DevTools 中检查该元素:查看计算出的背景颜色,包括所有重叠元素。
- 对于图片/渐变上的文本,在文本后添加半透明背景。
- 如果 axe 报告了误报(极少见),用人工对比度检查工具验证,并使用带有书面理由的
disableRules。
动态内容更改后焦点丢失
原因:当一个元素从 DOM 中移除时(关闭弹窗、删除列表项、SPA 导航),焦点回退到 <body>,使键盘用户无所适从。
修复:
// 关闭弹窗后,将焦点返回到触发器
await page.getByRole("button", { name: "Close" }).click()
await expect(page.getByRole("button", { name: "Open modal" })).toBeFocused()
// 删除项目后,将焦点移到下一个项目或逻辑地标
await page.getByRole("button", { name: "Delete item 3" }).click()
await expect(page.getByRole("listitem").nth(2)).toBeFocused() // 下一个项目
axe-core 扫描返回不完整结果(非违规)
原因:results.incomplete 包含 axe 无法自动确定的检查项。这些不是失败——是需要人工审查的项目。常见于复杂背景上的颜色对比度。
修复:
const results = await new AxeBuilder({ page }).analyze()
// 记录需要人工审查的不完整检查
if (results.incomplete.length > 0) {
console.log(
"Needs manual review:",
results.incomplete.map((i) => i.id)
)
}
// 对于明确的违规,仍然失败
expect(results.violations).toEqual([])
Tab 顺序测试间歇性失败
原因:焦点行为取决于页面是否完全加载并可交互。动画、懒加载内容或自动聚焦脚本可能会干扰。
修复:
// 在测试 Tab 顺序前等待页面完全可交互
await page.goto("/login")
await expect(page.getByLabel("Email")).toBeVisible()
// 先点击 body,确保焦点从已知位置开始
await page.locator("body").click()
// 现在测试 Tab 顺序
await page.keyboard.press("Tab")
await expect(page.getByLabel("Email")).toBeFocused()
相关
- core/locators.md —— 基于角色的定位器与无障碍最佳实践保持一致
- core/forms-and-validation.md —— 表单交互模式,包括无障碍错误处理
- ci/ci-github-actions.md —— 用于运行无障碍测试的 CI 设置
- core/i18n-and-localization.md —— 多语言应用的无障碍注意事项
- core/component-testing.md —— 在隔离环境中测试单个组件的无障碍