Laravelのテスト自動化実践ガイド:Pest、PHPUnit、Dusk、axe、Pa11yをCIで回す

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

Laravelのテストを増やしても、すべてをブラウザテストで確認すると実行時間と保守負担が膨らみます。

一方、Featureテストだけでは、画面上のフォーカス移動やキーボード操作まで確かめられません。

そこで、PestまたはPHPUnitを使うUnitテストとFeatureテストを土台にし、Laravel Dusk、axe-core、Pa11y、手動確認を役割ごとに組み合わせます。

この記事では、テスト層の分け方、動く最小例、GitHub Actionsへの組み込み方、アクセシビリティ自動検査の限界を順に整理します。

Laravelで使う五つのテスト層

Unitテストは、フレームワークやデータベースを起動せず、小さな計算や条件分岐を確かめるテストです。

Featureテストは、Laravelを起動し、HTTPリクエスト、認証、認可、バリデーション、データベース更新など、一つの利用目的を通して確認するテストです。

E2Eテストは、利用者と同じようにブラウザを操作し、画面遷移を含む一連の流れを確かめるテストです。

テスト層ごとの役割と向いている確認
層 主な役割 向いている確認 注意点
Unit 小さなロジックを隔離して検証する 計算、変換、条件分岐 LaravelのサービスやDBを前提にしない
Feature アプリケーションのユースケースを検証する HTTP、認証、認可、DB、副作用 正常系だけでなく拒否条件も確認する
Browser 実ブラウザで利用の流れを検証する 画面遷移、JavaScript、フォーカス 数を絞り、重要な経路に使う
axe、Pa11y 規則に基づく欠陥候補を検出する 名前、役割、関係、コントラストなど 未判定項目と誤検知を人が確認する
手動、支援技術 実際の操作と理解のしやすさを確認する キーボード、読み上げ、認知負荷 自動検査では代替できない

Laravel公式のテスト資料も、多くのアプリケーションではFeatureテストを中心に据える考え方を示しています。

ただし、層ごとの本数を固定比率で決める必要はありません。

障害時の影響、変更頻度、実行時間を見ながら、最も安価に不具合を見つけられる層へテストを置きます。

Pest、PHPUnit、Duskを準備する

PestとPHPUnitは競合する品質基準ではなく、テストの書き方を選ぶための道具です。

Pestは短い関数形式で記述しやすく、PHPUnitはクラス形式の資産や周辺知識を生かしやすいため、既存コードに合わせて選べます。

導入時はPHP、Laravel、Pestの対応バージョンを公式資料で照合し、既存のComposer制約を確認してください。

# PestとLaravel向けプラグインの例
composer require pestphp/pest pestphp/pest-plugin-laravel --dev --with-all-dependencies
./vendor/bin/pest --init

# Laravel Dusk
composer require laravel/dusk --dev
php artisan dusk:install

# アクセシビリティ検査
npm install --save-dev axe-core pa11y pa11y-ci

Laravelのテストは、PestとPHPUnitのどちらを使っていても、通常は次のArtisanコマンドから実行できます。

php artisan test

並列実行を使う場合は、対応する実行パッケージを開発依存へ追加します。

composer require brianium/paratest --dev
php artisan test --parallel
# ワーカー数を明示する場合
php artisan test --parallel --processes=4

並列テストではLaravelがプロセスごとのテストデータベースを用意しますが、ファイル、キャッシュ、外部サービスなどの共有資源はプロセストークンなどで分離する必要があります。

Featureテストでユースケースを固定する

Featureテストは、利用者の操作をHTTPリクエストへ縮め、入力から永続化までを一度に確かめられます。

次の例は、ログイン済みユーザーによる記事作成と、未ログイン時の拒否を別のテストに分けています。

use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

test('ログイン済みユーザーは記事を作成できる', function () {
    $user = User::factory()->create();

    $response = $this->actingAs($user)->post(route('posts.store'), [
        'title' => 'はじめての記事',
        'body' => '本文',
    ]);

    $response->assertRedirect(route('posts.index'));
    $this->assertDatabaseHas('posts', [
        'title' => 'はじめての記事',
        'user_id' => $user->id,
    ]);
});

test('未ログインユーザーの記事作成を拒否する', function () {
    $this->post(route('posts.store'), [
        'title' => '記事',
        'body' => '本文',
    ])->assertRedirect(route('login'));
});

ルート名、入力項目、遷移先は例なので、実際のアプリケーションの契約へ置き換えます。

成功時にはレスポンスだけでなく、保存された値や副作用まで確認すると、コントローラー内部を変更しても利用者から見た仕様を守れます。

フェイクは「依頼した事実」を検証する

フェイクは、メール、通知、キュー、HTTP通信などを実際には実行せず、アプリケーションが正しい依頼を出したか確認する仕組みです。

use App\Models\User;
use App\Notifications\PostPublished;
use Illuminate\Support\Facades\Notification;

test('公開時に担当者へ通知を依頼する', function () {
    Notification::fake();
    $user = User::factory()->create();

    $this->actingAs($user)->post(route('posts.store'), [
        'title' => '公開記事',
        'body' => '本文',
    ]);

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

このテストが保証するのは、Laravelが通知を送る処理を呼び出したことです。

メール事業者による配送や最終的な受信まで確かめるには、別の統合テストや監視が必要です。

再現できるテストデータを作る

テストデータは、実在の個人情報を使わず、失敗条件を再現できる最小の内容にします。

ランダム値を使う場合も、テストの意図を示す状態名と、失敗時に追跡できる入力を残します。

namespace Database\Factories;

use App\Models\Post;
use Illuminate\Database\Eloquent\Factories\Factory;

class PostFactory extends Factory
{
    protected $model = Post::class;

    public function definition(): array
    {
        return [
            'title' => $this->faker->sentence(),
            'body' => $this->faker->paragraph(),
            'status' => 'draft',
            'published_at' => null,
        ];
    }

    public function published(): static
    {
        return $this->state(fn () => [
            'status' => 'published',
            'published_at' => now(),
        ]);
    }
}

Factoryの状態設計やSeederとの分担は、関連記事のLaravelのマイグレーション、シーディング、Factory設計で詳しく説明しています。

RefreshDatabaseはFeatureテストに適していますが、Duskへそのまま使うことはできません。

Duskのブラウザとテストコードは別プロセスで動くため、トランザクションを共有できず、インメモリSQLiteも共有できないからです。

Duskでは、公式資料に従ってDatabaseMigrationsまたはDatabaseTruncationを選びます。

Duskで画面の振る舞いを確認する

Duskを使う場面は、HTTPレスポンスだけでは判断できない利用経路です。

たとえば、送信エラー後にエラー概要へフォーカスが移り、入力欄と説明がプログラム上で関連付けられているかを確認できます。

<div id="error-summary" role="alert" tabindex="-1">
  <h2>入力内容を確認してください</h2>
</div>

<label for="email">メールアドレス</label>
<input id="email" name="email"
       aria-invalid="true" aria-describedby="email-error">
<p id="email-error">メールアドレスを入力してください</p>
public function test_validation_error_moves_focus_to_summary(): void
{
    $this->browse(function (Browser $browser) {
        $browser->visit('/register')
            ->press('登録する')
            ->waitFor('#error-summary')
            ->assertFocused('#error-summary')
            ->assertPresent('[role="alert"]')
            ->assertAttribute('#email', 'aria-invalid', 'true')
            ->assertAttribute('#email', 'aria-describedby', 'email-error');
    });
}

tabindex="-1"は、通常のTab移動順へ要素を加えず、スクリプトからフォーカスできるようにするための指定です。

Duskのアサーションは、フォーカスやARIA属性がDOM上で期待どおりかを確かめますが、スクリーンリーダーが実際に何を読み上げるかまでは確認しません。

キーボード操作、フォーカス順、読み上げ、拡大時の理解しやすさは手動確認へ残します。

自動検査と手動確認の分担は、アクセシビリティテストの進め方でも整理しています。

axe-coreで重大な違反候補を検出する

axe-coreをDuskのページへ読み込み、検査結果を待ってからPHP側で判定します。

axe.run()は非同期なので、戻り値を直ちに配列として扱わず、完了またはエラーまで待つ必要があります。

public function test_accessibility_smoke(): void
{
    $this->browse(function (Browser $browser) {
        $browser->visit('/register');

        $axe = file_get_contents(base_path('node_modules/axe-core/axe.min.js'));
        $browser->script($axe . <<<'JS'
;window.__axeResult = null;
window.__axeError = null;
axe.run(document, { resultTypes: ['violations', 'incomplete'] })
    .then(result => { window.__axeResult = result; })
    .catch(error => { window.__axeError = error.message; });
JS);

        $browser->waitUntil(
            'window.__axeResult !== null || window.__axeError !== null',
            15
        );

        $payload = $browser->script(
            'return {result: window.__axeResult, error: window.__axeError};'
        )[0];

        $this->assertNull($payload['error']);

        $blocking = collect($payload['result']['violations'] ?? [])
            ->filter(fn (array $violation) => in_array(
                $violation['impact'] ?? null,
                ['serious', 'critical'],
                true
            ));

        $this->assertCount(
            0,
            $blocking,
            json_encode($blocking->values()->all(), JSON_UNESCAPED_UNICODE)
        );
    });
}

impactは配列ではなく、minor、moderate、serious、criticalのいずれか、またはnullです。

上の例はseriousとcriticalだけでCIを失敗させますが、これは導入時の例であり、moderateを無視してよいという意味ではありません。

incompleteには自動判定できなかった項目が入るため、結果を保存して人が確認します。

Pa11yで代表ページを継続検査する

Pa11y CIは、複数のURLを同じ設定で巡回したいときに使えます。

公開画面だけでなく、ログイン後、入力エラー、空状態、モーダル表示など、DOMが変わる代表状態を検査対象に含めます。

{
  "defaults": {
    "timeout": 30000,
    "standard": "WCAG2AA",
    "wait": 1000,
    "chromeLaunchConfig": {
      "args": ["--no-sandbox", "--disable-dev-shm-usage"]
    }
  },
  "urls": [
    "http://127.0.0.1:8000/",
    "http://127.0.0.1:8000/register",
    "http://127.0.0.1:8000/login"
  ]
}

WCAG2AAはPa11yの設定名であり、この指定だけでWCAG 2.2の全達成基準を評価できるわけではありません。

利用中のPa11y、検査ランナー、規則セットの対応範囲を固定し、更新時には検出結果の変化をレビューします。

検査を始める前にWebアクセシビリティの対象を整理したい場合は、Webアクセシビリティの基本と最初の改善項目も参考になります。

GitHub Actionsへ組み込む

CIでは、速いFeatureテストを先に実行し、フロントエンドをビルドしてからLaravelの開発サーバーを起動し、DuskとPa11yを続けます。

Duskが接続するサーバーは、Duskの実行前に起動しておかなければなりません。

次のYAMLは構成例なので、PHPとNode.jsのバージョン、必要な拡張、データベース設定をプロジェクトへ合わせてください。

name: tests

on: [push, pull_request]

jobs:
  quality:
    runs-on: ubuntu-latest
    env:
      APP_ENV: testing
      APP_URL: http://127.0.0.1:8000
      DB_CONNECTION: mysql
      DB_HOST: 127.0.0.1
      DB_PORT: 3306
      DB_DATABASE: testing
      DB_USERNAME: user
      DB_PASSWORD: secret

    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

    steps:
      - uses: actions/checkout@v5

      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.4'
          extensions: mbstring, pdo_mysql, intl
          coverage: none

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: npm

      - run: composer install --no-interaction --prefer-dist
      - run: npm ci
      - run: npm run build --if-present

      - name: Prepare application
        run: |
          cp .env.example .env
          php artisan key:generate
          php artisan migrate --force

      - name: Feature and unit tests
        run: php artisan test --parallel

      - name: Match ChromeDriver to Chrome
        run: php artisan dusk:chrome-driver --detect

      - name: Start Laravel server
        run: |
          php artisan serve --no-reload > /tmp/laravel.log 2>&1 &
          for i in {1..30}; do
            curl -sS -o /dev/null http://127.0.0.1:8000 && exit 0
            sleep 1
          done
          exit 1

      - name: Browser tests
        run: php artisan dusk

      - name: Pa11y
        run: npx pa11y-ci

      - name: Upload Dusk diagnostics
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: dusk-diagnostics
          path: |
            tests/Browser/screenshots
            tests/Browser/console

Duskのスクリーンショットは既定でtests/Browser/screenshotsへ、コンソールログはtests/Browser/consoleへ保存されます。

失敗時だけでも両方をアーティファクトに残すと、再実行前に画面とJavaScriptエラーを確認できます。

導入を段階的に進めるチェックリスト

  • 認証、認可、入力検証、主要な状態遷移をFeatureテストにする。
  • Factoryに意味のある状態名を付け、テストごとの前提を短くする。
  • ブラウザでしか確認できない主要経路をDuskへ絞る。
  • 入力エラー、フォーカス、キーボード操作、ライブ更新の期待動作を明文化する。
  • axe-coreとPa11yの結果から、CIを失敗させる基準と手動レビュー対象を決める。
  • DuskではRefreshDatabaseとインメモリSQLiteを使わない。
  • CIでLaravelサーバーの起動を待ってからDuskとPa11yを実行する。
  • スクリーンショット、コンソールログ、検査結果を失敗時の資料として保存する。
  • キーボードと主要な支援技術による手動確認をリリース手順へ残す。

自動化の完了条件

テスト自動化の目的は、テスト数を増やすことではなく、変更後にどの品質をどの方法で確かめたか説明できる状態を作ることです。

UnitとFeatureでロジックとユースケースを広く守り、Duskで重要な画面遷移を確認し、axe-coreとPa11yで規則違反の候補を早く検出します。

そのうえで、自動判定できない操作感や読み上げを人が確認すれば、速さと利用者視点を両立できます。

参考資料

この記事に関連する株式会社greedenの取り組み

テストをCIへ組み込んでも、自動検査だけでは利用時の困りごとを取り切れません。株式会社greedenは、現状サイトのアクセシビリティ診断から、WCAGやJISに基づく改善提案と実装支援まで対応しています。

投稿者 greeden

コメントを残す

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

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