Files
2026-07-13 21:36:47 +08:00

1422 lines
53 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 无障碍测试
> **使用时机**:每个项目都适用。无障碍不是一项特性——而是质量基线。将自动化检查(axe-core)集成到每个测试套件中,并辅以关键流程的手动键盘/屏幕阅读器验证。
> **前置条件**[core/configuration.md](configuration.md)、[core/locators.md](locators.md)
## 快速参考
```typescript
// 安装: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 能自动检测大约 3040% 的 WCAG 问题。这 30–40% 包括最常见和最严重的违规:缺少 alt 文本、标签关联错误、无效的 ARIA 和对比度失败。自动捕获这些问题能让你将手动精力投入到更难的问题上。
**TypeScript**
```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**
```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**
```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**
```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**
```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**
```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**
```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**
```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**
```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**
```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**
```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**
```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**
```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**
```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**
```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**
```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**
```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**
```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**
```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" },
},
],
})
```
```typescript
// 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([])
})
}
```
```typescript
// 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 }
```
```typescript
// 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**
```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" },
},
],
})
```
```javascript
// 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 集成:**
```yaml
# .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 上为 VoiceOverCmd+F5Windows 上为 NVDA:免费下载)。听一遍朗读的内容,然后问:一个看不见屏幕的用户能理解该做什么吗?
### 看起来正常的元素出现 "color-contrast" 违规
**原因**:axe-core 根据实际背景计算对比度,这可能涉及重叠元素、渐变或背景图片。计算出的背景可能与你看到的不同。
**修复**
- 在 DevTools 中检查该元素:查看计算出的背景颜色,包括所有重叠元素。
- 对于图片/渐变上的文本,在文本后添加半透明背景。
- 如果 axe 报告了误报(极少见),用人工对比度检查工具验证,并使用带有书面理由的 `disableRules`
### 动态内容更改后焦点丢失
**原因**:当一个元素从 DOM 中移除时(关闭弹窗、删除列表项、SPA 导航),焦点回退到 `<body>`,使键盘用户无所适从。
**修复**
```typescript
// 关闭弹窗后,将焦点返回到触发器
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 无法自动确定的检查项。这些不是失败——是需要人工审查的项目。常见于复杂背景上的颜色对比度。
**修复**
```typescript
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 顺序测试间歇性失败
**原因**:焦点行为取决于页面是否完全加载并可交互。动画、懒加载内容或自动聚焦脚本可能会干扰。
**修复**
```typescript
// 在测试 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](locators.md) —— 基于角色的定位器与无障碍最佳实践保持一致
- [core/forms-and-validation.md](forms-and-validation.md) —— 表单交互模式,包括无障碍错误处理
- [ci/ci-github-actions.md](../ci/ci-github-actions.md) —— 用于运行无障碍测试的 CI 设置
- [core/i18n-and-localization.md](i18n-and-localization.md) —— 多语言应用的无障碍注意事项
- [core/component-testing.md](component-testing.md) —— 在隔离环境中测试单个组件的无障碍