# 无障碍测试 > **使用时机**:每个项目都适用。无障碍不是一项特性——而是质量基线。将自动化检查(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({ 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 自动计算 | | 缺少表单标签 | 是 | 否 | 检测缺少的 `