php elephant sticker
Photo by RealToughCandy.com on Pexels.com
目次

【現場完全ガイド】Laravelのテスト自動化とアクセシビリティ検証――Pest/PHPUnit・Feature/E2E・Dusk・axe/Pa11y・CI整備まで

この記事で学べること(要点)

  • Pest/PHPUnit の使い分け、Feature/Unit/Browser(Dusk)のテスト設計
  • ファクトリ・シーディング・並列実行・モック/フェイク(Mail/Queue/Notification/Event)の実務パターン
  • アクセシビリティの自動検査(axe-core/Pa11y)を Dusk/CI と連携する方法
  • 画面遷移・フォーカス・ライブリージョン・キーボード操作のE2E検証
  • カバレッジ・失敗スクリーンショット・テストデータ方針・高速化のコツ
  • GitHub Actions を例にしたCI/CD パイプライン雛形

想定読者(だれが得をする?)

  • Laravel 初〜中級エンジニア:テストの全体像を掴み、失敗に強いプロジェクトへ育てたい方
  • テックリード/QA:E2Eとアクセシビリティ検査を毎プルリクで回したい方
  • デザイナー/ライター:UI文言やエラーメッセージの読み上げ保証を仕組みに落とし込みたい方

アクセシビリティレベル:★★★★★

Dusk と axe/Pa11y を併用し、role="status"/aria-live/aria-describedby/フォーカス移動/キーボード操作の達成確認まで自動化。失敗時の画面キャプチャとログ採取を CI に組み込みます。


1. はじめに:テストは「壊れにくさ」と「説明責任」を担保します

テストの目的は 仕様の固定化安心して変更するための網 を用意することです。Laravel は HTTP テスト・データベースの巻き戻し・各種フェイク・ブラウザ操作(Dusk)を標準で提供します。ここにアクセシビリティ検査を重ねると、見える・読める・操作できるを継続的に保証できます。まず地図を描いてから、少しずつ整備していきましょう。


2. 全体設計:レイヤ別テストの役割

  • Unit:小さな関数・クラスの純粋ロジック。I/O を避け、実行はミリ秒。
  • Feature(HTTP/DB):ルート・バリデーション・認可・DB 反映・通知などユースケース
  • Browser(Dusk):本物のブラウザで画面操作、フォーカス・キー操作・読み上げを確認。
  • A11y 自動検査:axe/Pa11y を Dusk/CI に統合し、基本的な欠陥を検出。
  • 契約/スナップショット:API スキーマ(OpenAPI)や UI テキストの破壊的変更を検知。

現場では Feature 7割 / Unit 2割 / E2E 1割 程度の比率で始めると、コストに対して効果が出やすいです。


3. セットアップ:Pest・並列・DB・ファクトリ

3.1 Pest の導入(任意)

composer require pestphp/pest --dev
php artisan pest:install
  • tests/Featuretests/Unit関数型テスト を書けます。PHPUnit と共存可能。

3.2 テスト用 DB と並列

php artisan test --parallel
# あるいは
php artisan test --parallel --processes=4
  • RefreshDatabase トレイトでトランザクション巻き戻しまたはマイグレーション実行
  • 並列時は in-memory SQLiteテスト用 MySQL の各プロセス分 DB を作成。

3.3 ファクトリと状態遷移

// database/factories/PostFactory.php
$factory->define(Post::class, fn() => [
  'title' => fake()->sentence(),
  'body'  => fake()->paragraph(),
  'status'=> 'draft',
  'published_at' => null,
]);

// 状態
public function published(): static {
  return $this->state(fn() => ['status'=>'published','published_at'=>now()]);
}
  • **状態(state)**で下書き/公開などを明示。テストが読みやすくなります。

4. Feature テスト:ユースケースを固定化する

4.1 バリデーションと認可

use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

test('記事を作成できる', function () {
  $user = User::factory()->create();
  $this->actingAs($user);

  $res = $this->post('/posts', ['title'=>'はじめて','body'=>'本文']);
  $res->assertRedirect('/posts');

  $this->assertDatabaseHas('posts', ['title'=>'はじめて','user_id'=>$user->id]);
});

test('未ログインは作成できない', function () {
  $this->post('/posts', ['title'=>'x','body'=>'y'])->assertRedirect('/login');
});

4.2 JSON API(Sanctum)

test('APIで一覧を取得できる', function () {
  Sanctum::actingAs(User::factory()->create(), ['posts:read']);
  Post::factory()->count(3)->published()->create();

  $this->getJson('/api/v1/posts')
      ->assertOk()
      ->assertJsonCount(3, 'data')
      ->assertJsonStructure(['data'=>[['id','title','author'=>['id','name']]]]);
});

4.3 フェイクで副作用を検証

test('作成時に通知が送られる', function () {
  Notification::fake();

  $user = User::factory()->create();
  $this->actingAs($user)->post('/posts', ['title'=>'x','body'=>'y']);

  Notification::assertSentTo($user, \App\Notifications\PostPublished::class);
});

4.4 署名付きURL・レート制限

test('期限切れURLは拒否', function () {
  $url = URL::temporarySignedRoute('files.show', now()->subMinute(), ['id'=>1]);
  $this->get($url)->assertForbidden();
});

test('コメント投稿はスロットリング', function () {
  RateLimiter::for('comment', fn()=>[Limit::perMinute(5)->by('t')]);
  $this->actingAs(User::factory()->create());

  for($i=0;$i<5;$i++){ $this->post('/comments', ['body'=>'hi']); }
  $this->post('/comments', ['body'=>'hi'])->assertStatus(429);
});

5. ブラウザテスト(Dusk):操作と読み上げを“体感”で守る

5.1 セットアップ

php artisan dusk:install
php artisan dusk
  • Chromedriver を同梱。CI では headless で実行。

5.2 基本の流れ

// tests/Browser/RegisterTest.php
public function test_register_flow()
{
    $this->browse(function (Browser $b) {
        $b->visit('/register')
          ->type('#name','山田花子')
          ->type('#email','hanako@example.com')
          ->type('#password','StrongPassw0rd!')
          ->type('#password_confirmation','StrongPassw0rd!')
          ->check('agree')
          ->press('登録する')
          ->waitForLocation('/dashboard')
          ->assertSee('ようこそ');
    });
}

5.3 キーボード操作・フォーカス・ライブリージョン

public function test_error_summary_focus_and_readable()
{
    $this->browse(function (Browser $b) {
        $b->visit('/register')
          ->press('登録する') // 空送信
          ->waitFor('#error-title')
          ->assertFocused('#error-title') // 先頭サマリにフォーカス
          ->assertSeeIn('#error-title','入力内容を確認してください。')
          ->assertPresent('[role="alert"]')
          ->assertAttribute('#email','aria-invalid','true');
    });
}
  • エラー時は先頭サマリにフォーカスが移ること、role="alert" の存在、各フィールドの aria-invalid を確認。
  • 進捗や結果は role="status"/aria-live を読み上げ対象に。Dusk では DOM 上の有無で確認します。

5.4 画面キャプチャとログ

public function test_capture_on_failure()
{
    $this->browse(function (Browser $b) {
        $b->visit('/')->screenshot('home'); // storage/screenshots/home.png
    });
}
  • 失敗時自動キャプチャは デバッグ最短ルート。CI のアーティファクトに必ず保存しましょう。

6. 自動アクセシビリティ検査:axe/Pa11y を Dusk/CI と接続

6.1 Dusk × axe-core(最小統合例)

  1. npm i -D axe-core を導入。
  2. Dusk から axe.min.js をページへ注入して評価します。
public function test_accessibility_smoke()
{
    $this->browse(function (Browser $b) {
        $b->visit('/register');

        // axe を注入
        $axe = file_get_contents(base_path('node_modules/axe-core/axe.min.js'));
        $b->script($axe.'; void(0);');

        // 実行して結果を受け取る
        $results = $b->script('return axe.run(document, { resultTypes: ["violations"] });')[0];

        // 重大度の高い違反を簡易検知
        $violations = collect($results['violations'] ?? [])->filter(function($v){
            return in_array('serious', $v['impact'] ?? []) || in_array('critical', $v['impact'] ?? []);
        });

        if ($violations->isNotEmpty()) {
            logger()->error('axe.violations', ['violations'=>$violations]);
        }

        $this->assertTrue($violations->isEmpty(), 'アクセシビリティ違反があります');
    });
}
  • まずはスモーク(重大違反の検知)から。
  • Detailed なルール調整は CI の Pa11y で行うと運用しやすいです。

6.2 Pa11y(CLI)でページ群を定期検査

  1. npm i -D pa11y pa11y-ci
  2. pa11y-ci.json を用意。
{
  "defaults": {
    "timeout": 30000,
    "standard": "WCAG2AA",
    "wait": 1000,
    "chromeLaunchConfig": { "args": ["--no-sandbox","--disable-dev-shm-usage"] }
  },
  "urls": [
    "http://localhost:8000/",
    "http://localhost:8000/register",
    "http://localhost:8000/login",
    "http://localhost:8000/posts"
  ]
}
  1. CI で Laravel サーバを起動してから npx pa11y-ci を実行。
  • ページ追加はJSONに列挙。誤検知があれば ルール除外を個別に設定できます。
  • ここで検出しきれない体験的な問題(フォーカス移動やライブ更新)は Dusk で補完します。

7. UIテキスト・エラー文・読み上げの検証

  • エラー文は短く具体的aria-describedby で入力と結び付け
  • トースト/バナーは優先度に応じて role="status"/role="alert" を使い分け。
  • 「色のみ」の表現がないか、テキスト/アイコンを併用しているかを Dusk で DOM 検査。
  • 多言語化では <html lang> と language-of-parts を確認(先行記事の方針を踏襲)。

8. テストデータ設計:偽装し過ぎず、現実に近く

  • Factory の値は実在しそうな組み合わせに。住所/電話/価格はフォーマットを現実準拠で。
  • Builder パターンで複雑な前提(例:公開済み記事+3件のタグ+2件のコメント)を簡潔に組み立てる。
  • 大量データの性能検証は Seeder を使い、E2E では 固定ID で参照しやすく。

9. 高速化:ボトルネックを潰す

  • 並列実行+ in-memory SQLite で起動速度を確保。
  • 外部API は Http::fake()ネットワーク不要に。
  • Dusk はケースを絞って要点検証、重いシナリオは 1〜3本 に集約。
  • フィード・一覧画面の E2E は API モックで安定化させ、UI の a11y を重点確認。

10. CI/CD:GitHub Actions 雛形(MySQL/Redis/Node 同時起動)

name: test

on: [push, pull_request]

jobs:
  laravel:
    runs-on: ubuntu-latest
    services:
      mysql:
        image: mysql:8
        env:
          MYSQL_DATABASE: testing
          MYSQL_USER: user
          MYSQL_PASSWORD: secret
          MYSQL_ROOT_PASSWORD: root
        ports: ["3306:3306"]
        options: >-
          --health-cmd="mysqladmin ping -h 127.0.0.1 -u root -proot"
          --health-interval=10s --health-timeout=5s --health-retries=3
      redis:
        image: redis:alpine
        ports: ["6379:6379"]

    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          extensions: mbstring, pdo_mysql, intl
          coverage: none

      - name: Cache composer
        uses: actions/cache@v4
        with:
          path: vendor
          key: composer-${{ hashFiles('composer.lock') }}

      - run: composer install --no-interaction --prefer-dist

      - name: Setup Node
        uses: actions/setup-node@v4
        with: { node-version: 20, cache: 'npm' }

      - run: npm ci
      - run: npm run build --if-present

      - name: App key & env
        run: |
          cp .env.example .env
          php artisan key:generate
          php artisan migrate --force

      - name: Pest / PHPUnit
        run: php artisan test --parallel --processes=4

      - name: Dusk (headless)
        run: php artisan dusk --env=dusk.local
        env:
          APP_URL: http://127.0.0.1:8000
          DB_DATABASE: testing
          DB_USERNAME: user
          DB_PASSWORD: secret

      - name: Start server for Pa11y
        run: php -S 127.0.0.1:8000 -t public & echo $! > server.pid && sleep 3

      - name: Pa11y CI
        run: npx pa11y-ci

      - name: Upload artifacts (screenshots)
        uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: dusk-screens
          path: storage/screenshots
  • 失敗時のスクリーンショットを必ずアップロード。
  • intl を有効化し、i18n/数値/日付のテストを通します。

11. よく使うフェイク/モックのレシピ

Mail::fake();
Queue::fake();
Event::fake();
Notification::fake();
Http::fake([
  'https://api.example.com/*' => Http::response(['ok'=>true], 200),
]);
Storage::fake('s3'); // アップロード検証
  • 「送ったつもり」を安全に検証。宛先・件名・件数のアサートを忘れずに。
  • 画像/PDF の生成は ダミー で代替し、処理の有無だけ確認する方針が堅実です。

12. 画面の“読みやすさ”を守る E2E の観点

  • フォーカスリングが消えていない(Tab で可視)。
  • ボタン/リンクはキーボードだけで到達できる。
  • 重要な通知は role="alert"、通常は role="status"aria-livepolite を基本。
  • 画像に alt、装飾は空 alt=""
  • 動きの多い要素は prefers-reduced-motion で軽くなる。
  • エラーは色だけに依存せず、テキスト/アイコン/枠線で多重表現。

Dusk では DOM アサートを中心に、必要に応じてスタイルの 存在 も確認します(色値に依存しない)。


13. 代表的なサンプル(抜粋)

13.1 入力エラーの a11y

public function test_validation_errors_have_describedby()
{
    $this->browse(function (Browser $b) {
        $b->visit('/register')
          ->press('登録する')
          ->waitFor('#error-title')
          ->assertPresent('#email-error')
          ->assertAttribute('#email','aria-describedby', fn($v) => str_contains($v,'email-error'));
    });
}

13.2 トースト読み上げ

public function test_toast_announces_status()
{
    $this->browse(function (Browser $b) {
        $b->visit('/profile')
          ->press('保存する')
          ->waitFor('[role="status"]')
          ->assertSeeIn('[role="status"]','保存しました');
    });
}

13.3 代替テキスト

test('画像に代替テキストがある', function () {
    $html = $this->get('/')->getContent();
    expect($html)->toContain('alt=');
});

14. 落とし穴と回避策

  • Dusk だけで全部やろうとする → 遅く不安定。Feature 中心で、体験の要点だけ Dusk。
  • a11y 自動検査だけで安心 → フォーカス移動・ライブ更新は自動検知しづらい。E2E 補完を。
  • テストデータが毎回ランダム → 失敗時に再現困難。シナリオは固定IDシードで安定化。
  • フェイクの戻し忘れ → 1テスト1アサートより前後処理の共通化(Pest のフックや PHPUnit の setUp/tearDown)。
  • CI での Chrome/DB 不整合 → ヘルスチェックを必ず入れる。--disable-dev-shm-usage でメモリ不足回避。
  • 失敗スクショ未保存 → 原因追跡が高コスト。アーティファクト保存を標準化。

15. チェックリスト(配布用)

戦略

  • [ ] Feature(HTTP/DB)を中心、Unit はロジック、E2E は要点のみ
  • [ ] 毎プルリクで Pest/Feature + Dusk + a11y(Pa11y/axe)を実行

データ

  • [ ] Factory の状態設計(draft/published など)
  • [ ] シードと固定IDでシナリオを再現可能に

アクセシビリティ

  • [ ] エラーのサマリにフォーカス、role="alert"/aria-invalid/aria-describedby
  • [ ] role="status"/aria-live で進捗・完了を読み上げ
  • [ ] 画像 alt、装飾は空 alt、色に依存しない表現
  • [ ] キーボードで完了まで操作可能

自動検査

  • [ ] Dusk + axe のスモーク
  • [ ] Pa11y の URL リストを CI で回す
  • [ ] 重大度ごとに失敗基準を定義

高速化/安定

  • [ ] 並列実行・in-memory SQLite(または複数 DB)
  • [ ] Http/Mail/Queue/Storage のフェイク
  • [ ] 失敗時スクショ・ログをアーティファクト化

CI

  • [ ] Chrome の headless 実行
  • [ ] サービス(DB/Redis)ヘルスチェック
  • [ ] 依存のキャッシュ(composer/npm)

16. まとめ

  • Pest/PHPUnit・Feature・Dusk を役割分担させ、日々の変更を安全に。
  • axe/Pa11y を取り入れて、見落としやすい a11y の初期欠陥を自動で抑止。
  • フォーカス移動・ライブリージョン・キーボード操作など、体験の肝は E2E で確認。
  • CI にスクリーンショットとログを残し、失敗から学べる体制に。
  • まずはスモークから。効果を見ながら、ページとルールを段階的に広げていきましょう。

みなさまのプロジェクトが「壊れにくく、誰にでも使いやすい」状態で育っていくよう、この記事の雛形をそのままチームの標準にしていただけたら嬉しいです。


このコンテンツのアクセシビリティ評価:★★★★★(とても高い)

  • a11y 自動検査と E2E で読み上げ・フォーカス・色非依存を継続的に担保。
  • 失敗時の説明責任(スクショ/ログ/リクエストID)の運用まで含めました。
  • 今後の強化:主要スクリーンリーダー(NVDA/JAWS/VoiceOver/TalkBack)での手動探索テストの定例化。

参考リンク

投稿者 greeden

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)