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

53 KiB
Raw Permalink Blame History

无障碍测试

使用时机:每个项目都适用。无障碍不是一项特性——而是质量基线。将自动化检查(axe-core)集成到每个测试套件中,并辅以关键流程的手动键盘/屏幕阅读器验证。 前置条件core/configuration.mdcore/locators.md

快速参考

// 安装:npm install -D @axe-core/playwright
import AxeBuilder from "@axe-core/playwright"

// 全页面扫描
const results = await new AxeBuilder({ page }).analyze()
expect(results.violations).toEqual([])

// 限定范围扫描——仅主内容区域
const results = await new AxeBuilder({ page }).include("#main-content").analyze()

// 仅 WCAG AA
const results = await new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa"]).analyze()

// 迁移期间排除已知问题
const results = await new AxeBuilder({ page }).disableRules(["color-contrast"]).analyze()

模式

axe-core/playwright 集成

适用场景:你希望对任何页面或组件进行自动化 WCAG 违规检测。这是你的第一道防线,应在每个测试套件中运行。 避免场景:你需要验证主观的 UX 质量(阅读顺序、认知负荷、通俗语言)。axe-core 能捕获结构层面的违规,而非可用性问题。

axe-core 能自动检测大约 3040% 的 WCAG 问题。这 30–40% 包括最常见和最严重的违规:缺少 alt 文本、标签关联错误、无效的 ARIA 和对比度失败。自动捕获这些问题能让你将手动精力投入到更难的问题上。

TypeScript

import { test, expect } from "@playwright/test"
import AxeBuilder from "@axe-core/playwright"

test.describe("accessibility", () => {
  test("home page has no accessibility violations", async ({ page }) => {
    await page.goto("/")

    const results = await new AxeBuilder({ page }).analyze()

    expect(results.violations).toEqual([])
  })

  test("dashboard has no accessibility violations after login", async ({ page }) => {
    await page.goto("/login")
    await page.getByLabel("Email").fill("user@example.com")
    await page.getByLabel("Password").fill("password123")
    await page.getByRole("button", { name: "Sign in" }).click()
    await page.waitForURL("/dashboard")

    // 页面完全可交互后进行扫描
    const results = await new AxeBuilder({ page }).analyze()

    expect(results.violations).toEqual([])
  })

  test("report violations with helpful details on failure", async ({ page }) => {
    await page.goto("/products")

    const results = await new AxeBuilder({ page }).analyze()

    // 格式化违规信息以便输出可读的测试结果
    const violationSummary = results.violations.map((v) => ({
      rule: v.id,
      impact: v.impact,
      description: v.description,
      nodes: v.nodes.length,
      help: v.helpUrl,
    }))

    expect(results.violations, JSON.stringify(violationSummary, null, 2)).toEqual([])
  })
})

JavaScript

const { test, expect } = require("@playwright/test")
const AxeBuilder = require("@axe-core/playwright").default

test.describe("accessibility", () => {
  test("home page has no accessibility violations", async ({ page }) => {
    await page.goto("/")

    const results = await new AxeBuilder({ page }).analyze()

    expect(results.violations).toEqual([])
  })

  test("report violations with helpful details on failure", async ({ page }) => {
    await page.goto("/products")

    const results = await new AxeBuilder({ page }).analyze()

    const violationSummary = results.violations.map((v) => ({
      rule: v.id,
      impact: v.impact,
      description: v.description,
      nodes: v.nodes.length,
      help: v.helpUrl,
    }))

    expect(results.violations, JSON.stringify(violationSummary, null, 2)).toEqual([])
  })
})

扫描特定区域

适用场景:你希望将 axe-core 聚焦于特定组件(新功能、重新设计的区域),或排除你无法控制的部分(第三方小部件、广告、嵌入的 iframe)。 避免场景:你需要全页面的基线扫描。先扫描全部,再缩小范围。

TypeScript

import { test, expect } from "@playwright/test"
import AxeBuilder from "@axe-core/playwright"

test.describe("scoped accessibility scans", () => {
  test("scan only the checkout form", async ({ page }) => {
    await page.goto("/checkout")

    const results = await new AxeBuilder({ page }).include("#checkout-form").analyze()

    expect(results.violations).toEqual([])
  })

  test("scan page excluding third-party chat widget", async ({ page }) => {
    await page.goto("/support")

    const results = await new AxeBuilder({ page })
      .exclude("#intercom-widget")
      .exclude(".third-party-ads")
      .analyze()

    expect(results.violations).toEqual([])
  })

  test("scan multiple specific regions", async ({ page }) => {
    await page.goto("/dashboard")

    // 包含多个区域——每个独立扫描
    const results = await new AxeBuilder({ page })
      .include("#navigation")
      .include("#main-content")
      .include("#footer")
      .exclude(".ad-banner")
      .analyze()

    expect(results.violations).toEqual([])
  })

  test("scan a modal after it opens", async ({ page }) => {
    await page.goto("/settings")
    await page.getByRole("button", { name: "Delete account" }).click()

    // 等待弹窗完全渲染
    await expect(page.getByRole("dialog", { name: "Confirm deletion" })).toBeVisible()

    const results = await new AxeBuilder({ page }).include('[role="dialog"]').analyze()

    expect(results.violations).toEqual([])
  })
})

JavaScript

const { test, expect } = require("@playwright/test")
const AxeBuilder = require("@axe-core/playwright").default

test.describe("scoped accessibility scans", () => {
  test("scan only the checkout form", async ({ page }) => {
    await page.goto("/checkout")

    const results = await new AxeBuilder({ page }).include("#checkout-form").analyze()

    expect(results.violations).toEqual([])
  })

  test("scan page excluding third-party chat widget", async ({ page }) => {
    await page.goto("/support")

    const results = await new AxeBuilder({ page })
      .exclude("#intercom-widget")
      .exclude(".third-party-ads")
      .analyze()

    expect(results.violations).toEqual([])
  })

  test("scan a modal after it opens", async ({ page }) => {
    await page.goto("/settings")
    await page.getByRole("button", { name: "Delete account" }).click()
    await expect(page.getByRole("dialog", { name: "Confirm deletion" })).toBeVisible()

    const results = await new AxeBuilder({ page }).include('[role="dialog"]').analyze()

    expect(results.violations).toEqual([])
  })
})

WCAG 合规等级

适用场景:你的项目针对特定的 WCAG 合规等级(大多数针对 AA)。使用标签将 axe-core 限制为与你的合规要求相关的规则。 避免场景:你想要最广泛的扫描。省略 withTags() 会运行所有规则,包括超出 WCAG 范围的最佳实践。

标签参考:

  • wcag2a — WCAG 2.0 A 级(最低)
  • wcag2aa — WCAG 2.0 AA 级(大多数组织的标准目标)
  • wcag2aaa — WCAG 2.0 AAA 级(严格;很少要求)
  • wcag21awcag21aawcag21aaa — WCAG 2.1 新增
  • wcag22aa — WCAG 2.2 新增
  • best-practice — 非 WCAG,但属于推荐模式

TypeScript

import { test, expect } from "@playwright/test"
import AxeBuilder from "@axe-core/playwright"

test.describe("WCAG compliance levels", () => {
  test("meets WCAG 2.1 AA (standard compliance target)", async ({ page }) => {
    await page.goto("/")

    const results = await new AxeBuilder({ page })
      .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
      .analyze()

    expect(results.violations).toEqual([])
  })

  test("meets WCAG 2.2 AA (latest standard)", async ({ page }) => {
    await page.goto("/")

    const results = await new AxeBuilder({ page })
      .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"])
      .analyze()

    expect(results.violations).toEqual([])
  })

  test("meets WCAG AAA (strict — use for government or healthcare)", async ({ page }) => {
    await page.goto("/")

    const results = await new AxeBuilder({ page })
      .withTags(["wcag2a", "wcag2aa", "wcag2aaa", "wcag21a", "wcag21aa", "wcag21aaa"])
      .analyze()

    expect(results.violations).toEqual([])
  })

  test("best practices beyond WCAG", async ({ page }) => {
    await page.goto("/")

    const results = await new AxeBuilder({ page }).withTags(["best-practice"]).analyze()

    // 使用软断言——最佳实践是建议性的,非阻断性的
    expect.soft(results.violations).toEqual([])
  })
})

JavaScript

const { test, expect } = require("@playwright/test")
const AxeBuilder = require("@axe-core/playwright").default

test.describe("WCAG compliance levels", () => {
  test("meets WCAG 2.1 AA (standard compliance target)", async ({ page }) => {
    await page.goto("/")

    const results = await new AxeBuilder({ page })
      .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
      .analyze()

    expect(results.violations).toEqual([])
  })

  test("meets WCAG 2.2 AA (latest standard)", async ({ page }) => {
    await page.goto("/")

    const results = await new AxeBuilder({ page })
      .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"])
      .analyze()

    expect(results.violations).toEqual([])
  })
})

禁用特定规则

适用场景:渐进式地将遗留应用迁移至无障碍合规。你已经在追踪系统中记录了已知违规,并希望测试套件能捕获新的回归,而不会在现有的已知问题上失败。 避免场景:隐藏你并不打算修复的违规。每个禁用的规则都应有对应的追踪工单。

TypeScript

import { test, expect } from "@playwright/test"
import AxeBuilder from "@axe-core/playwright"

// 集中管理已知异常——使其可见且可追踪
const KNOWN_ISSUES = {
  // JIRA-1234:遗留头部组件,计划在 Q2 重新设计
  rules: ["color-contrast"],
  // JIRA-1235:第三方日期选择器没有标签关联
  selectors: ["#legacy-datepicker"],
}

test.describe("accessibility with known exceptions", () => {
  test("no new violations (excluding tracked known issues)", async ({ page }) => {
    await page.goto("/dashboard")

    const results = await new AxeBuilder({ page })
      .disableRules(KNOWN_ISSUES.rules)
      .exclude(KNOWN_ISSUES.selectors[0])
      .analyze()

    expect(results.violations).toEqual([])
  })

  test("verify known issues still exist (remove when fixed)", async ({ page }) => {
    await page.goto("/dashboard")

    // 仅扫描已知问题以确认其仍然存在
    // 当此测试失败(违规消失)时,移除该异常
    const results = await new AxeBuilder({ page }).withRules(KNOWN_ISSUES.rules).analyze()

    if (results.violations.length === 0) {
      console.warn(
        "Known accessibility issues appear to be fixed. " +
          "Remove exceptions from KNOWN_ISSUES and close tracking tickets."
      )
    }
  })
})

JavaScript

const { test, expect } = require("@playwright/test")
const AxeBuilder = require("@axe-core/playwright").default

const KNOWN_ISSUES = {
  rules: ["color-contrast"],
  selectors: ["#legacy-datepicker"],
}

test.describe("accessibility with known exceptions", () => {
  test("no new violations (excluding tracked known issues)", async ({ page }) => {
    await page.goto("/dashboard")

    const results = await new AxeBuilder({ page })
      .disableRules(KNOWN_ISSUES.rules)
      .exclude(KNOWN_ISSUES.selectors[0])
      .analyze()

    expect(results.violations).toEqual([])
  })

  test("verify known issues still exist (remove when fixed)", async ({ page }) => {
    await page.goto("/dashboard")

    const results = await new AxeBuilder({ page }).withRules(KNOWN_ISSUES.rules).analyze()

    if (results.violations.length === 0) {
      console.warn(
        "Known accessibility issues appear to be fixed. " +
          "Remove exceptions from KNOWN_ISSUES and close tracking tickets."
      )
    }
  })
})

键盘导航测试

适用场景:验证所有交互元素是否可通过键盘独立到达和操作。这对运动障碍用户和习惯无鼠标导航的高级用户至关重要。 避免场景:永远不要跳过此项。自动化工具无法完全验证键盘导航——这需要行为测试。

TypeScript

import { test, expect } from "@playwright/test"

test.describe("keyboard navigation", () => {
  test("tab order follows logical reading order", async ({ page }) => {
    await page.goto("/login")

    // 依次 Tab 浏览交互元素并验证焦点顺序
    await page.keyboard.press("Tab")
    await expect(page.getByLabel("Email")).toBeFocused()

    await page.keyboard.press("Tab")
    await expect(page.getByLabel("Password")).toBeFocused()

    await page.keyboard.press("Tab")
    await expect(page.getByRole("link", { name: "Forgot password?" })).toBeFocused()

    await page.keyboard.press("Tab")
    await expect(page.getByRole("button", { name: "Sign in" })).toBeFocused()

    // 验证 Enter 能激活聚焦的按钮
    await page.getByLabel("Email").fill("user@example.com")
    await page.getByLabel("Password").fill("password123")
    await page.getByRole("button", { name: "Sign in" }).focus()
    await page.keyboard.press("Enter")
    await page.waitForURL("/dashboard")
  })

  test("skip navigation link moves focus to main content", async ({ page }) => {
    await page.goto("/")

    // 第一次 Tab 应落在跳转链接上(获得焦点前视觉隐藏)
    await page.keyboard.press("Tab")
    const skipLink = page.getByRole("link", { name: "Skip to main content" })
    await expect(skipLink).toBeFocused()

    // 激活跳转链接可将焦点移过导航
    await page.keyboard.press("Enter")
    await expect(page.locator("#main-content")).toBeFocused()
  })

  test("dropdown menu operates with keyboard", async ({ page }) => {
    await page.goto("/dashboard")

    const menuButton = page.getByRole("button", { name: "User menu" })
    await menuButton.focus()

    // 用 Enter 或 Space 打开菜单
    await page.keyboard.press("Enter")
    const menu = page.getByRole("menu")
    await expect(menu).toBeVisible()

    // 方向键浏览菜单项
    await page.keyboard.press("ArrowDown")
    await expect(page.getByRole("menuitem", { name: "Profile" })).toBeFocused()

    await page.keyboard.press("ArrowDown")
    await expect(page.getByRole("menuitem", { name: "Settings" })).toBeFocused()

    // Escape 关闭菜单并将焦点返回给触发器
    await page.keyboard.press("Escape")
    await expect(menu).not.toBeVisible()
    await expect(menuButton).toBeFocused()
  })

  test("keyboard shortcuts work correctly", async ({ page }) => {
    await page.goto("/editor")

    // Ctrl+S / Cmd+S 触发保存
    const modifier = process.platform === "darwin" ? "Meta" : "Control"
    const saveResponse = page.waitForResponse("**/api/save")
    await page.keyboard.press(`${modifier}+s`)
    await saveResponse

    await expect(page.getByText("Saved")).toBeVisible()
  })

  test("no keyboard traps in form navigation", async ({ page }) => {
    await page.goto("/complex-form")

    // 依次 Tab 浏览每个字段——焦点不应卡住
    const interactiveElements = page.locator(
      'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])'
    )
    const count = await interactiveElements.count()

    for (let i = 0; i < count; i++) {
      await page.keyboard.press("Tab")
      // 验证有元素获得焦点(焦点没有被困住或丢失)
      const focused = page.locator(":focus")
      await expect(focused).toBeAttached()
    }
  })
})

JavaScript

const { test, expect } = require("@playwright/test")

test.describe("keyboard navigation", () => {
  test("tab order follows logical reading order", async ({ page }) => {
    await page.goto("/login")

    await page.keyboard.press("Tab")
    await expect(page.getByLabel("Email")).toBeFocused()

    await page.keyboard.press("Tab")
    await expect(page.getByLabel("Password")).toBeFocused()

    await page.keyboard.press("Tab")
    await expect(page.getByRole("link", { name: "Forgot password?" })).toBeFocused()

    await page.keyboard.press("Tab")
    await expect(page.getByRole("button", { name: "Sign in" })).toBeFocused()
  })

  test("dropdown menu operates with keyboard", async ({ page }) => {
    await page.goto("/dashboard")

    const menuButton = page.getByRole("button", { name: "User menu" })
    await menuButton.focus()

    await page.keyboard.press("Enter")
    const menu = page.getByRole("menu")
    await expect(menu).toBeVisible()

    await page.keyboard.press("ArrowDown")
    await expect(page.getByRole("menuitem", { name: "Profile" })).toBeFocused()

    await page.keyboard.press("Escape")
    await expect(menu).not.toBeVisible()
    await expect(menuButton).toBeFocused()
  })

  test("no keyboard traps in form navigation", async ({ page }) => {
    await page.goto("/complex-form")

    const interactiveElements = page.locator(
      'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])'
    )
    const count = await interactiveElements.count()

    for (let i = 0; i < count; i++) {
      await page.keyboard.press("Tab")
      const focused = page.locator(":focus")
      await expect(focused).toBeAttached()
    }
  })
})

屏幕阅读器测试模式

适用场景:验证 ARIA 属性、实时区域和角色是否提供了正确的无障碍体验。你无法在 CI 中运行真实的屏幕阅读器,但可以验证屏幕阅读器所依赖的语义结构。 避免场景:你想测试实际的屏幕阅读器输出(请使用 NVDA/VoiceOver 进行手动测试)。

TypeScript

import { test, expect } from "@playwright/test"

test.describe("screen reader semantics", () => {
  test("ARIA labels provide meaningful context", async ({ page }) => {
    await page.goto("/dashboard")

    // 导航地标必须有明确的标签
    const mainNav = page.getByRole("navigation", { name: "Main" })
    const footerNav = page.getByRole("navigation", { name: "Footer" })
    await expect(mainNav).toBeVisible()
    await expect(footerNav).toBeVisible()

    // 区域应有可访问的名称
    const mainRegion = page.getByRole("main")
    await expect(mainRegion).toBeAttached()

    // 带图标的按钮必须有可访问的名称
    const closeButton = page.getByRole("button", { name: "Close" })
    await expect(closeButton).toBeAttached()

    // 图片必须有 alt 文本(getByRole('img') 仅匹配有可访问名称的元素)
    const logo = page.getByRole("img", { name: "Company logo" })
    await expect(logo).toBeVisible()
  })

  test("live regions announce dynamic content changes", async ({ page }) => {
    await page.goto("/notifications")

    // 在触发内容更新前验证实时区域存在
    const statusRegion = page.locator('[aria-live="polite"]')
    await expect(statusRegion).toBeAttached()

    // 触发一个会更新实时区域的操作
    await page.getByRole("button", { name: "Save" }).click()

    // 验证实时区域收到了更新
    await expect(statusRegion).toHaveText("Changes saved successfully")
  })

  test("alert live region announces errors immediately", async ({ page }) => {
    await page.goto("/checkout")

    // aria-live="assertive" 或 role="alert" 会中断屏幕阅读器
    await page.getByRole("button", { name: "Place order" }).click()

    const alert = page.getByRole("alert")
    await expect(alert).toBeVisible()
    await expect(alert).toHaveText("Payment method is required")
  })

  test("expandable sections announce their state", async ({ page }) => {
    await page.goto("/faq")

    const faqButton = page.getByRole("button", { name: "How do I reset my password?" })

    // aria-expanded 应反映当前状态
    await expect(faqButton).toHaveAttribute("aria-expanded", "false")

    await faqButton.click()
    await expect(faqButton).toHaveAttribute("aria-expanded", "true")

    // 受控面板应可见
    const panel = page.locator(`#${await faqButton.getAttribute("aria-controls")}`)
    await expect(panel).toBeVisible()
  })

  test("page headings form a logical hierarchy", async ({ page }) => {
    await page.goto("/about")

    // 应该只有一个 h1
    await expect(page.getByRole("heading", { level: 1 })).toHaveCount(1)

    // 标题级别不应跳跃(h1 -> h3 跳过 h2 是违规)
    const headings = page.getByRole("heading")
    const count = await headings.count()
    let previousLevel = 0

    for (let i = 0; i < count; i++) {
      const heading = headings.nth(i)
      const tagName = await heading.evaluate((el) => el.tagName.toLowerCase())
      const level = parseInt(tagName.replace("h", ""), 10)

      // 级别可以升 1 或降到任意更低级别,但绝不能向前跳跃
      if (level > previousLevel + 1 && previousLevel !== 0) {
        throw new Error(
          `Heading hierarchy skipped from h${previousLevel} to h${level}: "${await heading.textContent()}"`
        )
      }
      previousLevel = level
    }
  })

  test("table has proper headers and caption", async ({ page }) => {
    await page.goto("/reports")

    const table = page.getByRole("table", { name: "Monthly revenue" })
    await expect(table).toBeVisible()

    // 列标题
    const columnHeaders = table.getByRole("columnheader")
    await expect(columnHeaders).toHaveCount(4)
    await expect(columnHeaders.first()).toHaveText("Month")

    // 行标题(如适用)
    const rowHeaders = table.getByRole("rowheader")
    await expect(rowHeaders.first()).toHaveText("January")
  })
})

JavaScript

const { test, expect } = require("@playwright/test")

test.describe("screen reader semantics", () => {
  test("ARIA labels provide meaningful context", async ({ page }) => {
    await page.goto("/dashboard")

    const mainNav = page.getByRole("navigation", { name: "Main" })
    const footerNav = page.getByRole("navigation", { name: "Footer" })
    await expect(mainNav).toBeVisible()
    await expect(footerNav).toBeVisible()

    const closeButton = page.getByRole("button", { name: "Close" })
    await expect(closeButton).toBeAttached()

    const logo = page.getByRole("img", { name: "Company logo" })
    await expect(logo).toBeVisible()
  })

  test("live regions announce dynamic content changes", async ({ page }) => {
    await page.goto("/notifications")

    const statusRegion = page.locator('[aria-live="polite"]')
    await expect(statusRegion).toBeAttached()

    await page.getByRole("button", { name: "Save" }).click()
    await expect(statusRegion).toHaveText("Changes saved successfully")
  })

  test("expandable sections announce their state", async ({ page }) => {
    await page.goto("/faq")

    const faqButton = page.getByRole("button", { name: "How do I reset my password?" })
    await expect(faqButton).toHaveAttribute("aria-expanded", "false")

    await faqButton.click()
    await expect(faqButton).toHaveAttribute("aria-expanded", "true")
  })
})

颜色对比度验证

适用场景:确保文本和 UI 组件满足 WCAG 对比度要求。axe-core 会自动检查对比度,但对于动态主题、暗黑模式或品牌颜色变更,你可能需要显式检查。 避免场景:axe-core 内置的对比度规则已覆盖你的需求。仅对 axe-core 在单次扫描中无法观测的动态颜色变更添加显式检查。

TypeScript

import { test, expect } from "@playwright/test"
import AxeBuilder from "@axe-core/playwright"

test.describe("color contrast", () => {
  test("light theme meets contrast requirements", async ({ page }) => {
    await page.goto("/")

    const results = await new AxeBuilder({ page }).withRules(["color-contrast"]).analyze()

    expect(results.violations).toEqual([])
  })

  test("dark theme meets contrast requirements", async ({ page }) => {
    await page.goto("/")

    // 激活暗黑模式
    await page.getByRole("button", { name: "Toggle dark mode" }).click()

    // 等待主题切换完成
    await expect(page.locator("html")).toHaveAttribute("data-theme", "dark")

    const results = await new AxeBuilder({ page }).withRules(["color-contrast"]).analyze()

    expect(results.violations).toEqual([])
  })

  test("high contrast mode meets AAA contrast requirements", async ({ page }) => {
    await page.goto("/settings/display")
    await page.getByRole("checkbox", { name: "High contrast" }).check()

    // AAA 要求普通文本 7:1,大文本 4.5:1
    const results = await new AxeBuilder({ page })
      .withTags(["wcag2aaa"])
      .withRules(["color-contrast"])
      .analyze()

    expect(results.violations).toEqual([])
  })

  test("focus indicators are visible", async ({ page }) => {
    await page.goto("/")

    // Tab 到一个元素并验证焦点轮廓有足够的对比度
    await page.keyboard.press("Tab")
    const focusedElement = page.locator(":focus")

    // 验证轮廓不是透明或零宽度的
    const outline = await focusedElement.evaluate((el) => {
      const styles = window.getComputedStyle(el)
      return {
        outlineStyle: styles.outlineStyle,
        outlineWidth: styles.outlineWidth,
        outlineColor: styles.outlineColor,
        boxShadow: styles.boxShadow,
      }
    })

    // 焦点必须可见——要么有轮廓,要么有 box-shadow
    const hasVisibleFocus =
      (outline.outlineStyle !== "none" && outline.outlineWidth !== "0px") ||
      outline.boxShadow !== "none"

    expect(hasVisibleFocus, "Focused element must have a visible focus indicator").toBe(true)
  })
})

JavaScript

const { test, expect } = require("@playwright/test")
const AxeBuilder = require("@axe-core/playwright").default

test.describe("color contrast", () => {
  test("light theme meets contrast requirements", async ({ page }) => {
    await page.goto("/")

    const results = await new AxeBuilder({ page }).withRules(["color-contrast"]).analyze()

    expect(results.violations).toEqual([])
  })

  test("dark theme meets contrast requirements", async ({ page }) => {
    await page.goto("/")
    await page.getByRole("button", { name: "Toggle dark mode" }).click()
    await expect(page.locator("html")).toHaveAttribute("data-theme", "dark")

    const results = await new AxeBuilder({ page }).withRules(["color-contrast"]).analyze()

    expect(results.violations).toEqual([])
  })

  test("focus indicators are visible", async ({ page }) => {
    await page.goto("/")
    await page.keyboard.press("Tab")
    const focusedElement = page.locator(":focus")

    const outline = await focusedElement.evaluate((el) => {
      const styles = window.getComputedStyle(el)
      return {
        outlineStyle: styles.outlineStyle,
        outlineWidth: styles.outlineWidth,
        boxShadow: styles.boxShadow,
      }
    })

    const hasVisibleFocus =
      (outline.outlineStyle !== "none" && outline.outlineWidth !== "0px") ||
      outline.boxShadow !== "none"

    expect(hasVisibleFocus, "Focused element must have a visible focus indicator").toBe(true)
  })
})

焦点陷阱测试

适用场景:测试弹窗、对话框、下拉菜单、滑出面板以及任何必须在自身内部保持焦点的覆盖层,以防止用户意外与背景内容交互。 避免场景:组件不覆盖内容时(内联可展开部分不需要焦点陷阱)。

TypeScript

import { test, expect } from "@playwright/test"

test.describe("focus trap", () => {
  test("modal traps focus within itself", async ({ page }) => {
    await page.goto("/settings")

    // 打开弹窗
    await page.getByRole("button", { name: "Delete account" }).click()
    const dialog = page.getByRole("dialog", { name: "Confirm deletion" })
    await expect(dialog).toBeVisible()

    // 焦点应自动移入对话框
    const firstFocusable = dialog.getByRole("button", { name: "Cancel" })
    await expect(firstFocusable).toBeFocused()

    // Tab 应在对话框内循环
    await page.keyboard.press("Tab")
    await expect(dialog.getByRole("button", { name: "Delete" })).toBeFocused()

    // 再次 Tab 应回到第一个可聚焦元素
    await page.keyboard.press("Tab")
    await expect(dialog.getByRole("button", { name: "Cancel" })).toBeFocused()

    // Shift+Tab 应回到最后一个可聚焦元素
    await page.keyboard.press("Shift+Tab")
    await expect(dialog.getByRole("button", { name: "Delete" })).toBeFocused()

    // Escape 关闭对话框
    await page.keyboard.press("Escape")
    await expect(dialog).not.toBeVisible()

    // 焦点返回触发器元素
    await expect(page.getByRole("button", { name: "Delete account" })).toBeFocused()
  })

  test("dropdown menu traps focus and returns it on close", async ({ page }) => {
    await page.goto("/dashboard")

    const trigger = page.getByRole("button", { name: "Actions" })
    await trigger.click()

    const menu = page.getByRole("menu")
    await expect(menu).toBeVisible()

    // 第一个菜单项获得焦点
    await expect(page.getByRole("menuitem").first()).toBeFocused()

    // ArrowDown 在项目间移动
    await page.keyboard.press("ArrowDown")
    await expect(page.getByRole("menuitem").nth(1)).toBeFocused()

    // Escape 关闭并返回焦点
    await page.keyboard.press("Escape")
    await expect(menu).not.toBeVisible()
    await expect(trigger).toBeFocused()
  })

  test("background content is inert when modal is open", async ({ page }) => {
    await page.goto("/settings")
    await page.getByRole("button", { name: "Delete account" }).click()

    const dialog = page.getByRole("dialog")
    await expect(dialog).toBeVisible()

    // 背景内容应有 aria-hidden="true" 或为 inert
    const mainContent = page.locator("main")
    const isHidden = await mainContent.evaluate((el) => {
      return el.getAttribute("aria-hidden") === "true" || el.hasAttribute("inert")
    })

    expect(isHidden, "Background content must be hidden from assistive technology").toBe(true)
  })
})

JavaScript

const { test, expect } = require("@playwright/test")

test.describe("focus trap", () => {
  test("modal traps focus within itself", async ({ page }) => {
    await page.goto("/settings")
    await page.getByRole("button", { name: "Delete account" }).click()

    const dialog = page.getByRole("dialog", { name: "Confirm deletion" })
    await expect(dialog).toBeVisible()

    const firstFocusable = dialog.getByRole("button", { name: "Cancel" })
    await expect(firstFocusable).toBeFocused()

    await page.keyboard.press("Tab")
    await expect(dialog.getByRole("button", { name: "Delete" })).toBeFocused()

    await page.keyboard.press("Tab")
    await expect(dialog.getByRole("button", { name: "Cancel" })).toBeFocused()

    await page.keyboard.press("Escape")
    await expect(dialog).not.toBeVisible()
    await expect(page.getByRole("button", { name: "Delete account" })).toBeFocused()
  })

  test("background content is inert when modal is open", async ({ page }) => {
    await page.goto("/settings")
    await page.getByRole("button", { name: "Delete account" }).click()
    await expect(page.getByRole("dialog")).toBeVisible()

    const mainContent = page.locator("main")
    const isHidden = await mainContent.evaluate((el) => {
      return el.getAttribute("aria-hidden") === "true" || el.hasAttribute("inert")
    })

    expect(isHidden, "Background content must be hidden from assistive technology").toBe(true)
  })
})

无障碍表单

适用场景:测试表单是否可被辅助技术使用。每个表单字段必须有相关的标签,错误消息必须以编程方式关联,必填字段必须被朗读出来。 避免场景:任何表单都绝不要跳过此项。

TypeScript

import { test, expect } from "@playwright/test"
import AxeBuilder from "@axe-core/playwright"

test.describe("accessible forms", () => {
  test("all form fields have associated labels", async ({ page }) => {
    await page.goto("/register")

    // 每个输入都应能通过 getByLabel 到达(证明标签关联存在)
    await expect(page.getByLabel("First name")).toBeVisible()
    await expect(page.getByLabel("Last name")).toBeVisible()
    await expect(page.getByLabel("Email")).toBeVisible()
    await expect(page.getByLabel("Password")).toBeVisible()

    // 运行 axe 以捕获我们遗漏的任何问题
    const results = await new AxeBuilder({ page })
      .include("form")
      .withRules(["label", "label-title-only"])
      .analyze()

    expect(results.violations).toEqual([])
  })

  test("required fields are announced to screen readers", async ({ page }) => {
    await page.goto("/register")

    // 必填字段必须有 aria-required="true" 或 required 属性
    const emailField = page.getByLabel("Email")
    const hasRequired = await emailField.evaluate((el) => {
      return el.hasAttribute("required") || el.getAttribute("aria-required") === "true"
    })

    expect(hasRequired, "Email field must be marked as required").toBe(true)
  })

  test("error messages are linked to their fields via aria-describedby", async ({ page }) => {
    await page.goto("/register")

    // 提交空表单以触发验证
    await page.getByRole("button", { name: "Create account" }).click()

    // 错误消息应可见
    const errorMessage = page.getByText("Email is required")
    await expect(errorMessage).toBeVisible()

    // 错误必须通过 aria-describedby 链接到字段
    const emailField = page.getByLabel("Email")
    const describedBy = await emailField.getAttribute("aria-describedby")
    expect(describedBy).toBeTruthy()

    // 错误消息的 id 与 aria-describedby 的值匹配
    const errorId = await errorMessage.getAttribute("id")
    expect(describedBy).toContain(errorId)

    // 字段也应指示无效状态
    await expect(emailField).toHaveAttribute("aria-invalid", "true")
  })

  test("form error summary is announced and links to fields", async ({ page }) => {
    await page.goto("/register")
    await page.getByRole("button", { name: "Create account" }).click()

    // 错误摘要应以 role="alert" 出现以便即时朗读
    const errorSummary = page.getByRole("alert")
    await expect(errorSummary).toBeVisible()
    await expect(errorSummary).toContainText("Please fix the following errors")

    // 错误摘要中的链接应将焦点移到对应字段
    await errorSummary.getByRole("link", { name: "Email is required" }).click()
    await expect(page.getByLabel("Email")).toBeFocused()
  })

  test("autocomplete attributes are set for common fields", async ({ page }) => {
    await page.goto("/checkout")

    // autocomplete 帮助密码管理器和辅助技术填写表单
    await expect(page.getByLabel("Full name")).toHaveAttribute("autocomplete", "name")
    await expect(page.getByLabel("Email")).toHaveAttribute("autocomplete", "email")
    await expect(page.getByLabel("Street address")).toHaveAttribute(
      "autocomplete",
      "street-address"
    )
    await expect(page.getByLabel("Postal code")).toHaveAttribute("autocomplete", "postal-code")
  })

  test("fieldsets group related fields with legends", async ({ page }) => {
    await page.goto("/checkout")

    // 相关字段应分组在带 legend 的 fieldset 中
    const shippingGroup = page.getByRole("group", { name: "Shipping address" })
    await expect(shippingGroup).toBeVisible()
    await expect(shippingGroup.getByLabel("Street address")).toBeVisible()

    const billingGroup = page.getByRole("group", { name: "Billing address" })
    await expect(billingGroup).toBeVisible()
  })
})

JavaScript

const { test, expect } = require("@playwright/test")
const AxeBuilder = require("@axe-core/playwright").default

test.describe("accessible forms", () => {
  test("all form fields have associated labels", async ({ page }) => {
    await page.goto("/register")

    await expect(page.getByLabel("First name")).toBeVisible()
    await expect(page.getByLabel("Last name")).toBeVisible()
    await expect(page.getByLabel("Email")).toBeVisible()
    await expect(page.getByLabel("Password")).toBeVisible()

    const results = await new AxeBuilder({ page })
      .include("form")
      .withRules(["label", "label-title-only"])
      .analyze()

    expect(results.violations).toEqual([])
  })

  test("error messages are linked to their fields via aria-describedby", async ({ page }) => {
    await page.goto("/register")
    await page.getByRole("button", { name: "Create account" }).click()

    const errorMessage = page.getByText("Email is required")
    await expect(errorMessage).toBeVisible()

    const emailField = page.getByLabel("Email")
    const describedBy = await emailField.getAttribute("aria-describedby")
    expect(describedBy).toBeTruthy()

    const errorId = await errorMessage.getAttribute("id")
    expect(describedBy).toContain(errorId)

    await expect(emailField).toHaveAttribute("aria-invalid", "true")
  })

  test("autocomplete attributes are set for common fields", async ({ page }) => {
    await page.goto("/checkout")

    await expect(page.getByLabel("Full name")).toHaveAttribute("autocomplete", "name")
    await expect(page.getByLabel("Email")).toHaveAttribute("autocomplete", "email")
    await expect(page.getByLabel("Street address")).toHaveAttribute(
      "autocomplete",
      "street-address"
    )
  })
})

CI 中的无障碍测试

适用场景:你希望无障碍违规导致构建失败,防止回归问题进入生产环境。每个团队都应在 CI 流水线上设置无障碍门槛。 避免场景:绝不要。如果你只在本地运行无障碍检查,它们就会被跳过。

TypeScript

// playwright.config.ts — 专用的无障碍项目
import { defineConfig } from "@playwright/test"

export default defineConfig({
  projects: [
    {
      name: "accessibility",
      testMatch: "**/*.a11y.spec.ts",
      use: {
        browserName: "chromium", // axe-core 与 Chromium 配合最佳
      },
    },
    {
      name: "e2e-chromium",
      testMatch: "**/*.spec.ts",
      testIgnore: "**/*.a11y.spec.ts",
      use: { browserName: "chromium" },
    },
  ],
})
// tests/pages.a11y.spec.ts — 扫描所有关键页面
import { test, expect } from "@playwright/test"
import AxeBuilder from "@axe-core/playwright"

const PAGES_TO_SCAN = [
  { name: "Home", path: "/" },
  { name: "Login", path: "/login" },
  { name: "Register", path: "/register" },
  { name: "Dashboard", path: "/dashboard" },
  { name: "Products", path: "/products" },
  { name: "Checkout", path: "/checkout" },
  { name: "Contact", path: "/contact" },
]

for (const { name, path } of PAGES_TO_SCAN) {
  test(`${name} page (${path}) has no WCAG AA violations`, async ({ page }) => {
    await page.goto(path)

    const results = await new AxeBuilder({ page })
      .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
      .analyze()

    // 将违规详情附加到测试报告
    await test.info().attach("accessibility-scan-results", {
      body: JSON.stringify(results.violations, null, 2),
      contentType: "application/json",
    })

    expect(results.violations).toEqual([])
  })
}
// tests/helpers/a11y-fixture.ts — 可复用的 axe-core 夹具
import { test as base, expect } from "@playwright/test"
import AxeBuilder from "@axe-core/playwright"

type A11yFixtures = {
  makeAxeBuilder: () => AxeBuilder
}

export const test = base.extend<A11yFixtures>({
  makeAxeBuilder: async ({ page }, use) => {
    const makeAxeBuilder = () =>
      new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
    await use(makeAxeBuilder)
  },
})

export { expect }
// tests/dashboard.a11y.spec.ts — 使用夹具
import { test, expect } from "./helpers/a11y-fixture"

test("dashboard has no violations after data loads", async ({ page, makeAxeBuilder }) => {
  await page.goto("/dashboard")
  await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()

  const results = await makeAxeBuilder().analyze()

  await test.info().attach("a11y-results", {
    body: JSON.stringify(results.violations, null, 2),
    contentType: "application/json",
  })

  expect(results.violations).toEqual([])
})

JavaScript

// playwright.config.js
const { defineConfig } = require("@playwright/test")

module.exports = defineConfig({
  projects: [
    {
      name: "accessibility",
      testMatch: "**/*.a11y.spec.js",
      use: { browserName: "chromium" },
    },
    {
      name: "e2e-chromium",
      testMatch: "**/*.spec.js",
      testIgnore: "**/*.a11y.spec.js",
      use: { browserName: "chromium" },
    },
  ],
})
// tests/pages.a11y.spec.js
const { test, expect } = require("@playwright/test")
const AxeBuilder = require("@axe-core/playwright").default

const PAGES_TO_SCAN = [
  { name: "Home", path: "/" },
  { name: "Login", path: "/login" },
  { name: "Dashboard", path: "/dashboard" },
  { name: "Products", path: "/products" },
]

for (const { name, path } of PAGES_TO_SCAN) {
  test(`${name} page (${path}) has no WCAG AA violations`, async ({ page }) => {
    await page.goto(path)

    const results = await new AxeBuilder({ page })
      .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
      .analyze()

    await test.info().attach("accessibility-scan-results", {
      body: JSON.stringify(results.violations, null, 2),
      contentType: "application/json",
    })

    expect(results.violations).toEqual([])
  })
}

GitHub Actions 集成:

# .github/workflows/accessibility.yml
name: Accessibility Tests
on: [push, pull_request]

jobs:
  accessibility:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npx playwright install --with-deps chromium

      - name: Run accessibility tests
        run: npx playwright test --project=accessibility

      - name: Upload accessibility report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: accessibility-report
          path: playwright-report/
          retention-days: 30

决策指南

检查项目 自动化(axe-core 手动(键盘/屏幕阅读器) 原因
缺少 alt 文本 axe-core 能可靠检测
颜色对比度比例 从 CSS 自动计算
缺少表单标签 检测缺少的 <label> 和 aria 关联
无效的 ARIA 属性 根据 WAI-ARIA 规范验证
重复 ID DOM 分析
Tab 顺序 / 逻辑流程 需要理解页面布局和用户意图
弹窗中的焦点管理 部分(axe 检查 aria-hidden 焦点陷阱行为需要行为测试
屏幕阅读器 UX 质量 朗读是否有帮助是主观的
认知负荷 / 可读性 无法自动化——需要人工判断
触摸目标大小(移动端) 是(WCAG 2.2 axe 检查最小尺寸;真实设备体验需要手动测试
动态内容朗读 实时区域行为取决于时序和屏幕阅读器
键盘快捷键冲突 需要了解操作系统/浏览器/辅助技术快捷键
阅读顺序 vs 视觉顺序 CSS 重排(flexbox order、grid)可能破坏此顺序
错误恢复流程 错误指导是否可理解需要人工判断
视频字幕 / 音频描述 部分(检查 <track> 字幕质量必须手动验证

经验法则:自动化所有 axe-core 能捕获的内容(在 CI 中对每个页面运行),然后将你的手动测试预算投入到最关键用户流程的键盘导航、焦点管理和屏幕阅读器朗读上。

反模式

不要这样做 问题 应该这样做
仅用 axe-core 测试 只能捕获约 30–40% 的 WCAG 问题。遗漏键盘导航、焦点管理、阅读顺序和 UX 质量。 将 axe-core 与键盘导航测试及定期的屏幕阅读器人工审计相结合
忽略键盘导航 约 8% 的用户依赖纯键盘导航。不可访问的键盘 UX 会阻挡真实用户。 对每个交互组件测试 Tab 顺序、Enter/Space 激活、Escape 关闭和方向键导航
跳过弹窗中的焦点管理 用户 Tab 到弹窗后的背景内容中,丢失上下文,或无法关闭对话框。 测试焦点陷阱、Escape 关闭以及焦点返回到触发器元素
仅测试一次无障碍 每次 PR 都可能引入回归。今天的通过审计不能代表下一个迭代。 在 CI 中每个构建都运行 axe-core;每季度安排一次手动审计
使用 tabindex > 0 覆盖自然 Tab 顺序,造成不可预测的导航。极难维护。 使用 tabindex="0" 使元素可聚焦,tabindex="-1" 用于程序化聚焦;永远不要使用正值
依赖 title 属性实现无障碍 屏幕阅读器对 title 的处理不一致——许多会忽略它。 使用 aria-labelaria-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>,使键盘用户无所适从。

修复

// 关闭弹窗后,将焦点返回到触发器
await page.getByRole("button", { name: "Close" }).click()
await expect(page.getByRole("button", { name: "Open modal" })).toBeFocused()

// 删除项目后,将焦点移到下一个项目或逻辑地标
await page.getByRole("button", { name: "Delete item 3" }).click()
await expect(page.getByRole("listitem").nth(2)).toBeFocused() // 下一个项目

axe-core 扫描返回不完整结果(非违规)

原因results.incomplete 包含 axe 无法自动确定的检查项。这些不是失败——是需要人工审查的项目。常见于复杂背景上的颜色对比度。

修复

const results = await new AxeBuilder({ page }).analyze()

// 记录需要人工审查的不完整检查
if (results.incomplete.length > 0) {
  console.log(
    "Needs manual review:",
    results.incomplete.map((i) => i.id)
  )
}

// 对于明确的违规,仍然失败
expect(results.violations).toEqual([])

Tab 顺序测试间歇性失败

原因:焦点行为取决于页面是否完全加载并可交互。动画、懒加载内容或自动聚焦脚本可能会干扰。

修复

// 在测试 Tab 顺序前等待页面完全可交互
await page.goto("/login")
await expect(page.getByLabel("Email")).toBeVisible()

// 先点击 body,确保焦点从已知位置开始
await page.locator("body").click()

// 现在测试 Tab 顺序
await page.keyboard.press("Tab")
await expect(page.getByLabel("Email")).toBeFocused()

相关