カバレッジが失われるのはチケットの段階。ツールの段階ではない
チケットには「ニュースレターのダイアログを追加」とあります。フォーカスがダイアログの中に移ること、Escapeで閉じること、フォーカスがボタンに戻ることは書かれていません。だから誰もそのテストを書かず、スキャンは通り、障壁はそのまま公開されます。それを防ぐ6つのステップ。すべて無料です。
-
準備 · 一度だけ、コーディングエージェントに
エージェントにルールを渡す
無料のMCPサーバーを追加し、これを
CLAUDE.md、AGENTS.md、.cursor/rulesに貼り付けます。サーバーはエージェントにツールを呼ばせられませんが、エージェントが毎回読むルールならできます。UIの設計・実装・変更を頼まれたら、コードを書いたり画面を描いたりする前に、依頼内容をそのまま easeweb の before_building ツールに渡すこと。返ってきた要件は、別のアクセシビリティ用テストではなく、その機能自身のテストに入れること。作り終えたら test_steps を呼び、エージェント向けとして挙がったテストをすべて書き、「人が確認すること」はそのまま私に引き継ぐこと。[person] のテストを完了扱いにしたり、削除したりしないこと。最後の一文が最も重要です。エージェントは頼まれたことを終えたと報告します。引き継ぐよう指示された
[person]の項目だけは、黙って閉じません。 -
企画 · プロダクトオーナー
振る舞いをチケットに書く
仕事を割り当てる前に、依頼をそのままの言葉で
before_buildingにかけます。組み込むべき振る舞いが、誰が確認できるかで分かれて返ります。2つの一覧をそのままチケットに貼ります。色やデザイントークンにはcheck_paletteが同じことをします:各ペアのコントラスト比を 1.4.3 と 1.4.11 に照らし、合格する最も近い色も返します。ボタンで開くニュースレター登録のダイアログ
エージェントが確認できること
- キーボードで開くと、フォーカスがダイアログ内に移る。
- 開いている間、TabとShift + Tabで背後のページに移動しない。
- Escapeと閉じるボタンのどちらでも閉じ、フォーカスが開いたボタンに戻る。
- ダイアログにアクセシブルな名前がある。
人が確認すること
- ダイアログ内の作業に適した位置にフォーカスが入る。
- 開いたときに、スクリーンリーダーがダイアログの名前を読み上げる。
- 拡大表示や強制カラーでも、ダイアログを操作できる。
抜粋。完全な答えには参考コードも含まれます。これは開発の流れの「企画」の段階です。
-
実装 · 開発者
lint をエラーに
入力中に、フレームワークのアクセシビリティルールで。スタックに合うものを入れ、エラーに設定します:
- React, Next:
eslint-plugin-jsx-a11y。推奨ルールをエラーに - Vue:
eslint-plugin-vuejs-accessibility - Svelte: コンパイラの a11y 警告を
svelte-check --fail-on-warningsで失敗に - Angular:
@angular-eslint/templateのアクセシビリティルール
捕まえる: ソースコード上のパターン:alt属性のない画像、divに付けたクリックハンドラ、対応する欄のないラベル。
見逃す: ページを動かして初めて現れるものすべて。
- React, Next:
-
プルリクエスト · 開発者
変えた部分を、描画した状態でスキャン
Playwright と axe を入れ、デスクトップとスマートフォンの2つのプロジェクトを設定して、すべてのテストがスマートフォンの表示でも走るようにします。スキャンするのはプルリクエストで変えたもの:その部品を、開いた状態で、部品自身のテストの中で。1秒で済みます。ページ全体のスキャンは共通テンプレート向けに、種類ごとに1ページだけ。幅320pxのリフロー確認もそこで。
npm i -D @playwright/test @axe-core/playwright npx playwright install --with-deps chromium// playwright.config.js import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ use: { baseURL: process.env.BASE_URL ?? 'http://localhost:5173' }, // すべてのテストが2回走る:デスクトップの表示と、スマートフォンの表示で。 projects: [ { name: 'desktop', use: { ...devices['Desktop Chrome'] } }, { name: 'phone', use: { ...devices['Pixel 7'] } } ] });// tests/a11y/scan.spec.js import { test, expect } from '@playwright/test'; import AxeBuilder from '@axe-core/playwright'; const TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa']; const scan = (page, within) => new AxeBuilder({ page }).withTags(TAGS).include(within).analyze(); // プルリクエストで変えたもの:開いた状態の部品だけ。 test('newsletter dialog has no axe violations', async ({ page }) => { await page.goto('/'); await page.getByRole('button', { name: 'Subscribe' }).click(); const { violations } = await scan(page, '[role=dialog]'); expect(violations.map((v) => v.id)).toEqual([]); }); // 共通テンプレート:種類ごとに1ページ、静止状態で。1ページ数秒。 for (const path of ['/', '/products/example', '/checkout']) { test(`axe ${path}`, async ({ page }) => { await page.goto(path); const { violations } = await scan(page, 'body'); expect(violations.map((v) => v.id)).toEqual([]); // リフロー(WCAG 1.4.10):幅320pxで横スクロールが出ない。 await page.setViewportSize({ width: 320, height: 800 }); const over = () => document.documentElement.scrollWidth > 320; expect(await page.evaluate(over)).toBe(false); }); }捕まえる: 一般的なルールで表せるもの:名前の欠落、コントラスト不足、壊れたマークアップ。
見逃す: ダイアログがフォーカスを閉じ込めているか、エラーが読み上げられるか、名前に意味があるか。画像やグラデーションの上の文字(axe は不合格ではなく「incomplete」にします)。キーを押すことはありません。
-
プルリクエスト · 開発者またはエージェント
機能自身のテストで、チケットの振る舞いを検証
別のアクセシビリティ用テストにはしません。ビルドが遅いと飛ばされ、不安定になると消されるからです。チケットの1つ目の一覧が、そのまま検証になります。
// tests/newsletter.spec.js // 機能自身のテスト。アクセシビリティの要件もここに。 test('newsletter dialog keeps focus', async ({ page }) => { await page.goto('/'); const opener = page.getByRole('button', { name: 'Subscribe' }); await opener.focus(); await page.keyboard.press('Enter'); const dialog = page.getByRole('dialog', { name: 'Newsletter' }); await expect(dialog).toBeVisible(); // フォーカスが中に移った await expect(dialog.locator(':focus')).toHaveCount(1); // そして中に留まった for (let i = 0; i < 10; i++) await page.keyboard.press('Tab'); await expect(dialog.locator(':focus')).toHaveCount(1); // そして戻った await page.keyboard.press('Escape'); await expect(opener).toBeFocused(); });捕まえる: フォーカスの行き先、各キーの動作、変わる属性、ライブリージョンの内容。要件そのものを表せる唯一の層です。
見逃す: スクリーンリーダーが何と言うか、そしてその言葉が画面を見られない人にとって意味を成すか。
-
プルリクエスト · CI
3つすべてをプルリクエストごとに実行
一つのジョブが、プレビューURLに対して lint、スキャン、機能のテストを実行し、main ブランチの必須チェックになります。そのあと、わざとルールを一つ破るプルリクエストを開き、赤になるのを確かめます。ジョブが動いている証拠はそれだけです。
# .github/workflows/accessibility.yml name: Accessibility on: pull_request jobs: accessibility: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - uses: actions/setup-node@v5 with: { node-version: 22, cache: npm } - run: npm ci - run: npm run lint - run: npx playwright install --with-deps chromium - run: npx playwright test env: # playwright.config.js がこれを baseURL として読む。 BASE_URL: ${{ vars.PREVIEW_URL }} -
リリース前 · 人
2分の手順を行う
チケットの2つ目の一覧が、どんなテストにも扱えない部分です。
test_stepsはそれを手順として返します:どのキーを押し、何が起きるべきか。スマートフォンでも一度、VoiceOver か TalkBack で行います。デスクトップの実行では確かめられません。- ボタンでEnterを押して開く。「ニュースレターに登録、ダイアログ」と聞こえる。
- Tabで一周する。各操作部品が読み上げられ、背後のものは読まれない。
- Escapeを押す。最初のボタンがもう一度読み上げられる。
- 200%の拡大で欠ける部分がない。強制カラーでダイアログの縁とフォーカスリングが見える。
各項目は
[person]とfixmeの印が付いたテストとしても返るので、人にしか確認できないことが CI の実行のたびに一覧に出ます。削除したりfixmeを外したりせず、誰が確認したかをプルリクエストに書きます。
2つの落とし穴
緑はテスト済みではない
空のまま合格するテスト
テストを頼まれたエージェントは、ダイアログが表示されることだけを検証してチケットを完了にしがちです。test_steps の雛形はどのテストにも仮の検証を残すので、書かれなかったテストは失敗します。レビューでは、キー操作もフォーカスの検証もないテストは何も確かめていません。
静かに素通りするチェック
スキャンの対象が違う要素になっている、テストが対象のページに到達していない、ジョブが誰もマージしないブランチで動いている。ステップ6のわざと失敗させる確認で分かります。CIを変えるたびに繰り返してください。
正直に
MCPサーバーがここで果たすこと
- チケットの段階で、エージェントがARIAをうろ覚えで書くのではなく、ガイドの言葉で要件を渡します。
- エージェントが確認できることと人が確認することの線引きを、ツールの出力の中で固定します。
- 人の項目に印をつけた状態でテストの雛形を返すので、CIに自動化の隙間が見えます。
果たさないこと
- エージェントにツールを呼ばせること。それができるのはプロジェクトのルールだけです。
- 誰かに2分の手順を実行させること。
- 修正を確認すること。あなたのページを見ることはありません。
ステップ7が、私の出番です
継続サポートでは、Section 508 Trusted Tester が2分の手順と、定期的な手動での全体テストを担当します。見つかった指摘は、エージェントのルールとCIの回帰テストとして上流に戻ります。