Mechanically Detecting Accessibility Violations
Tags: a11y, javascript, japanese
Published: 2023-12-01 03:53:09

> This article is in process of translation.
> The original text is at https://b.0218.jp/202312010000.html 🙏

---

## Introduction

Web accessibility (hereinafter referred to as accessibility) is a crucial element to ensure that all users can use websites and information systems. However, despite recognizing the importance of accessibility, many developers do not know how to detect and improve accessibility violations.

This article focuses on methods to mechanically detect accessibility violations and explains their implementation.


### Verification Tools

The use of verification tools is very effective in finding and improving accessibility violations. There are various types of verification tools, for example, using [Lighthouse](https://developer.chrome.com/docs/lighthouse/overview/) integrated into [Chrome DevTools](https://developer.chrome.com/docs/devtools/) allows for easy verification of accessibility.

<figure>
  <img src="https://dev-to-uploads.s3.amazonaws.com/uploads/articles/y93bcerk6vdplyt12czj.png" alt=""><figcaption>Results verified with Lighthouse in Chrome DevTools</figcaption>
</figure>

Furthermore, Lighthouse[^1] uses [axe-core](https://github.com/dequelabs/axe-core-npm) as the audit engine for accessibility, and by using it directly, more detailed information can be obtained. Thus, verification tools clarify specific problems, allowing for the formulation of improvement measures based on them.

[^1]: The same results can be obtained using [PageSpeed Insights](https://pagespeed.web.dev/?hl=ja).


## What is axe-core?

[axe-core](https://github.com/dequelabs/axe-core-npm) is an accessibility testing library for websites, developed by Deque Systems, a leading vendor in accessibility. axe-core provides various rules compliant with WCAG 2.0, 2.1, and 2.2[^2] levels A, AA, and AAA, including common best practices in accessibility, such as ensuring each page has an h1 heading. The rules are grouped by WCAG level and best practices[^3][^4].

[^2]: WCAG (Web Content Accessibility Guidelines) is an international standard guideline created by W3C to make web content more accessible. It includes specific criteria to enable users with various disabilities, such as visual, auditory, or motor impairments, to access web content easily.
[^3]: These are rules accepted in the industry to enhance user experience, not necessarily conforming to WCAG success criteria.
[^4]: [List of axe-core rules](https://github.com/dequelabs/axe-core/blob/develop/doc/rule-descriptions.md)

Furthermore, browser extensions and VS Code extensions (e.g., [axe Accessibility Linter](https://marketplace.visualstudio.com/items?itemName=deque-systems.vscode-axe-linter)) are also available.


### How to Use

axe-core offers a package [@axe-core/puppeteer](https://www.npmjs.com/package/@axe-core/puppeteer) that is convenient for integration with [Puppeteer](https://pptr.dev/).

The core of axe-core requires embedding the library into the target site for execution, and there's no feature to insert and execute axe externally. Therefore, to verify externally, tools like headless browsers are necessary, and for headless browser verification, @axe-core/puppeteer is convenient.

axe-core-npm also offers other packages such as:


- [`@axe-core/cli`](https://github.com/dequelabs/axe-core-npm/blob/develop/packages/cli/README.md)
- [`@axe-core/playwright`](https://github.com/dequelabs/axe-core-npm/blob/develop/packages/playwright/README.md)
- [`@axe-core/puppeteer`](https://github.com/dequelabs/axe-core-npm/blob/develop/packages/puppeteer/README.md)
- [`@axe-core/react`](https://github.com/dequelabs/axe-core-npm/blob/develop/packages/react/README.md)
- [`@axe-core/reporter-earl`](https://github.com/dequelabs/axe-core-npm/blob/develop/packages/reporter-earl/README.md)
- [`@axe-core/webdriverio`](https://github.com/dequelabs/axe-core-npm/blob/develop/packages/webdriverio/README.md)
- [`@axe-core/webdriverjs`](https://github.com/dequelabs/axe-core-npm/blob/develop/packages/webdriverjs/README.md)

Choose the appropriate package according to your needs.

Moreover, for reporting the verification results of axe-core, a package called [axe-reports](https://www.npmjs.com/package/axe-reports) is provided by volunteers. It allows the export of axe-core's verification results in CSV or TSV formats, enabling the generation of ideal verification results by combining these.


#### インストール

まずは、各種パッケージをインストールする。

```bash
npm install puppeteer @axe-core/puppeteer axe-reports
```

#### コード例

公式で記載されている実装例にaxe-reportsを組み合わせた形で実装すると以下のようになる。

```js
import { AxePuppeteer } from '@axe-core/puppeteer';
import puppeteer from 'puppeteer';
import AxeReports from 'axe-reports';

(async () => {
  const browser = await puppeteer.launch({ headless: 'new' });
  const page = await browser.newPage();

  await page.setBypassCSP(true);
  await page.goto('https://dequeuniversity.com/demo/mars/');

  try {
    AxeReports.createCsvReportHeaderRow();
    const results = await new AxePuppeteer(page).analyze();
    AxeReports.createCsvReportRow(results);
  } catch (e) {
    // do something with the error
  }

  await browser.close();
})();
```

対象のURLにヘッドレスブラウザでアクセスをして、`Page`をaxe（AxePuppeteer）に渡して検証する。検証結果は、axe-reportsによって、CSVの形式で出力する。

大まかな実装は上記の通りだが、これを使い勝手の良いように整えていく。

### 検証するルール（規格）を指定する

`withTags()`メソッドを使うことで、検証するルール（規格）の指定もできる。

```ts
await new AxePuppeteer(page).withTags(['wcag2a', 'wcag2aa', 'wcag2aaa', 'wcag21a', 'wcag21aa', 'wcag21aaa']).analyze();
```

指定できるタグは以下の通り。用途に応じて適切な規格を指定できる。

> | Tag Name        | Accessibility Standard / Purpose    |
> | --------------- | ----------------------------------- |
> | `wcag2a`        | WCAG 2.0 Level A                    |
> | `wcag2aa`       | WCAG 2.0 Level AA                   |
> | `wcag2aaa`      | WCAG 2.0 Level AAA                  |
> | `wcag21a`       | WCAG 2.1 Level A                    |
> | `wcag21aa`      | WCAG 2.1 Level AA                   |
> | `wcag22aa`      | WCAG 2.2 Level AA                   |
> | `best-practice` | Common accessibility best practices |
>
> [Section 2: API Reference - Axe-core Tags](https://github.com/dequelabs/axe-core/blob/develop/doc/API.md#axe-core-tags)より抜粋

たとえば、対象のサイトがWCAG 2.2 Level AAに準拠していることを確認したい場合は `wcag22aa` を指定するといった具合である。

## 実装

使い勝手の良いように実装していく。以下の仕様で実装をする。

- **複数の指定されたURLの全てのページを検証する**
- **出力結果を日本語化する**

以降、それぞれの実装のコードを説明用に抜粋したものを紹介していく（完全なコードは別途公開する）。

### 複数の指定されたURLの全てのページを検証する

1. urls.txt というURLの設定ファイルを用意する（1行毎にURLを記載する）

   ```
   #例
   https://example.com/
   https://example.jp/
   https://example.jp/aaa
   ```

2. 設定ファイルからURLを読み込み、それぞれのURLに対して検証をしていく

   ```ts
   import fs from 'node:fs';

   const readUrls = async () => {
     const urlsFile = await fs.promises.readFile('./urls.txt', 'utf-8');

     const urls = urlsFile
       .replace(/\r\n?/g, '\n')
       .split('\n')
       .filter((url) => url);

     return urls;
   };
   ```

`readUrls()`を組み込むと以下のような形になる。`url`に対して非同期処理の並列実行を`Promise.all()`で行う。

```js
import { AxePuppeteer } from '@axe-core/puppeteer';
import puppeteer from 'puppeteer';
import AxeReports from 'axe-reports';

const setupAndRunAxeTest = async (url, browser) => {
  const page = await browser.newPage();
  await page.setBypassCSP(true);
  await page.goto(url);

  try {
    const results = await new AxePuppeteer(page).analyze();
    AxeReports.createCsvReportRow(results);
  } catch (e) {}
};

(async () => {
  AxeReports.createCsvReportHeaderRow();

  const urls = await readUrls();
  const browser = await puppeteer.launch({ headless: 'new' });

  try {
    await Promise.all(urls.map((url) => setupAndRunAxeTest(url, browser)));
  } catch (error) {
    console.error(`Error during tests: ${error}`);
  } finally {
    await browser.close();
  }
})();
```

### 出力結果を日本語化する

英語のままの出力で問題なければ以下の実装は不要。

#### 見出しの日本語化

AxeReportsが出力するCSVヘッダは英語の固定値になっており変更できない（TSVも同様）。

```ts
// このような形で出力される
'URL,Volation Type,Impact,Help,HTML Element,Messages,DOM Element\r';
```

ここのロケールを変えたり、任意の文字を指定できないため、日本語で出力したい場合は`AxeReports.createCsvReportHeaderRow()`を使わず自前で出力する必要がある。

単純に時前でCSVファイルを作成するだけである。

```ts
import fs from 'node:fs';

const CSV_FILE_PATH = `./result.csv`;

// 元のヘッダー部分を日本語化したもの
const CSV_HEADER = 'URL,種別,影響度,ヘルプ,HTML要素,メッセージ,DOM要素\r';

// 既存のCSVファイルがあれば削除
if (fs.existsSync(CSV_FILE_PATH)) {
  fs.rmSync(CSV_FILE_PATH);
}

// 新しいCSVファイルを作成
fs.writeFileSync(CSV_FILE_PATH, CSV_HEADER);

// 中略

AxeReports.createCsvReportRow(results);
```

#### 検証結果の日本語化

axeの検証結果は標準では英語で出力されるが、日本語のロケール（`axe-core/locales/ja.json`）が用意されているため、それを利用して日本語化できる。
ロケールの指定は、以下のようにAxePuppeteerの`configure()`メソッドの引数に指定することで日本語化できる。

```ts
import AXE_LOCALE_JA from 'axe-core/locales/ja.json';

// 中略

const results = await new AxePuppeteer(page).configure({ locale: AXE_LOCALE_JA }).analyze();
```

##### 検証結果の影響度

メッセージ部分はロケールを指定することで日本語化されるが、影響度は英語のままで出力される。

影響度として、`critical`・`serious`・`moderate`・`minor`が定義されている。出力した際に分かりやすいように以下のように置き換える。

| 英語     | 日本語           |
| -------- | ---------------- |
| critical | 緊急（Critical） |
| serious  | 深刻（Serious）  |
| moderate | 普通（Moderate） |
| minor    | 軽微（Minor）    |

---

AxePuppeteerの`analyze()`メソッドの戻り値に対して、指定の影響度の文字列を置き換える。`AxeResults`の値に応じて置換をしていく。

```ts
import type { AxeResults, ImpactValue } from 'axe-core';

type AxeResultsKeys = keyof Omit<
  AxeResults,
  'toolOptions' | 'testEngine' | 'testRunner' | 'testEnvironment' | 'url' | 'timestamp'
>;

const CSV_TRANSLATE_RESULT_GROUPS: AxeResultsKeys[] = ['inapplicable', 'violations', 'incomplete', 'passes'];
const CSV_TRANSLATE_IMPACT_VALUE = {
  critical: '緊急 (Critical)',
  serious: '深刻 (Serious)',
  moderate: '普通 (Moderate)',
  minor: '軽微 (Minor)',
};

const replaceImpactValues = (axeResult: AxeResults): AxeResults => {
  const result = { ...axeResult };

  for (const key of CSV_TRANSLATE_RESULT_GROUPS) {
    if (result[key] && Array.isArray(result[key])) {
      const updatedItems = [];
      for (const item of result[key]) {
        if (item.impact && CSV_TRANSLATE_IMPACT_VALUE[item.impact]) {
          updatedItems.push({
            ...item,
            impact: CSV_TRANSLATE_IMPACT_VALUE[item.impact] as ImpactValue,
          });
        } else {
          updatedItems.push(item);
        }
      }
      result[key] = updatedItems;
    }
  }

  return result;
};

// 中略

const results = await new AxePuppeteer(page)
  .configure({ locale: AXE_LOCALE_JA })
  .analyze()
  .then((analyzeResults) => replaceImpactValues(analyzeResults));
```

### その他

axeとは直接関係ない部分を紹介する。

#### デバイスの指定

以下のように`page.emulate()`メソッドを使うことでデバイス指定ができる。

```ts
// モバイルの指定をした場合
const userAgent = await browser.userAgent();
await page.emulate({
  userAgent,
  viewport: {
    width: 375,
    height: 812,
    isMobile: true,
    hasTouch: true,
  },
});
```

`page.emulate` の `userAgent` は必須項目のため、現状の `browser.userAgent()` を利用する。

さらにデバイスのフラグを`.env`ファイルにもたせるなどして、切り替えできるようにしておくと良い。

#### ページ最下部までスクロールする

スクロールすることで読み込まれるコンテンツを検証するために、ページの最下部までスクロールする。

無限スクロールが実装されているページなどでは永久にスクロールが終わらなくなってしまうため、スクロール回数の上限を設けている。

```ts
/**
 * 指定した時間だけ待機する関数
 * @param ms 待機時間（ミリ秒）
 */
const waitForTimeout = (ms: number): Promise<void> => {
  return new Promise((resolve) => setTimeout(resolve, ms));
};

/**
 * ページの最下部までスクロールする
 */
const scrollToBottom = async (page: Page, maxScrolls = 10, waitTime = 3000): Promise<void> => {
  let previousHeight = 0;
  let scrollCount = 0;

  while (scrollCount < maxScrolls) {
    // 現在のページの高さを取得
    const currentHeight: number = await page.evaluate(() => document.body.scrollHeight);

    // 前回と高さが変わらなければ終了
    if (previousHeight === currentHeight) break;

    // ページの最下部までスクロール
    await page.evaluate('window.scrollTo(0, document.body.scrollHeight)');
    previousHeight = currentHeight;

    // 指定された時間だけ待機
    await waitForTimeout(waitTime);

    // スクロール回数をカウント
    scrollCount++;
  }
};
```

## 完成したコード例

これまでの実装例を組み合わせると、以下のような形になった。実際はもう少しファイル分割をすると良い。

```ts
import 'dotenv/config';

import fs from 'node:fs';
import { AxePuppeteer } from '@axe-core/puppeteer';
import type { Spec, AxeResults, ImpactValue } from 'axe-core';
import AxeReports from 'axe-reports';
import puppeteer, { Browser, Page } from 'puppeteer';
import AXE_LOCALE_JA from 'axe-core/locales/ja.json';

import type { AxeResultsKeys } from './types';

export const FILE_NAME = 'result';
export const FILE_EXTENSION = 'csv';
export const CSV_FILE_PATH = `./${FILE_NAME}.${FILE_EXTENSION}`;
export const CSV_HEADER = 'URL,種別,影響度,ヘルプ,HTML要素,メッセージ,DOM要素\r';
export const CSV_TRANSLATE_RESULT_GROUPS: AxeResultsKeys[] = ['inapplicable', 'violations', 'incomplete', 'passes'];
export const CSV_TRANSLATE_IMPACT_VALUE = {
  critical: '緊急 (Critical)',
  serious: '深刻 (Serious)',
  moderate: '普通 (Moderate)',
  minor: '軽微 (Minor)',
};

/**
 * URLをファイルから非同期で読み込む
 */
const readUrls = async (): Promise<string[]> => {
  const urlsFile = await fs.promises.readFile('./urls.txt', 'utf-8');

  const urls = urlsFile
    .replace(/\r\n?/g, '\n')
    .split('\n')
    .filter((url) => url);

  return urls;
};

/**
 * 指定した時間だけ待機する関数
 * @param ms 待機時間（ミリ秒）
 */
const waitForTimeout = (ms: number): Promise<void> => {
  return new Promise((resolve) => setTimeout(resolve, ms));
};

/**
 * ページの最下部までスクロールする
 */
const scrollToBottom = async (page: Page, maxScrolls = 10, waitTime = 3000): Promise<void> => {
  let previousHeight = 0;
  let scrollCount = 0;

  while (scrollCount < maxScrolls) {
    const currentHeight: number = await page.evaluate(() => document.body.scrollHeight);

    if (previousHeight === currentHeight) break;

    await page.evaluate('window.scrollTo(0, document.body.scrollHeight)');
    previousHeight = currentHeight;

    await waitForTimeout(waitTime);

    scrollCount++;
  }
};

/**
 * Axeの結果の影響度の値を日本語に置き換える
 */
const replaceImpactValues = (axeResult: AxeResults): AxeResults => {
  const result = { ...axeResult };

  for (const key of CSV_TRANSLATE_RESULT_GROUPS) {
    if (result[key] && Array.isArray(result[key])) {
      const updatedItems = [];
      for (const item of result[key]) {
        if (item.impact && CSV_TRANSLATE_IMPACT_VALUE[item.impact]) {
          updatedItems.push({
            ...item,
            impact: CSV_TRANSLATE_IMPACT_VALUE[item.impact] as ImpactValue,
          });
        } else {
          updatedItems.push(item);
        }
      }
      result[key] = updatedItems;
    }
  }

  return result;
};

/**
 * Axeによるアクセシビリティテストを実行する
 */
const runAxeTest = async (page: Page, url: string): Promise<AxeResults> => {
  console.log(`Testing ${url}...`);

  // 指定されたURLにアクセス
  await page.goto(url, { waitUntil: ['load', 'networkidle2'] }).catch(() => {
    console.error(`Connection failed: ${url}`);
  });

  console.log(`page title: ${await page.title()}`);

  await scrollToBottom(page);

  const results = await new AxePuppeteer(page)
    .configure({ locale: AXE_LOCALE_JA } as unknown as Spec)
    .withTags(['wcag2a', 'wcag21a', 'best-practice'])
    .analyze()
    .then((analyzeResults) => replaceImpactValues(analyzeResults));

  return results;
};

/**
 * URLごとにページを設定し、アクセシビリティテストを実行する
 */
async function setupAndRunAxeTest(url: string, browser: Browser) {
  const page = await browser.newPage();
  await page.setBypassCSP(true);

  /**
   * process.env.DEVICE_TYPE
   * @type {"0" | "1" | undefined}
   * @description "0" はデスクトップ / "1" はモバイル
   */
  if (process.env.DEVICE_TYPE === '1') {
    const userAgent = await browser.userAgent();
    await page.emulate({
      userAgent,
      viewport: {
        width: 375,
        height: 812,
        isMobile: true,
        hasTouch: true,
      },
    });
  }

  try {
    const results = await runAxeTest(page, url);
    AxeReports.processResults(results, FILE_EXTENSION, FILE_NAME);
  } catch (error) {
    console.error(`Error testing ${url}:`, error);
  } finally {
    await page.close();
  }
}

(async () => {
  const urls = await readUrls();

  if (fs.existsSync(CSV_FILE_PATH)) {
    fs.rmSync(CSV_FILE_PATH);
  }
  fs.writeFileSync(CSV_FILE_PATH, CSV_HEADER);

  const browser = await puppeteer.launch({ headless: 'new' });

  try {
    await Promise.all(urls.map((url) => setupAndRunAxeTest(url, browser)));
  } catch (error) {
    console.error(`Error during tests: ${error}`);
  } finally {
    await browser.close();
  }
})();
```

## 検証結果

完成したコードで[デジタル庁](https://www.digital.go.jp/)のURLを指定してアクセシビリティの検証をしてみる。

- **URL**
  `https://www.digital.go.jp/`
- **ルール**
  ```ts
  withTags(['wcag2a', 'wcag21a', 'best-practice']);
  ```
- **その他**
  - Node.js上で実行するが、TypeScriptで実装しているため、`ts-node`もしくは`node -r esbuild-register`などを使って実行する

スクリプトの実行後、以下のような結果がCSV出力された。日本語化の対応によって、影響度やヘルプ（のURL）、メッセージが日本語で出力されていることが確認できる。

```csv
URL,種別,影響度,ヘルプ,HTML要素,メッセージ,DOM要素
https://www.digital.go.jp/,heading-order,普通 (Moderate),https://dequeuniversity.com/rules/axe/4.8/heading-order?application=axe-puppeteer&lang=ja,<h5 class="card-image__title text-r">マイナンバー制度・マイナンバーカード</h5>,見出しの順序が無効です,a[href$="mynumber"] > .card-image__text > h5
https://www.digital.go.jp/,page-has-heading-one,普通 (Moderate),https://dequeuniversity.com/rules/axe/4.8/page-has-heading-one?application=axe-puppeteer&lang=ja,<html lang="ja" dir="ltr" prefix="og: https://ogp.me/ns#" class=" js">,,html
https://www.digital.go.jp/,region,普通 (Moderate),https://dequeuniversity.com/rules/axe/4.8/region?application=axe-puppeteer&lang=ja,<div class="template__pagetop">,ページの一部のコンテンツがランドマークに含まれていません,.template__pagetop
https://www.digital.go.jp/,svg-img-alt,深刻 (Serious),https://dequeuniversity.com/rules/axe/4.8/svg-img-alt?application=axe-puppeteer&lang=ja,<svg role="img" class="icon icon--12px icon--arrow-rightwards">  <path xmlns="http://www.w3.org/2000/svg" d="M7.3813 1.67358L12.3591 6.59668L7.3813 11.5198L6.4582 10.5967L9.85825 7.19663H2.08008V5.99663H9.85816L6.4582 2.59668L7.3813 1.67358Z"></path></svg>,要素にタイトルを示す子要素が存在しません--aria-label属性が存在しない、または空です--aria-labelledby属性が存在しない、存在しない要素を参照している、または空の要素を参照しています--要素にtitle属性が指定されていません,a[href$="newgraduates/"] > .mdcontainer-button-inner__text > .svg-wrapper > .icon--arrow-rightwards.icon--12px.icon
```

出力された結果を見ると、以下のようなアクセシビリティ違反がある。

- `imgロールを持つ<svg>要素には代替テキストが存在しなければなりません`
- `見出しのレベルは1つずつ増加させなければなりません`
- `ページにはレベル1の見出しが含まれていなければなりません`
- `ページのすべてのコンテンツはlandmarkに含まれていなければなりません`

出力結果には、[Deque University](https://dequeuniversity.com/)へのリンクが含まれているため、そこから詳細を確認できる（例：https://dequeuniversity.com/rules/axe/4.8/heading-order?application=axe-puppeteer&lang=ja）。

また、axe DevToolsでも同様の設定で実行して、同様の結果が得られている。

<figure>
  <img src="https://dev-to-uploads.s3.amazonaws.com/uploads/articles/9158l5j442pyilys8ubw.png" alt=""><figcaption>axe DevToolsの検査結果</figcaption>
</figure>


## Conclusion

- Verification tools are highly effective in detecting accessibility violations and are essential for improving the accessibility of websites. These tools quickly identify technical issues and facilitate the development of improvement strategies.
- However, since verification tools cannot detect all accessibility issues[^5], it is important not to rely solely on these tools. Human reviews and evaluations based on actual user experiences are also crucial.
- Verification tools are aids in enhancing accessibility, but they should be complemented by continuous monitoring and improvement. Ultimately, the combined efforts of these approaches will lead to a better, more accessible web experience for all users.

[^5]: This refers to the limitation of verification tools in detecting every possible accessibility issue.


[^5]: 「チェックツールで見つけられる問題は、ウェブアクセシビリティの問題の2割から3割程度」といわれている。（[ウェブアクセシビリティ 導入ガイドブック](https://www.digital.go.jp/resources/introduction-to-web-accessibility-guidebook)）