DEV Community

hiro
hiro

Posted on • Edited on • Originally published at b.0218.jp

Mechanically Detecting Accessibility Violations

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 integrated into Chrome DevTools allows for easy verification of accessibility.

Results verified with Lighthouse in Chrome DevTools

Furthermore, Lighthouse1 uses axe-core 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.

What is axe-core?

axe-core 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.22 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 practices34.

Furthermore, browser extensions and VS Code extensions (e.g., axe Accessibility Linter) are also available.

How to Use

axe-core offers a package @axe-core/puppeteer that is convenient for integration with Puppeteer.

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:

Choose the appropriate package according to your needs.

Moreover, for reporting the verification results of axe-core, a package called 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.

インストール

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

npm install puppeteer @axe-core/puppeteer axe-reports
Enter fullscreen mode Exit fullscreen mode

コード例

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

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();
})();
Enter fullscreen mode Exit fullscreen mode

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

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

検証するルール(規格)を指定する

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

await new AxePuppeteer(page).withTags(['wcag2a', 'wcag2aa', 'wcag2aaa', 'wcag21a', 'wcag21aa', 'wcag21aaa']).analyze();
Enter fullscreen mode Exit fullscreen mode

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

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より抜粋

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

実装

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

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

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

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

  1. urls.txt というURLの設定ファイルを用意する(1行毎にURLを記載する)
   #例
   https://example.com/
   https://example.jp/
   https://example.jp/aaa
Enter fullscreen mode Exit fullscreen mode
  1. 設定ファイルからURLを読み込み、それぞれのURLに対して検証をしていく
   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;
   };
Enter fullscreen mode Exit fullscreen mode

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

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();
  }
})();
Enter fullscreen mode Exit fullscreen mode

出力結果を日本語化する

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

見出しの日本語化

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

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

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

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

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);
Enter fullscreen mode Exit fullscreen mode

検証結果の日本語化

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

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

// 中略

const results = await new AxePuppeteer(page).configure({ locale: AXE_LOCALE_JA }).analyze();
Enter fullscreen mode Exit fullscreen mode
検証結果の影響度

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

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

英語 日本語
critical 緊急(Critical)
serious 深刻(Serious)
moderate 普通(Moderate)
minor 軽微(Minor)

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

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));
Enter fullscreen mode Exit fullscreen mode

その他

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

デバイスの指定

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

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

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

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

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

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

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

/**
 * 指定した時間だけ待機する関数
 * @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++;
  }
};
Enter fullscreen mode Exit fullscreen mode

完成したコード例

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

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();
  }
})();
Enter fullscreen mode Exit fullscreen mode

検証結果

完成したコードでデジタル庁のURLを指定してアクセシビリティの検証をしてみる。

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

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

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
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

axe DevToolsの検査結果

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 issues5, 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.

  1. The same results can be obtained using PageSpeed Insights

  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 

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

Top comments (0)