可访问性测试

介绍

Playwright 可用于测试应用程序中的多种可访问性问题。

一些可能发现的问题示例如下:

  • 由于与背景色的对比度差,文字对于视力障碍用户来说可能难以阅读

  • 没有标签的 UI 控件和表单元素,屏幕阅读器无法识别

  • 具有重复 ID 的交互元素,可能会混淆辅助技术

以下示例依赖于 @axe-core/playwright 包,它为 Playwright 测试添加了运行 【axe 可访问性测试引擎】的支持。

免责声明

自动化可访问性测试可以检测一些常见的可访问性问题,如缺失或无效的属性。但许多可访问性问题只能通过手动测试发现。我们建议结合使用自动化测试、手动可访问性评估和包容性用户测试。

对于手动评估,我们推荐使用 Accessibility Insights for Web,它是一个免费的开源开发工具,可以帮助您评估网站是否符合 WCAG 2.1 AA 标准。

可访问性测试示例

可访问性测试和其他 Playwright 测试一样工作。您可以为它们创建单独的测试用例,也可以将可访问性扫描和断言集成到现有的测试用例中。

以下示例展示了一些基本的可访问性测试场景。

扫描整个页面

这个示例展示了如何测试整个页面,检查是否存在可自动检测的可访问性违规。测试过程如下:

  1. 导入 @axe-core/playwright

  2. 使用普通的 Playwright 测试语法定义一个测试用例

  3. 使用普通的 Playwright 语法导航到待测试页面

  4. 等待 AxeBuilder.analyze() 执行可访问性扫描

  5. 使用 Playwright 测试【断言】,验证返回的扫描结果中没有违规

  • TypeScript

  • JavaScript

import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright'; // 1

test.describe('homepage', () => { // 2
  test('should not have any automatically detectable accessibility issues', async ({ page }) => {
    await page.goto('https://your-site.com/'); // 3

    const accessibilityScanResults = await new AxeBuilder({ page }).analyze(); // 4

    expect(accessibilityScanResults.violations).toEqual([]); // 5
  });
});
const { test, expect } = require('@playwright/test');
const AxeBuilder = require('@axe-core/playwright').default; // 1

test.describe('homepage', () => { // 2
  test('should not have any automatically detectable accessibility issues', async ({ page }) => {
    await page.goto('https://your-site.com/'); // 3

    const accessibilityScanResults = await new AxeBuilder({ page }).analyze(); // 4

    expect(accessibilityScanResults.violations).toEqual([]); // 5
  });
});

配置 axe 扫描页面的特定部分

@axe-core/playwright 支持多种配置选项。您可以通过使用 AxeBuilder 类的 Builder 模式指定这些选项。

例如,您可以使用 AxeBuilder.include() 来限制可访问性扫描只运行在页面的特定部分。

AxeBuilder.analyze() 将扫描当前页面状态。要扫描通过 UI 交互揭示的页面部分,使用 Locators 在调用 analyze() 之前与页面进行交互:

test('navigation menu should not have automatically detectable accessibility violations', async ({
  page,
}) => {
  await page.goto('https://your-site.com/');

  await page.getByRole('button', { name: 'Navigation Menu' }).click();

  // It is important to waitFor() the page to be in the desired
  // state *before* running analyze(). Otherwise, axe might not
  // find all the elements your test expects it to scan.
  await page.locator('#navigation-menu-flyout').waitFor();

  const accessibilityScanResults = await new AxeBuilder({ page })
      .include('#navigation-menu-flyout')
      .analyze();

  expect(accessibilityScanResults.violations).toEqual([]);
});

扫描 WCAG 违规

默认情况下,axe 会检查多种可访问性规则,其中一些规则对应于 Web 内容可访问性指南(WCAG)的特定成功标准,另一些则是 “最佳实践” 规则,虽然这些规则不一定是 WCAG 的必需标准。

您可以通过 AxeBuilder.withTags() 来限制只运行与特定 WCAG 成功标准相关的规则。例如,Accessibility Insights for Web 的自动检查只包括测试 WCAG A 和 AA 成功标准的 axe 规则;要匹配该行为,您可以使用标签 wcag2awcag2aawcag21awcag21aa

请注意,自动化测试无法检测所有类型的 WCAG 违规。

test('should not have any automatically detectable WCAG A or AA violations', async ({ page }) => {
  await page.goto('https://your-site.com/');

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

  expect(accessibilityScanResults.violations).toEqual([]);
});

您可以【在 axe API 文档的 "Axe-core 标签" 部分】找到 axe-core 支持的规则标签的完整列表。

处理已知问题

在向应用程序添加可访问性测试时,一个常见的问题是:“如何抑制已知的违规情况?”以下示例演示了您可以使用的一些技术。

排除单个元素的扫描

如果您的应用程序包含一些已知问题的特定元素,您可以使用 AxeBuilder.exclude() 方法将它们从扫描中排除,直到您能够修复这些问题。

这是通常最简单的选项,但它有一些重要的缺点:

  • exclude() 会排除指定元素及其所有子元素。避免在包含许多子元素的组件上使用此方法。

  • exclude() 会阻止所有规则对指定元素运行,而不仅仅是与已知问题相关的规则。

以下是一个示例,展示了如何在一个特定的测试中排除一个元素的扫描:

test('should not have any accessibility violations outside of elements with known issues', async ({
  page,
}) => {
  await page.goto('https://your-site.com/page-with-known-issues');

  const accessibilityScanResults = await new AxeBuilder({ page })
      .exclude('#element-with-known-issue')
      .analyze();

  expect(accessibilityScanResults.violations).toEqual([]);
});

如果该元素在多个页面中被反复使用,可以考虑【使用测试夹具(test fixture)】在多个测试中重用相同的 AxeBuilder 配置。

禁用单个扫描规则

如果您的应用程序包含许多特定规则的预先存在的违规情况,您可以使用 AxeBuilder.disableRules() 方法临时禁用单个规则,直到您能够修复这些问题。

您可以在要抑制的违规项的 id 属性中找到要传递给 disableRules() 的规则 ID。【axe 的规则的完整列表】可以在 axe-core 的文档中找到。

test('should not have any accessibility violations outside of rules with known issues', async ({
  page,
}) => {
  await page.goto('https://your-site.com/page-with-known-issues');

  const accessibilityScanResults = await new AxeBuilder({ page })
      .disableRules(['duplicate-id'])
      .analyze();

  expect(accessibilityScanResults.violations).toEqual([]);
});

使用快照允许特定已知问题

如果您希望允许更细粒度的已知问题,可以使用【快照】来验证一组预先存在的违规情况是否没有变化。这种方法避免了使用 AxeBuilder.exclude() 的缺点,但代价是稍微增加了一些复杂性和脆弱性。

不要对整个 accessibilityScanResults.violations 数组使用快照,因为它包含了问题元素的实现细节,例如它们的渲染 HTML 片段;如果将这些内容包含在快照中,每当相关组件发生变化时,测试就容易出现问题:

// Don't do this! This is fragile.
expect(accessibilityScanResults.violations).toMatchSnapshot();

相反,创建一个包含足够信息来唯一标识问题的违规情况的指纹,并对指纹使用快照:

// This is less fragile than snapshotting the entire violations array.
expect(violationFingerprints(accessibilityScanResults)).toMatchSnapshot();

// my-test-utils.js
function violationFingerprints(accessibilityScanResults) {
  const violationFingerprints = accessibilityScanResults.violations.map(violation => ({
    rule: violation.id,
    // These are CSS selectors which uniquely identify each element with
    // a violation of the rule in question.
    targets: violation.nodes.map(node => node.target),
  }));

  return JSON.stringify(violationFingerprints, null, 2);
}

将扫描结果作为测试附件导出

大多数可访问性测试主要关注 axe 扫描结果中的 violations 属性。然而,扫描结果不仅仅包含违规情况。例如,结果还包含关于通过的规则的信息,以及 axe 对某些规则结果不确定的元素信息。这些信息对调试未检测到所有预期违规情况的测试非常有用。

要将所有扫描结果作为测试结果的一部分用于调试目的,您可以使用 testInfo.attach() 将扫描结果作为测试附件添加。【报告工具】随后可以将完整的结果嵌入或链接到测试输出中。

以下示例展示了如何将扫描结果附加到测试中:

test('example with attachment', async ({ page }, testInfo) => {
  await page.goto('https://your-site.com/');

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

  await testInfo.attach('accessibility-scan-results', {
    body: JSON.stringify(accessibilityScanResults, null, 2),
    contentType: 'application/json'
  });

  expect(accessibilityScanResults.violations).toEqual([]);
});

使用测试夹具进行常见的 axe 配置

【测试夹具】是一个很好的方法,可以在多个测试之间共享常见的 AxeBuilder 配置。一些可能需要使用测试夹具的场景包括:

  • 在所有测试中使用相同的规则集

  • 在多个不同页面中抑制某个常见元素的已知违规情况

  • 为多个扫描一致地附加独立的可访问性报告

以下示例演示了如何创建和使用一个涵盖这些场景的测试夹具。

创建夹具

以下示例夹具创建了一个 AxeBuilder 对象,该对象预先配置了共享的 withTags()exclude() 配置。

  • TypeScript

  • JavaScript

axe-test.ts
import { test as base } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

type AxeFixture = {
  makeAxeBuilder: () => AxeBuilder;
};

// Extend base test by providing "makeAxeBuilder"
//
// This new "test" can be used in multiple test files, and each of them will get
// a consistently configured AxeBuilder instance.
export const test = base.extend<AxeFixture>({
  makeAxeBuilder: async ({ page }, use, testInfo) => {
    const makeAxeBuilder = () => new AxeBuilder({ page })
        .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
        .exclude('#commonly-reused-element-with-known-issue');

    await use(makeAxeBuilder);
  }
});
export { expect } from '@playwright/test';
axe-test.js
const base = require('@playwright/test');
const AxeBuilder = require('@axe-core/playwright').default;

// Extend base test by providing "makeAxeBuilder"
//
// This new "test" can be used in multiple test files, and each of them will get
// a consistently configured AxeBuilder instance.
exports.test = base.test.extend({
  makeAxeBuilder: async ({ page }, use, testInfo) => {
    const makeAxeBuilder = () => new AxeBuilder({ page })
        .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
        .exclude('#commonly-reused-element-with-known-issue');

    await use(makeAxeBuilder);
  }
});
exports.expect = base.expect;

使用夹具

要使用该夹具,可以将先前示例中的 new AxeBuilder({ page }) 替换为新定义的 makeAxeBuilder 夹具:

const { test, expect } = require('./axe-test');

test('example using custom fixture', async ({ page, makeAxeBuilder }) => {
  await page.goto('https://your-site.com/');

  const accessibilityScanResults = await makeAxeBuilder()
      // Automatically uses the shared AxeBuilder configuration,
      // but supports additional test-specific configuration too
      .include('#specific-element-under-test')
      .analyze();

  expect(accessibilityScanResults.violations).toEqual([]);
});