1422 lines
53 KiB
Markdown
1422 lines
53 KiB
Markdown
# 无障碍测试
|
||
|
||
> **使用时机**:每个项目都适用。无障碍不是一项特性——而是质量基线。将自动化检查(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 能自动检测大约 30–40% 的 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 上为 VoiceOver:Cmd+F5;Windows 上为 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) —— 在隔离环境中测试单个组件的无障碍
|