APG Patterns
English
English

テスト戦略

APG Patterns Examples のアクセシブルなコンポーネントのテスト戦略。

設計原則: DAMP (Descriptive And Meaningful Phrases)

テストコードでは DRY より DAMP を優先 する。

なぜ DAMP か

テストは「何をテストしているか」が一目で分かることが最も重要。 抽象化によるコード削減よりも、可読性と自己完結性を優先する。

観点DRYDAMP
可読性抽象化で意図が隠れるテストを見れば分かる
デバッグ共通コードを追う必要ありそのテストだけで完結
保守性共通コード変更が全体に影響独立して変更可能
学習コスト抽象化の理解が必要コピペで追加可能

抽象化の方針

抽象化してよいもの (How-to):

  • テストユーティリティ: createMockTabs()
  • カスタムマッチャー: toHaveNoViolations()
  • セットアップ処理: 環境構築の共通処理

抽象化しないもの (What-to):

  • テストケース本体
  • アサーション(期待値は具体的に書く)
  • テストデータ(テスト内で定義)

// ❌ 抽象化しすぎ
testToggleBehavior(ToggleButton, { stateAttr: 'aria-pressed' });

// ✅ DAMP: 明示的で自己完結
it('aria-pressed が false から true に変わる', async () => {
  render(<ToggleButton>Mute</ToggleButton>);
  const button = screen.getByRole('button');

  expect(button).toHaveAttribute('aria-pressed', 'false');
  await userEvent.click(button);
  expect(button).toHaveAttribute('aria-pressed', 'true');
});

テストの2つの軸

1. 基本動作テスト

コンポーネントが正しく動作するかを検証。

  • レンダリング
  • ユーザー操作(クリック、入力)
  • 状態変化
  • コールバック呼び出し
  • props の受け渡し

2. APG 準拠テスト

WAI-ARIA APG 仕様に準拠しているかを検証。

  • ARIA 属性(role, aria-*
  • キーボード操作
  • フォーカス管理
  • axe-core 自動チェック

APG 準拠テストの観点

A. ARIA 属性

  • 正しい role が設定されている
  • 必須の aria-* 属性が存在する
  • 状態変化で aria-* 属性が更新される
  • 関連要素の参照(aria-controls, aria-labelledby)が正しい

B. キーボード操作

  • アクティベーションキー(Space, Enter)
  • ナビゲーションキー(矢印キー, Home, End)
  • 閉じる/キャンセル(Escape)
  • 特殊操作(Delete, Tab)

C. フォーカス管理

  • Roving tabindex(タブ順序に含まれるのは tabindex="0" の1要素のみ)
  • フォーカストラップ(モーダル系)
  • フォーカス復元(閉じた後の戻り先)

D. axe-core

  • WCAG 2.1 AA の自動検出可能な違反をチェック
  • axe-core で検出可能な違反がないこと

パターン間で重複するテスト観点

パターンが異なっても、同じ「観点」でテストを書くことになる。 ただし、テストコード自体は抽象化せず、各コンポーネントで明示的に書く。

例: トグル系コンポーネント

ToggleButton, Switch, Checkbox は似た動作だが、テストは個別に書く。

観点ToggleButtonSwitchCheckbox
状態属性aria-pressedaria-checkedaria-checked
アクティベーションSpace, EnterSpace, EnterSpace
状態変化true/falsetrue/falsetrue/false/mixed

例: ナビゲーション系コンポーネント

Tabs, RadioGroup, Menu は矢印キーナビゲーションを持つ。

観点TabsRadioGroupMenu
コンテナ roletablistradiogroupmenu
子要素 roletabradiomenuitem
選択属性aria-selectedaria-checked-
矢印キー← → (水平) / ↑ ↓ (垂直)← → ↑ ↓↑ ↓
ループありありあり

ファイル構成

src/patterns/
├── button/
│   ├── ToggleButton.tsx
│   └── ToggleButton.test.tsx
├── tabs/
│   ├── Tabs.tsx
│   └── Tabs.test.tsx
└── accordion/
    ├── Accordion.tsx
    └── Accordion.test.tsx

1つのテストファイルに全カテゴリをまとめる。 テストを見れば、そのコンポーネントの仕様が分かるようにする。

テストファイルの構成

リスクベース優先順位でカテゴリを整理する。

describe('ComponentName', () => {
  // 🔴 High Priority: APG 準拠の核心
  describe('APG: キーボード操作', () => {
    it('...', () => {});
  });

  describe('APG: ARIA 属性', () => {
    it('...', () => {});
  });

  // 🟡 Medium Priority: アクセシビリティ検証
  describe('アクセシビリティ', () => {
    it('axe による違反がない', async () => {});
  });

  describe('Props', () => {
    // 他でカバーされない固有の props テスト
    it('...', () => {});
  });

  // 🟢 Low Priority: 拡張性
  describe('HTML 属性継承', () => {
    it('...', () => {});
  });
});

注意: APG: ARIA 属性で状態変化(クリック、初期状態)をテストするため、 「基本動作」カテゴリは Props に限定し、重複を避ける。

新規パターン追加時のガイド

  1. APG 仕様を確認

  2. テストファイルを作成

    • 既存のテストを参考に同じ構成で作成
    • テスト名は日本語で具体的に
  3. DAMP で書く

    • 各テストは自己完結
    • 重複を恐れず、明確さを優先

マルチフレームワークテスト

方針

各フレームワーク(React, Vue, Svelte, Astro)で 独立したテストファイル を持つ。 テスト観点は共通だが、テストコード自体は DAMP 原則に従い各フレームワークで明示的に書く。

src/patterns/button/
├── ToggleButton.tsx
├── ToggleButton.test.tsx        # React ユニットテスト
├── ToggleButton.vue
├── ToggleButton.test.vue.ts     # Vue ユニットテスト
├── ToggleButton.svelte
├── ToggleButton.test.svelte.ts  # Svelte ユニットテスト
├── ToggleButton.astro
└── ToggleButton.test.astro.ts   # Astro ユニットテスト(Container API)

e2e/
└── table-visual-spanning.spec.ts  # E2E テスト(全フレームワーク共通)

テストの種類

テスト種別対象ツール実行コマンド
ユニットReact/Vue/Svelte@testing-library + jsdomnpm run test:unit
ユニットAstroContainer API + JSDOMnpm run test:astro
E2E全フレームワークPlaywrightnpm run test:e2e

フレームワーク別ユニットテスト

フレームワークテストライブラリ実行環境コマンド
React@testing-library/reactVitest + jsdomnpm run test:react
Vue@testing-library/vueVitest + jsdomnpm run test:vue
Svelte@testing-library/svelteVitest + jsdomnpm run test:svelte
AstroContainer APIVitest + JSDOMnpm run test:astro

E2E テスト

E2E テストは e2e/ ディレクトリに配置し、Playwright で全フレームワークを横断的にテストする。 視覚的なレンダリング(CSS Grid のスパン表示など)や実際のブラウザ動作を検証する場合に使用。

// e2e/table-visual-spanning.spec.ts
const frameworks = ['react', 'vue', 'svelte', 'astro'] as const;

for (const framework of frameworks) {
  test.describe(`Table Visual Spanning (${framework})`, () => {
    test.beforeEach(async ({ page }) => {
      await page.goto(`/patterns/table/${framework}/`);
    });
    // ...
  });
}

Astro Web Component パターンの2層テスト戦略

Astro コンポーネントが Web Component(<script> 内の class extends HTMLElement)を使用する場合、 テストを 2層 に分離する必要がある。

なぜ2層か

Astro コンポーネントは以下の2つの部分で構成される:

  1. テンプレート部分 - サーバーサイドでレンダリングされる HTML 出力
  2. Web Component 部分 - クライアントサイドで実行される JavaScript

Container API はテンプレート出力をサーバー側でレンダリングし、ブラウザ側の Web Component スクリプトを実行しない。加えて、素の Node.js 環境では HTMLElement などのブラウザグローバルが 利用できない。クライアントサイドの Web Component 動作は E2E テストで検証する。

テスト分離の方針

テスト層対象ツールテスト内容
Unit (Container API)テンプレート出力Vitest + JSDOMHTML構造、属性、CSSクラス
E2E (Playwright)Web Component 動作Playwrightクリック、キーボード、イベント、フォーカス

Container API でテストできるもの

  • HTML 要素の存在と階層構造
  • 初期属性値(checked, disabled, aria-* など)
  • props から生成される属性
  • CSS クラスの適用
  • 条件付きレンダリング

E2E でテストすべきもの

  • クリック・キーボード操作による状態変更
  • カスタムイベントのディスパッチ
  • indeterminate など JavaScript で設定される状態
  • フォーカス管理とタブナビゲーション
  • ラベルクリックによるトグル動作

例: Checkbox

src/patterns/checkbox/
├── Checkbox.astro           # コンポーネント本体
├── Checkbox.test.astro.ts   # Container API テスト(テンプレート出力)

e2e/
└── checkbox.spec.ts         # E2E テスト(Web Component 動作)

Container API テスト(テンプレート出力の検証):

// Checkbox.test.astro.ts
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import Checkbox from './Checkbox.astro';

describe('Checkbox (Astro Container API)', () => {
  let container: AstroContainer;

  beforeEach(async () => {
    container = await AstroContainer.create();
  });

  it('renders input with type="checkbox"', async () => {
    const html = await container.renderToString(Checkbox, { props: {} });
    const doc = new JSDOM(html).window.document;
    expect(doc.querySelector('input[type="checkbox"]')).not.toBeNull();
  });

  it('renders with checked attribute when initialChecked is true', async () => {
    const html = await container.renderToString(Checkbox, {
      props: { initialChecked: true }
    });
    const doc = new JSDOM(html).window.document;
    expect(doc.querySelector('input')?.hasAttribute('checked')).toBe(true);
  });
});

E2E テスト(Web Component 動作の検証):

// e2e/checkbox.spec.ts
const frameworks = ['react', 'vue', 'svelte', 'astro'] as const;

for (const framework of frameworks) {
  test.describe(`Checkbox (${framework})`, () => {
    // Helper to get checkbox and its visual control
    const getCheckbox = (page, id: string) => {
      const checkbox = page.locator(`#${id}`);
      // The visual control is a sibling of the input
      const control = checkbox.locator('~ .apg-checkbox-control');
      return { checkbox, control };
    };

    test('toggles checked state on click', async ({ page }) => {
      await page.goto(`patterns/checkbox/${framework}/`);
      // Note: The input is visually hidden (1x1px), so we click
      // the visual control instead of the input directly
      const { checkbox, control } = getCheckbox(page, 'demo-terms');

      await expect(checkbox).not.toBeChecked();
      await control.click();
      await expect(checkbox).toBeChecked();
    });

    test('clicking label toggles checkbox', async ({ page }) => {
      // Label association test - clicks label, not the control
      const { checkbox } = getCheckbox(page, 'demo-terms');
      const label = page.locator('label').filter({ has: checkbox });

      await expect(checkbox).not.toBeChecked();
      await label.click();
      await expect(checkbox).toBeChecked();
    });

    test('dispatches checkedchange event', async ({ page }) => {
      // カスタムイベントのテストは E2E で行う(Astro only)
    });
  });
}

Web Component を使わない Astro パターン

Table のように Web Component を使わず、純粋なテンプレートのみのパターンは Container API テストのみで十分にカバーできる。

なぜ独立したテストか

  1. DAMP 原則に従う - 各テストが自己完結
  2. フレームワーク固有の問題を即座に特定 - Vue の v-bind / Svelte の $props() など
  3. 並列実行可能 - CI で効率的に実行
  4. 学習リソースとして有用 - 各フレームワークのテスト手法を示す

デモ単独ページ(E2E テスト用)

一部のパターン(特に Landmarks など)では、コンポーネントがページレイアウト内に埋め込まれると セマンティクスが変わる場合がある。例えば <header> 要素は、ページの <main> 内にネストされると 暗黙的な banner ロールを失う。

このようなパターンでは、デモ単独ページ を作成する。このページは:

  • E2E テストで正確なセマンティクスを検証するために使用
  • ユーザーがデモのみを確認するためのリンクとしても提供

ディレクトリ構成

src/pages/patterns/landmarks/
├── react/
│   ├── index.astro      # 通常のパターンページ(レイアウト付き)
│   └── demo/
│       └── index.astro  # デモ単独ページ(レイアウトなし)
├── vue/
│   ├── index.astro
│   └── demo/
│       └── index.astro
├── svelte/
│   ├── index.astro
│   └── demo/
│       └── index.astro
└── astro/
    ├── index.astro
    └── demo/
        └── index.astro

デモ単独ページの実装

---
// src/pages/patterns/landmarks/react/demo/index.astro
/**
 * Demo-only Page: LandmarkDemo (React)
 *
 * This page renders the LandmarkDemo component in isolation without
 * the site layout. This ensures proper landmark semantics are preserved
 * (e.g., <header> retains its implicit banner role).
 *
 * Used for:
 * - E2E testing with correct landmark structure
 * - Standalone demo viewing
 */
import '@/styles/global.css';
import LandmarkDemo from '@patterns/landmarks/LandmarkDemo.tsx';
---

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta name="robots" content="noindex, nofollow" />
    <title>Demo: Landmarks (React)</title>
  </head>
  <body>
    <LandmarkDemo client:load showLabels={true} />
  </body>
</html>

パターンページからデモ単独ページへのリンク

パターンページのデモセクションに「Open demo only」リンクを追加する:

<!-- Demo Section -->
<section class="mb-12">
  <Heading level={2} class="mb-4 text-xl font-semibold">Demo</Heading>
  <p class="text-muted-foreground mb-4">
    This demo visualizes the 8 landmark regions with distinct colored borders.
  </p>
  <div class="border-border bg-background rounded-lg border p-6">
    <LandmarkDemo showLabels={true} />
  </div>
  <p class="text-muted-foreground mt-2 text-sm">
    <a href="./demo/" class="text-primary hover:underline">Open demo only →</a>
  </p>
</section>

E2E テストのパス

// e2e/landmarks.spec.ts
for (const framework of frameworks) {
  test.describe(`Landmarks (${framework})`, () => {
    test.beforeEach(async ({ page }) => {
      // デモ単独ページを使用
      await page.goto(`patterns/landmarks/${framework}/demo/`);
    });
    // ...
  });
}

このアプローチを使うべきパターン

パターン理由
Landmarks<header>/<footer> がページ <main> 内でロールを失う
Dialogフォーカストラップがページ要素と干渉する可能性
その他ページレイアウトがコンポーネントの動作に影響する場合

注意事項

  • デモ単独ページは robots: noindex, nofollow メタタグで検索エンジンから除外
  • パス構造 /patterns/{pattern}/{framework}/demo/ は URL として意味が明確
  • パターンページから「Open demo only」リンクでアクセス可能
  • CI 時間を増やさない(別ビルド不要)ため、このアプローチを推奨

共通のテスト観点

フレームワークが異なっても、APG 準拠コンポーネントは同じ観点でテストする。

ToggleButton

カテゴリテスト観点
🔴 APG: キーボードSpace でトグル、Enter でトグル、Tab でフォーカス移動
🔴 APG: ARIArole=“button”、aria-pressed の状態変化、type=“button”
🟡 アクセシビリティaxe 違反なし、アクセシブルネーム
PropsinitialPressed、onPressedChange
🟢 HTML 属性継承className マージ、data-* 継承

Tabs

カテゴリテスト観点
🔴 APG: キーボードArrow でナビゲーション、Home/End、ループ、手動アクティベーション
🔴 APG: ARIArole=“tablist/tab/tabpanel”、aria-selected、aria-controls/labelledby
🔴 フォーカス管理Roving tabindex、Tab でパネルへ移動
🟡 アクセシビリティaxe 違反なし
PropsdefaultSelectedId、orientation、activationMode
🟢 HTML 属性継承className 適用

Accordion

カテゴリテスト観点
🔴 APG: キーボードEnter/Space で開閉(ヘッダー間は Tab 順序で移動)
🔴 APG: ARIAaria-expanded、aria-controls/labelledby、role=“region” の条件
🔴 見出し構造headingLevel で h2-h6
🟡 アクセシビリティaxe 違反なし
PropsdefaultExpanded、allowMultiple
🟢 HTML 属性継承className 適用

E2E テストの安定化

E2E テストは実際のブラウザで動作するため、タイミングに依存したフラキーテストが発生しやすい。 以下のパターンと対策を把握しておくことで、安定したテストを書ける。

フラキーテストの原因と対策

1. フォーカスレース問題

問題: click() の後に page.keyboard.press() を使うと、フォーカスが一時的に別の要素に移動し、 キーイベントが意図しない要素に送信されることがある。

// ❌ フラキー: click() 後のフォーカスが不安定
await secondTab.click();
await page.keyboard.press('ArrowLeft'); // フォーカスが secondTab にない可能性

// ✅ 安定: locator.press() を使用
await secondTab.click();
await secondTab.press('ArrowLeft'); // locator にフォーカスしてキーを送信

対策:

  • page.keyboard.press() の代わりに locator.press() を使用する
  • locator.press() は対象の locator にフォーカスしてからキーイベントを送信するため、その時点でフォーカスを持つ要素への依存を減らせる

2. 選択されていないタブへの focus()

問題: Roving tabindex パターンでは、選択されていない要素は tabindex="-1" を持つ。 これらの要素に focus() を呼んでも、コンポーネントの内部状態と同期せず期待通りに動作しないことがある。

// ❌ 不安定: tabindex="-1" の要素への focus()
const secondTab = tabs.getByRole('tab').nth(1); // tabindex="-1"
await secondTab.focus(); // 動作が不安定
await page.keyboard.press('ArrowLeft');

// ✅ 安定: キーボードナビゲーションで移動
const firstTab = tabs.getByRole('tab').first(); // tabindex="0" (選択済み)
await firstTab.focus();
await expect(firstTab).toBeFocused();
await firstTab.press('ArrowRight'); // キーボードで secondTab に移動
await expect(secondTab).toBeFocused();
await secondTab.press('ArrowLeft'); // 目的の操作をテスト

対策:

  • これらのテストではまずアクティブなタブ(tabindex="0")にフォーカスし、キーボードナビゲーションで目的のタブに移動する
  • プログラムによるフォーカスを意図的に検証する場合を除き、tabindex="-1" のタブへ直接フォーカスするのは避ける

3. ハイドレーション待機の不足

問題: フレームワークコンポーネント(特に React)は、HTML がレンダリングされた後も JavaScript のハイドレーションが完了するまでインタラクティブにならない。

// ❌ 不十分: 要素の存在だけを待機
await getTabs(page).first().waitFor();

// ✅ 十分: ハイドレーション完了を示す属性も確認
await getTabs(page).first().waitFor();
const firstTab = getTabButtons(page).first();
// ID が正しく設定されているか(このアプリ固有のハイドレーション指標)
await expect.poll(async () => {
  const id = await firstTab.getAttribute('id');
  return id && id.length > 1 && !id.startsWith('-');
}).toBe(true);
// インタラクティブな状態か
await expect(firstTab).toHaveAttribute('tabindex', '0');
await expect(firstTab).toHaveAttribute('aria-selected', 'true');

対策:

  • beforeEach でハイドレーション完了を示す属性(id, aria-controls など)を待機する
  • 初期状態の ARIA 属性(tabindex, aria-selected など)が設定されていることを確認する

ユニットテスト vs E2E の使い分け

jsdom/happy-dom 環境はフォーカス操作が実際のブラウザと異なる挙動を示すことがある。

テスト対象推奨環境理由
ARIA 属性の初期値ユニットDOM 操作のみで検証可能
キーボードナビゲーションE2Eフォーカス移動が正確
フォーカストラップE2ETab キーの挙動が jsdom で不安定
フォーカス復元E2E複雑なフォーカス管理は実ブラウザで検証

: フォーカストラップのテストが jsdom でフラキーな場合

// ユニットテストで削除し、E2E でカバー
// src/patterns/alert-dialog/AlertDialog.test.vue.ts

// Note: "Tab が最後から最初にループする" テストは E2E で担保
// (e2e/alert-dialog.spec.ts: "Tab wraps from last to first element")
// jsdom 環境でのフォーカス操作が不安定なため、Unit test からは削除

Playwright キーボード操作のベストプラクティス

メソッド用途安定性
locator.focus()locator にフォーカスを設定tabindex="0" の要素に適する
locator.press(key)locator にフォーカスしてキーを送信✅ 安定
locator.click()locator をクリック⚠️ フォーカス移動後の keyboard.press() に注意
page.keyboard.press(key)現在のフォーカス要素にキーを送信⚠️ フォーカスレースの可能性

推奨パターン:

// キーボードナビゲーションのテスト
test('ArrowRight moves focus to next tab', async ({ page }) => {
  const firstTab = tabs.getByRole('tab').first();
  const secondTab = tabs.getByRole('tab').nth(1);

  // 1. 選択済み要素にフォーカス
  await firstTab.focus();
  await expect(firstTab).toBeFocused();

  // 2. locator.press() でキー送信
  await firstTab.press('ArrowRight');

  // 3. フォーカス移動を検証
  await expect(secondTab).toBeFocused();
});

使用ツール

ツール用途
Vitestテストランナー
@testing-library/reactReact コンポーネントテスト
@testing-library/vueVue コンポーネントテスト
@testing-library/svelteSvelte コンポーネントテスト
@testing-library/user-eventユーザー操作
@testing-library/jest-domカスタムマッチャー
jest-axeアクセシビリティ自動テスト
Astro Container APIAstro コンポーネントテスト
PlaywrightE2E テスト(全フレームワーク)
@vitest/coverage-v8カバレッジ測定

セットアップファイル

src/test/setup.ts でマッチャーを拡張:

import '@testing-library/jest-dom/vitest';
import { toHaveNoViolations } from 'jest-axe';
import { expect } from 'vitest';

expect.extend(toHaveNoViolations);

参考リンク