【中級者向け】Laravelで実践するTDD:PHPUnit、Dusk、Pa11y、CIの組み立て方

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

Laravelでテスト駆動開発を始めるときは、テストツールを増やす前に、それぞれが何を確かめるのかを分けます。
モデルの振る舞い、HTTPリクエスト、実際のブラウザ操作、アクセシビリティの自動検査では、見つけられる問題が異なるためです。

この記事では、PHPUnit、Laravel Dusk、Pa11yを使い、失敗するテストから実装を進めてCIで継続的に確認するまでの流れを整理します。
サンプル中のルート名、認証方式、画面文言、PHPとNode.jsのバージョンは、対象プロジェクトの構成に合わせて調整してください。

TDDと各テストの役割

テスト駆動開発(TDD)は、期待する振る舞いをテストで先に表し、そのテストを通す最小限の実装を書き、動作を保ったままコードを整える進め方です。
先に失敗を確認することで、テストが意図した理由で失敗し、実装によって成功へ変わったことを追跡できます。

  1. 一つの振る舞いをテストとして書く。
  2. テストを実行し、未実装の振る舞いが原因で失敗することを確認する。
  3. テストを通すための実装を書く。
  4. 成功する状態を保ちながら、重複や読みにくさを直す。
  5. 次の振る舞いへ進む。

すべてのテストを同じ粒度で書く必要はありません。
次の表のように対象を分けると、失敗したときに見直す範囲が明確になります。

Laravel開発で使うテストの役割
テストの層 確認すること 主な道具 失敗時に見直す対象
小さなロジック 一つの計算や条件分岐 PHPUnit 対象クラスやメソッド
モデルとHTTP データベースの絞り込み、認証、レスポンス Laravelのテスト機能とPHPUnit モデル、ルート、認証、コントローラ
ブラウザ操作 入力、ボタン操作、画面遷移、表示 Laravel Dusk 画面、JavaScript、ブラウザ上の導線
アクセシビリティ 自動判定できるHTML、ARIA、コントラストなどの問題 Pa11y マークアップと見た目の実装

テスト全体の設計をさらに検討したい場合は、Laravelのテスト戦略を扱った解説も参照できます。

テスト環境を準備する

Laravelプロジェクトにはテストを実行する仕組みが用意されています。
まず通常実行で環境を確認し、その後にDuskとPa11yを開発用依存関係として追加します。

# Laravelのテストを実行
php artisan test

# Duskを開発用に追加
composer require --dev laravel/dusk
php artisan dusk:install

# Pa11yを開発用に追加
npm install --save-dev pa11y

php artisan test --parallelを使う場合は、並列実行に必要な開発用パッケージも追加します。
最初から並列化すると設定不備とテスト失敗を区別しにくいため、通常実行が成功してから切り替えると原因を追いやすくなります。

composer require --dev brianium/paratest
php artisan test --parallel

テストでは本番データベースを使わず、テスト専用の接続先を設定します。
設定をキャッシュしている場合は、テスト用の環境変数が反映されていることも確認してください。

モデルの振る舞いをテストから作る

最初の例では、Postモデルのpublishedスコープが公開済み投稿だけを返すことを確かめます。
データベースとLaravel本体を使うため、孤立した小さなロジックを置くtests/Unitではなく、tests/Featureに配置します。

// tests/Feature/PostPublishedScopeTest.php
namespace Tests\Feature;

use App\Models\Post;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

final class PostPublishedScopeTest extends TestCase
{
    use RefreshDatabase;

    public function test_it_only_returns_published_posts(): void
    {
        $expected = Post::factory()->count(3)->create([
            'published' => true,
        ]);

        Post::factory()->count(2)->create([
            'published' => false,
        ]);

        $actual = Post::published()->get();

        $this->assertEqualsCanonicalizing(
            $expected->modelKeys(),
            $actual->modelKeys(),
        );
    }
}

件数だけでなく投稿IDも比較するため、「3件を返したが、非公開の投稿が混ざっていた」という誤りも検出できます。
このテストが意図した理由で失敗することを確認してから、モデルへ公開条件を実装します。

use Illuminate\Database\Eloquent\Builder;

public function scopePublished(Builder $query): Builder
{
    return $query->where('published', true);
}

RefreshDatabaseは各テストを独立させるために使います。
ただし、マイグレーションとFactoryにpublished属性が存在することが前提です。

HTTPテストで認証とレスポンスを確かめる

投稿作成APIでは、未認証の利用者を拒否し、認証済みの利用者には作成結果を返す振る舞いをテストします。
次の例は/api/postsが定義され、Sanctumの認証ガードが設定されているプロジェクトを想定しています。

// tests/Feature/PostControllerTest.php
namespace Tests\Feature;

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

final class PostControllerTest extends TestCase
{
    use RefreshDatabase;

    public function test_guests_cannot_create_posts(): void
    {
        $response = $this->postJson('/api/posts', [
            'title' => 'Test',
            'body' => 'Content',
        ]);

        $response->assertStatus(401);
    }

    public function test_authenticated_users_can_create_posts(): void
    {
        $user = User::factory()->create();
        $this->actingAs($user, 'sanctum');

        $response = $this->postJson('/api/posts', [
            'title' => 'Hello',
            'body' => 'World',
        ]);

        $response
            ->assertStatus(201)
            ->assertJson([
                'title' => 'Hello',
            ]);
    }
}

このテストは、認証、ルーティング、コントローラ、JSONレスポンスをまとめて通します。
失敗した場合は、ステータスコードだけを変更して合わせるのではなく、どの契約が崩れたのかをレスポンス本文とログから確認します。

Duskで利用者の操作を確認する

エンドツーエンドテスト(E2Eテスト)は、利用者の操作に近い形で画面から処理結果までを確認するテストです。
Duskでは、ログイン画面への入力、ボタンの押下、遷移後の表示を一つの流れとして検証できます。

// tests/Browser/LoginTest.php
namespace Tests\Browser;

use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Laravel\Dusk\Browser;
use Tests\DuskTestCase;

final class LoginTest extends DuskTestCase
{
    public function test_user_can_login_via_login_page(): void
    {
        $user = User::factory()->create([
            'password' => Hash::make('secret'),
        ]);

        $this->browse(function (Browser $browser) use ($user): void {
            $browser
                ->visit('/login')
                ->type('email', $user->email)
                ->type('password', 'secret')
                ->press('ログイン')
                ->assertPathIs('/dashboard')
                ->assertSee('ようこそ');
        });
    }
}

assertSeeで確認できるのは、指定した文字列が画面に見えていることです。
この一つのアサーションだけで、ラベルの関連付け、キーボード操作、フォーカス順序、読み上げ時の理解しやすさまで保証できるわけではありません。
画面の受け入れ条件に応じて、フォーカス、属性、エラー表示などの確認を追加します。

Pa11yで自動検査を継続する

Pa11yは、ブラウザで表示できるURLに対してアクセシビリティの自動検査を行います。
ローカルのLaravelアプリをhttp://127.0.0.1:8000で起動する場合は、package.jsonに次のスクリプトを追加できます。

"scripts": {
  "test:a11y": "pa11y http://127.0.0.1:8000 --standard WCAG2AA --reporter html > a11y-report.html"
}
npm run test:a11y

HTML形式のレポートは問題箇所の確認に使えます。
CIでは終了コードも判定されるため、検査対象のURLへ到達できる状態で実行します。

自動検査は、検出ルールに合う問題を繰り返し確認するのに向いています。
しかし、代替テキストが文脈に合うか、キーボードだけで一連の作業を完了できるか、エラー説明を理解できるかなど、人の判断が必要な項目は残ります。
自動検査、手動確認、支援技術による確認の分担は、アクセシビリティテスト実践ガイドで詳しく整理しています。

GitHub Actionsでテストをつなぐ

CIでは、依存関係のインストール、アプリケーションキーとテスト用データベースの準備、PHPUnit、Dusk、Pa11yの順に実行します。
Pa11yとDuskは表示中のアプリへアクセスするため、検査前にLaravelの開発サーバーとChromeDriverを起動します。

次のワークフローは構成例です。
PHP、Node.js、Actionのバージョンは、composer.jsonpackage-lock.json、採用中のLaravelに合わせて固定してください。

# .github/workflows/ci.yml
name: CI

on: [push, pull_request]

jobs:
  tests:
    runs-on: ubuntu-latest

    services:
      mysql:
        image: mysql:8
        env:
          MYSQL_ROOT_PASSWORD: password
          MYSQL_DATABASE: laravel
        ports:
          - 3306:3306
        options: >-
          --health-cmd='mysqladmin ping -h 127.0.0.1 -ppassword'
          --health-interval=10s
          --health-timeout=5s
          --health-retries=5

    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: laravel
      DB_USERNAME: root
      DB_PASSWORD: password

    steps:
      - uses: actions/checkout@v7

      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.4'
          coverage: none

      - uses: actions/setup-node@v7
        with:
          node-version: '22'
          cache: npm

      - run: composer install --no-interaction --prefer-dist
      - run: npm ci
      - run: cp .env.example .env
      - run: php artisan key:generate
      - run: php artisan migrate --force
      - run: php artisan test

      - name: Start browser test services
        run: |
          php artisan dusk:chrome-driver --detect
          ./vendor/laravel/dusk/bin/chromedriver-linux --port=9515 > /tmp/chromedriver.log 2>&1 &
          php artisan serve --host=127.0.0.1 --port=8000 --no-reload > /tmp/laravel.log 2>&1 &

      - run: php artisan dusk
      - run: npm run test:a11y

一つのジョブに集約すると構成を追いやすい一方、実行時間が長くなる場合があります。
運用では、PHPUnitを先に実行し、成功後にブラウザテストとアクセシビリティ検査へ進むようジョブを分けると、失敗箇所を判断しやすくなります。

導入時の確認項目

  • TDDでは、先に失敗を確認してから最小限の実装を書く。
  • 小さく孤立したロジックと、データベースやHTTPを使うテストを分ける。
  • Duskでは、利用者が完了したい操作の流れを確認する。
  • Pa11yの自動検査だけで適合や使いやすさを断定せず、手動確認を組み合わせる。
  • CIではテスト用データベースを使い、DuskとPa11yの前に必要なサービスを起動する。
  • 依存パッケージと実行環境のバージョンを固定し、更新時にテスト結果を確認する。

テストの数を増やすこと自体が目的ではありません。
変更によって壊したくない振る舞いを、適切な層のテストで短く表し、失敗から修正箇所へたどれる状態を保つことが、TDDとCIを継続する土台になります。

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

自動検査で見つかる問題と、人が確かめる使いやすさを分けて運用すれば、改善を継続しやすくなります。
株式会社greedenは、UUUウェブアクセシビリティサービスとWebアクセシビリティチェックを通じ、サイト改善を支援しています。

投稿者 greeden

コメントを残す

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

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