axe-coreの検出レベルを全深刻度に引き上げ、a11yを継続的に担保できるようにしました

  • URLをコピーしました!

こんにちは、PR TIMESでインターンをしている勝間田(@Sho_26_ts)です。

今回は、フロントエンドにおけるアクセシビリティ向上の一環として、axe-coreを用いた改善と、それを継続的に担保するための方法を紹介します。

目次

これまでのアクセシビリティの取り組み

PR TIMESではMarkuplintとPlaywright、Storybookでアクセシビリティ(a11y)テストを行ってきました。

MarkuplintとPlaywrightでのアクセシビリティ品質のチェック方法はこちらの記事をご覧ください。

あわせて読みたい
PR TIMESのフロントエンド環境にMarkuplintを導入しました こんにちは。PR TIMESでフロントエンドエンジニアをしている夛田(@unachang113)です。 今回はMarkuplintを導入した話をしようと思います。 【Markuplintとは】 Markupli...
あわせて読みたい
@axe-core/playwrightの導入で実現するHTML全体のアクセシビリティ品質担保 こんにちは、フロントエンドエンジニアのやなぎ(@apple_yagi)です。 PR TIMESのフロントエンドではこれまで、MarkuplintやStorybook Testを用いたアクセシビリティ(a...

その中で今回はStorybookに焦点を向けて改善を進めてみました。

PR TIMESのアクセシビリティ課題

Storybookではこれまで@storybook/test-runnerを使ってアクセシビリティチェックを行っていましたがCriticalしか条件に入れてなかったのでエラーの検出レベルが緩いままでした。

import {getStoryContext, type TestRunnerConfig} from '@storybook/test-runner';
import {injectAxe, checkA11y, configureAxe} from 'axe-playwright';

const config: TestRunnerConfig = {
  async preVisit(page) {
    await injectAxe(page);
  },
  async postVisit(page, context) {
    // ストーリーのコンテキストを取得
    const storyContext = await getStoryContext(page, context);

    // a11yが無効化されているストーリーはスキップ
    if (storyContext.parameters?.a11y?.disable === true) {
      return;
    }

    // ストーリーレベルのa11yルールを適用
    await configureAxe(page, {
      rules: storyContext.parameters?.a11y?.config?.rules,
    });

    await checkA11y(page, '#storybook-root', {
	    // criticalのみを対象にする
      includedImpacts: ['critical'],
      detailedReport: false,
      verbose: false,
      axeOptions: storyContext.parameters?.a11y?.options,
    });
  },
};

export default config;

多くのアクセシビリティ問題を見逃している可能性があり、より包括的なチェックが必要でした。

解決策:全深刻度をチェックする体制の構築

そこで、axe-coreの全深刻度をチェックできる体制を構築することにしました。

axe-coreは、Deque Systems社が開発・メンテナンスしているオープンソースのアクセシビリティテストエンジンです。

GitHub
GitHub - dequelabs/axe-core: Accessibility engine for automated Web UI testing Accessibility engine for automated Web UI testing. Contribute to dequelabs/axe-core development by creating an account on GitHub.

axe-coreには深刻度(Impact)という概念があり、スキャンで見つかった問題がエンドユーザーに与える影響を区分しています。

GitHub
axe-core/doc/issue_impact.md at develop · dequelabs/axe-core Accessibility engine for automated Web UI testing. Contribute to dequelabs/axe-core development by creating an account on GitHub.

なぜStorybook + axe-coreを選んだのか

Playwrightでは以下の課題がありました。

  • コンポーネント単位でのテストができないため、a11yテストの網羅性を上げづらい
  • ページ全体のテストであるため、コンポーネント単位のVitestより実行速度が遅い
 import {
  expect as baseExpect,
  type Page,
} from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

 export const expect = baseExpect.extend({
    async toPassA11y(page: Page) {
      const results = await new AxeBuilder({page})
        .analyze();

      // エラーメッセージのフォーマット処理(30-40行)
      // ...
    },
  });

また、MarkuplintはあくまでHTML仕様への準拠を検証するツールです。仕様に準拠した正しいHTMLであっても、アクセシビリティの観点では不十分なケースがあります(例:alt 属性は存在するが空文字、見出しレベルの飛ばし、コントラスト不足など)。そのため、アクセシビリティを網羅的に担保するには、axe-coreなどの専用ツールを併用する必要があります。

そこで私たちは Storybookのtest-runner + axe-core を採用することにしました。

継続的にアクセシビリティを担保する仕組み

今回の取り組みによって最終的に運用している仕組みの全体像を紹介します

新しく採用した構成

Storybook + Vitest addon + axe-coreを採用しました。

まず、@storybook/addon-a11yを導入します。このaddonはStorybook上でアクセシビリティの問題を確認できるだけでなく、CIでのa11yチェックにも必要な基盤となります。

// .storybook/main.ts
export default {
  addons: [
    '@storybook/addon-a11y',  // a11y addonを追加
  ],
};

a11yチェックの設定は.storybook/preview.tsxで行っています。

// .storybook/preview.tsx
export const parameters = {
  a11y: {
    config: {
      rules: [
        {
          id: 'color-contrast',
          enabled: false, // デザイナーとの調整が必要なため一時的に無効化
        },
      ],
    },
    test: 'error', // a11yエラーをテスト失敗として扱う
  },
};

ポイントは test: 'error' の設定です。これにより、a11yエラーが検出されるとテストが失敗するようになります。

CIでの自動チェック

GitHub Actionsで、PRの作成・更新時に自動でa11yチェックを実行しています。

# .github/workflows/prtimes-ci.yaml(抜粋)
storybook:
  timeout-minutes: 20
  steps:
    - name: Checkout
      uses: actions/checkout@v5

    # 変更の影響があるstoriesを検出
    - name: Detect affected stories
      id: affected
      run: # 影響範囲検出ロジック

    - name: Build Storybook
      if: steps.affected.outputs.tests != ''
      run: pnpm sb:build

    - name: Install Playwright
      if: steps.affected.outputs.tests != ''
      run: pnpm exec playwright install --with-deps chromium

    # Vitest Addonでa11yテスト実行
    - name: Test Storybook
      if: steps.affected.outputs.tests != ''
      run: pnpm run sb:vitest:ci

効率化のポイント:影響範囲の検出

すべてのStoryをテストすると時間がかかるため、PRで変更されたファイルから影響のあるStoryを検出し、該当するStoryのみをテストしています。ただし、共通コンポーネントの変更など影響範囲が広い場合は、全Storyをテストするジョブが実行されます。

この仕組みのメリット

この仕組みにより、新規でコンポーネントを開発する際にもアクセシビリティの問題があればCIがエラーで教えてくれます。開発者がアクセシビリティを意識していなくても、Storyを書くだけで自動的にチェックされるため、問題を早期に発見できます。

レビュー時に「この実装はアクセシビリティ的に問題ないか」を人力で確認する負荷も軽減され、チーム全体でアクセシビリティの品質を維持しやすくなりました。

Storybookでのレポート確認

Storybookを起動すると「Accessibility」パネルが表示され、各Storyのエラー内容をその場で確認できます。

特定のStoryでチェックを無効化する

やむを得ず特定のStoryでa11yチェックを無効化したい場合は、パラメータを追加するだけです。

export const SkipA11y: Story = {
  parameters: {
    a11y: {
      disable: true,
    },
  },
};

特定のルールだけを無効化することもできます。

export const SkipColorContrast: Story = {
  parameters: {
    a11y: {
      config: {
        rules: [
          { id: 'color-contrast', enabled: false },
        ],
      },
    },
  },
};

test-runnerからVitest addonへの移行

実は、ここまで紹介してきた仕組みに至るまでには、試行錯誤がありました。当初は@storybook/test-runnerを使用してアクセシビリティチェックを行っていましたが、直近のリファクタリングデーでVitest addonに置き換えました。

あわせて読みたい
PR TIMESの改善文化を支えるリファクタリングデー こんにちはバックエンドエンジニアの中山です。 今回はPR TIMESで継続的に実施しているリファクタリングデーについて紹介したいと思います。PR TIMESでのリファクタリン...
Storybook
Vitest addon | Storybook docs Storybook is a frontend workshop for building UI components and pages in isolation. Thousands of teams use it for UI development, testing, and documentation. It...

test-runnerからVitest addonへ変更した理由は3つあります。

1つ目は、a11yのviolationをユニットテストとして確認できること。test-runnerはJestとPlaywrightを使用していますが、PR TIMESのユニットテストはVitestで管理しており、今回のa11yチェックもVitestで実行できるようになりました。 2つ目は、Storybook内で直接コンポーネントをテストできること。 3つ目は、Viteを利用したStorybookフレームワークを使用している場合、公式ドキュメントでtest-runnerの代わりにVitest addonの使用が推奨されていることです。PR TIMESのフロントエンドでは@storybook/react-viteをフレームワークとして採用しているため、スムーズに移行できました。

デメリットとして、axe-playwrightを使わなくなったため、深刻度の種類でエラーをフィルタリングする機能は失われました。ただし、すでに深刻度の高い問題は解消済みのため、問題がないと判断しました。

検出されたエラーと改善例

Vitest addonで取り組む中で、実際に検出されたエラーを紹介します。 色のコントラスト系のエラーはデザイナーとの調整が必要なため一時的にスキップし、それ以外はすべて修正しました。ここでは代表的なエラーと改善方法を紹介します。

重複するbannerランドマーク

プラン変更フォームのサブスクリプションプランカタログで、以下のようなコードを使用していました:

  // 修正前
  <div className={styles.catalog}>
    <header className={styles.header}>
      <span className={styles.planGroupName}>定額制</span>
      <span className={styles.unit}>30配信 / 月</span>
    </header>
    <div className={styles.body}>
      {/* プランカード */}
    </div>
  </div>

<header>要素は暗黙的にbannerロールのランドマークとして扱われます。しかし、この要素はページ全体のヘッダーではなく、プランカタログ内のラベル部分でした。bannerランドマークはページごとに1つだけ存在すべきですが、複数の<header>要素により重複が発生し、スクリーンリーダーユーザーがページ構造を正しく理解できなくなっていました。

修正方法:

セクション内のラベルとして機能するため、<header>を<div>に変更:

 // 修正後
  <div className={styles.catalog}>
    <div className={styles.header}>
      <span className={styles.planGroupName}>定額制</span>
      <span className={styles.unit}>30配信 / 月</span>
    </div>
    <div className={styles.body}>
      {/* プランカード */}
    </div>
  </div>

画像のalt属性とテキストの重複

個人ユーザーのサイドメニューで、以下のようなコードを使用していました:

 // 修正前
  <img
    src={getIconUrl(menu, isItemHeighlight(menu))}
    alt={sideMenus[menu].name}
    width={44}
    height={44}
  />
  <span className={styles.itemName}>
    {sideMenus[menu].name}
  </span>

画像のalt属性とその直後の<span>要素が同じテキスト(例: “プレスリリース受信設定”)を含んでいました。これにより、スクリーンリーダーは「プレスリリース受信設定、画像、プレスリリース受信設定」のように同じ内容を二重に読み上げてしまい、ユーザー体験を損ねていました。

修正方法:

アイコンは装飾的な画像であるため、alt属性を空文字列に変更:

 // 修正後
  <img
    src={getIconUrl(menu, isItemHeighlight(menu))}
    alt=""
    width={44}
    height={44}
  />
  <span className={styles.itemName}>
    {sideMenus[menu].name}
  </span>

react-dropzoneのrole=”presentation”とbuttonの競合

CSVインポートモーダルのドロップゾーンで、以下のようなコードを使用していました:

  // 修正前
  <button
    type='button'
    {...getRootProps()}  // react-dropzoneのprops
    className={styles.root}
    onClick={open}
  >
    {/* コンテンツ */}
  </button>

react-dropzoneライブラリのgetRootProps()は、内部的にrole=”presentation”を設定します。しかし、<button>要素はデフォルトで暗黙的にrole=”button”を持つため、これらが競合してアクセシビリティエラーが発生していました。

修正方法:

<button>から<div>に変更したことで、react-dropzoneの内部roleとの競合を回避:

 // 修正後
  <div
    {...getRootProps()}  // react-dropzoneのrole="presentation"と競合しない
    className={styles.root}
    onClick={open}
    }}
  >
    {/* コンテンツ */}
  </div>

まとめ

今回の取り組みにより、axe-coreの深刻度の高い問題をすべて解消し、Storybook経由で継続的にアクセシビリティをチェックできる体制を整えました。さらに、test-runnerからVitest addonへの移行により、より効率的なテスト環境を実現しています。 今後もアクセシビリティの改善を継続し、すべてのユーザーにとって使いやすいサービスを目指していきます。

  • URLをコピーしました!

この記事を書いた人

2026年卒予定 フロントエンドエンジニア

目次