Claude Media
Claude CodeでPlaywrightとaxeのアクセシビリティ検査をPRで落とす

Claude CodeでPlaywrightとaxeのアクセシビリティ検査をPRで落とす

Playwrightと @axe-core/playwrightの検査をPRごとのCIに載せ、違反をClaude Codeが読める要約にして直させる手順です。既存の違反は指紋の比較で許容します。

アクセシビリティ検査をPRで落とすなら、Playwrightのテストの中でaxe-coreを走らせるのが最短です。違反があればテストが失敗し、CIが赤くなります。Claude Codeには、失敗した検査の要約を読ませて直させます。

ただし、検査を足すだけでは運用に乗りません。既存ページに違反が山ほどあれば、初日からCIが赤いままです。違反のJSONは長く、そのまま読ませるとコンテキストを食います。この記事は、その2点を越える構成を順に組みます。

@axe-core/playwrightで最初のテストを書く

必要なのは @playwright/test と @axe-core/playwright の2つです。後者がaxeの検査エンジンをPlaywrightから呼ぶ橋渡しをします。

npm install --save-dev @playwright/test @axe-core/playwright
npx playwright install --with-deps

テストの骨格はPlaywrightの標準的な書き方のままです。ページを開き、AxeBuilder の analyze() を呼び、violations が空であることを確かめます。analyze() は呼んだ時点のページ状態を検査します。メニューを開いたあとの領域を調べたいときは、操作して対象が現れるのを待ってから呼びます。待たずに呼ぶと、期待した要素が検査対象に入らないことがあります。

プロジェクトで共通の設定は、フィクスチャにまとめておくと各テストが短くなります。次はPlaywrightのドキュメントにある makeAxeBuilder の形です。

// tests/a11y/axe-test.ts
import { test as base } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
 
type AxeFixture = { makeAxeBuilder: () => AxeBuilder };
 
export const test = base.extend<AxeFixture>({
  makeAxeBuilder: async ({ page }, use) => {
    await use(() =>
      new AxeBuilder({ page })
        .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
    );
  },
});
export { expect } from '@playwright/test';

withTags() に渡すタグで検査する規則を絞れます。axe-coreの規則は、WCAGの版と適合レベルを示すタグを1つ持ちます。WCAGに属さない規則は best-practice タグです。上の4つなら、WCAG 2.0と2.1のレベルAとAAに対応する規則だけが走ります。WCAG 2.2のAAまで見たければ wcag22aa を足します。

best-practice を外すかどうかは、PRで落とす基準に直結します。WCAG上の要件ではない指摘でPRが止まると、開発側の納得が得にくくなります。最初はWCAGのタグだけで始め、運用が安定してから広げる順番が現実的です。

違反をClaudeが読める短さに要約する

violations の配列をそのまま失敗メッセージに流すと、各ノードのHTML断片まで含んだ長大な出力になります。axeの結果の各違反には、規則の id、深刻度を示す impact、短い説明の help、詳細ページへの helpUrl、該当要素の nodes が入っています。impact は minor / moderate / serious / critical のいずれかです。

Claudeが修正に使うのは、どの規則が、どの要素で、どの深刻度かという骨格です。そこで要約を1つの関数に集めます。

// tests/a11y/axe-utils.ts
import type { AxeResults } from 'axe-core';
 
export function summarize(results: AxeResults): string {
  return results.violations
    .map((v) => {
      const targets = v.nodes.map((n) => `  - ${n.target.join(' ')}`).join('\n');
      return `${v.id} [${v.impact}] ${v.help}\n${targets}\n  詳細: ${v.helpUrl}`;
    })
    .join('\n');
}
 
// 要素の実装詳細(HTML断片)を含めず、規則とセレクタだけで識別する
export function fingerprints(results: AxeResults): string {
  return JSON.stringify(
    results.violations.map((v) => ({
      rule: v.id,
      targets: v.nodes.map((n) => n.target),
    })),
    null,
    2,
  );
}

テスト本体は、完全な結果をJSONの添付として残しつつ、アサーションには要約を使います。

// tests/a11y/pages.a11y.spec.ts
import { test, expect } from './axe-test';
import { summarize } from './axe-utils';
 
for (const path of ['/', '/pricing', '/login']) {
  test(`a11y: ${path}`, async ({ page, makeAxeBuilder }, testInfo) => {
    await page.goto(path);
    await page.getByRole('main').waitFor();
 
    const results = await makeAxeBuilder().analyze();
    await testInfo.attach('axe-results', {
      body: JSON.stringify(results, null, 2),
      contentType: 'application/json',
    });
    expect(summarize(results), 'axeの違反').toBe('');
  });
}

testInfo.attach() で結果全体を添付しておくと、レポーターが出力にリンクまたは埋め込みます。合格した規則や要確認の項目まで含む全量が手元に残るので、人間が詳細を追いたいときはここを見ます。Claudeに渡すのは短い要約だけです。

失敗時の出力は、例えば次のような形になります(形のイメージです。規則名と要素は検査対象次第で変わります)。

axeの違反
  button-name [critical] Buttons must have discernible text
    - #checkout > button.icon-only
    詳細: https://dequeuniversity.com/rules/axe/...

既存の違反を抱えたまま始める

古いプロジェクトに導入する場面では、全ページに違反がある前提で考えます。選択肢は3つあり、それぞれ副作用が違います。

方法効果副作用
exclude()効果指定要素と子孫を検査から外す副作用その要素では全規則が止まる
disableRules()効果規則単位で止める副作用全ページでその規則が見えなくなる
違反の指紋を保存効果既知の違反だけを許容する副作用スナップショットの更新が要る

Playwrightのドキュメントも、exclude() は最も簡単だが子孫を丸ごと除外すること、全規則が止まることを欠点として挙げています。PRで落とす運用では、3つ目の指紋方式が筋の良い選択です。新しい違反だけを検出できるからです。

ドキュメントは、violations 配列全体をスナップショットにするのは壊れやすいと注意しています。配列にはレンダリング済みHTMLの断片のような実装詳細が入り、無関係な変更でも差分が出るためです。代わりに、規則IDと要素のセレクタだけの指紋を保存します。先ほどの fingerprints() がそれです。

expect(fingerprints(results)).toMatchSnapshot(`${path.replace(/\W/g, '_')}.json`);

初回は npx playwright test tests/a11y --update-snapshots でベースラインを書き出し、コミットします。以降は、新しい違反が増えればPRが落ちます。

ここに1つ落とし穴があります。スナップショットは完全一致で比べるので、違反を直した場合も差分になり、テストは失敗します。直したPRではスナップショットを更新してコミットする、という流れを運用ルールに入れておきます。

この更新をClaudeに任せると、危険な近道が生まれます。テストが落ちたとき、原因を直さずに --update-snapshots で通してしまう動きです。新しい違反をベースラインに取り込めば、CIは緑になります。次の節のhookで、この近道を塞ぎます。

Claude Codeに直させるループとhook

人間が直すのでも、Claudeが直すのでも、手順は同じです。Claude Code側では次の流れになります。

手順

検査から修正までのループ

  1. 1

    a11yテストだけを実行する

    npx playwright test tests/a11y を走らせ、失敗した要約を受け取ります。

  2. 2

    該当コンポーネントを直す

    要約の規則IDとセレクタから、ソース上のコンポーネントを特定して修正します。

  3. 3

    同じコマンドで再実行する

    違反が消えたことを、要約が空になるまで確かめます。

  4. 4

    残った違反だけを報告する

    直せなかったものは、規則IDと理由を添えて報告します。

この流れをClaudeに守らせる最も軽い方法は、CLAUDE.mdに書くことです。

## アクセシビリティ検査
- UIを変更したら `npx playwright test tests/a11y` を実行し、失敗を直す
- 違反は、マークアップの修正で解消する。`exclude()` と `disableRules()` を足して通さない
- `--update-snapshots` は、違反が減ったときだけ実行し、変更内容を報告する

CLAUDE.mdは助言であり、強制ではありません。強制したい部分はhookで担保します。

Stopフックで終了前に検査を走らせる

Claudeがターンを終えようとする時点で検査を走らせ、失敗なら終了を止める構成です。Stop イベントで終了コード2を返すと、Claudeの停止が防がれ、会話が続きます。標準エラーに書いた内容が、続行の理由としてClaudeに渡ります。

#!/bin/bash
# .claude/hooks/a11y-gate.sh
INPUT=$(cat)
# 指摘を受けて続行中なら素通しする(無限ループ防止)
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
  exit 0
fi
 
OUT=$(npx playwright test tests/a11y --reporter=line 2>&1)
if [ $? -ne 0 ]; then
  echo "アクセシビリティ検査が失敗しました。要約:" >&2
  echo "$OUT" | tail -n 40 >&2
  exit 2
fi
exit 0

stop_hook_active の確認は欠かせません。Claude Codeは、Claudeのツール呼び出しを挟まずにStopフックが8回連続でブロックすると、そのフックを上書きして終了を許します。自分で素通しの条件を持たないスクリプトは、上限に当たるまで同じ失敗を繰り返します。収束に8回を超える反復が必要なら、CLAUDE_CODE_STOP_HOOK_BLOCK_CAP で上限を上げられます。

Stopフックはターンの終わりごとに発火します。テストが数十秒かかる環境では、軽い質問への返答のたびに待たされます。UIを触っていないターンでは、git diff --name-only で対象ファイルの変更を調べ、無ければ即座に exit 0 する分岐を足すと待ち時間を抑えられます。

Stopフックの書き方と非同期版のPostToolUseフックは、ClaudeでPlaywrightのE2Eテストをhookで自動実行する手順で詳しく扱っています。ここではa11y専用に絞った使い方に留めます。

スナップショットの書き換えをPreToolUseで止める

先ほどの近道を、ファイルの保護で塞ぎます。hooksガイドの「保護対象ファイルへの編集をブロックする」例と同じ形で、PreToolUse フックが終了コード2を返すと、そのツール呼び出しは実行されません。標準エラーの文面はClaudeに戻るので、なぜ止まったかを理解して別の手を探せます。

#!/bin/bash
# .claude/hooks/protect-a11y-baseline.sh
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
 
if [[ "$FILE_PATH" == *"tests/a11y/"*"-snapshots/"* ]]; then
  echo "Blocked: a11yのベースライン $FILE_PATH は直接編集できません。" >&2
  echo "違反を修正してください。ベースライン更新は人間が行います。" >&2
  exit 2
fi
exit 0
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-a11y-baseline.sh" }
        ]
      }
    ]
  }
}

このフックが止めるのは Edit と Write ツールだけです。Claudeが Bash で --update-snapshots を実行する経路は止まりません。その経路まで閉じたいなら、Bash に対するPreToolUseフックで jq -r '.tool_input.command' を調べ、update-snapshots を含むコマンドを拒否する第2のフックを足します。

PRごとにCIで落とす

CIは、Playwrightのドキュメントにある標準のGitHub Actionsの形から始めます。pull_request で起動し、依存を入れ、ブラウザを --with-deps 付きで入れ、テストを走らせ、レポートをアーティファクトに残します。a11yだけを独立したジョブに切り出すと、PRの画面で「a11y」のチェックが単独で赤くなり、原因が見えます。

# .github/workflows/a11y.yml
name: a11y
on:
  pull_request:
    branches: [ main ]
jobs:
  axe:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v6
        with:
          node-version: lts/*
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npx playwright test tests/a11y
      - uses: actions/upload-artifact@v5
        if: ${{ !cancelled() }}
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 30

この定義は、Playwrightのドキュメントのワークフロー例を、a11yのテストだけに絞った形です。ブランチ保護の設定で、このジョブを必須のステータスチェックに指定すると、違反を含むPRはマージできなくなります。

レポートのアーティファクトには、先ほど添付した axe-results のJSONが入ります。PRが落ちたとき、人間は要約で当たりを付け、詳細が要るときだけJSONを開きます。

自動検査が見つけないもの

axeが拾えるのは、色のコントラスト不足、ラベルのないフォーム部品、重複IDのような機械判定できる問題です。Playwrightのドキュメントは、自動テストが検出できるのは一部であり、多くの問題は手動のテストでしか見つからないと明記しています。手動の評価や、実際のユーザーによるテストとの併用が前提です。

結果には violations のほかに incomplete があります。これは「合格とも不合格とも断定できなかった」項目で、人間の確認に回すための区分です。上のテストは violations だけを見ているので、incomplete に溜まった項目はCIでは見えません。incomplete の件数をレポートに出す、という拡張は有効ですが、落とす条件には向きません。断定できない項目で毎回PRを止めると、誤検知への不信が先に育つからです。

つまずきやすい点を、症状別にまとめます。

  • ローカルでは通るのにCIで落ちる: 検査前にページの状態が整っていないことが多いです。analyze() の前に、対象の要素が現れるまで待つ処理を入れます。
  • 指紋が毎回変わる: セレクタに動的なクラス名やIDが入っています。data-testid を持たせた要素に絞るか、include() で検査範囲を限定します。
  • Stopフックが長い: a11yテストに全ブラウザを使う必要は薄く、1つに絞ると速くなります。
  • フックが止まらない: PostToolUse フックはツール実行後に発火するため、実行済みの操作を取り消せません。止めたい操作は PreToolUse で扱います。

隣接する手段との使い分け

同じ「アクセシビリティ検査」でも、道具によって役割が違います。

Playwrightとaxe-coreの構成は、コードとして残り、CIで再現できます。PRごとの合否判定に向くのはこの方式です。一方、Axe Accessibilityプラグインを使う方式は、Claude Codeのセッション内で対話的に検査と修正を回す使い方です。手順はClaude CodeのAxe Accessibilityプラグインでアクセシビリティ検査を回すにまとめています。

WCAGの観点でコードやデザインを監査したい場面には、スキルによるレビューが合います。Claudeのaccessibility-reviewスキルでWCAG 2.1 AA監査を回すが、その使い方です。

CIでE2Eを回す土台そのものを整えたいときは、Claude Codeセルフホスト環境をCIでE2Eテストするが近い話題です。

まとめ

PRで落とす仕組みの核は、3つの部品です。@axe-core/playwright のテスト、規則IDとセレクタだけの短い要約と指紋、pull_request で動く必須チェックです。既存の違反はスナップショットで許容し、新規の違反だけを止めます。

Claude Codeの側では、CLAUDE.mdに修正の作法を書き、Stopフックで検査の実行を担保し、PreToolUseフックでベースラインの書き換えを止めます。自動検査は問題の一部しか見ません。最終的な適合の判断には、手動の確認が残ります。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/hooks-guide
  2. playwright.dev/docs/accessibility-testing
  3. playwright.dev/docs/ci
  4. github.com/dequelabs/axe-core/blob/develop/doc/API.md
  5. github.com/dequelabs/axe-core-npm/tree/develop/packages/playwright
この記事を共有:XはてブLinkedIn