Laravel Duskを使ったE2Eテスト

はじめに

品質の高いWebアプリケーションを開発するためには、効果的なテスト戦略が不可欠です。単体テストやインテグレーションテストも重要ですが、実際のユーザー体験を確認するためには、エンドツーエンド(E2E)テストが必要になります。Laravel Duskは、Laravelが提供するブラウザテスト自動化ツールで、実際のユーザー操作をシミュレートし、アプリケーションの動作を包括的にテストすることができます。

この記事では、Laravel Duskの基本的な使い方から応用テクニックまで、実践的なコード例とともに解説します。

Laravel Duskとは

Laravel Duskは、ChromeDriverを利用してブラウザの操作を自動化し、ユーザーの実際の操作をシミュレートするテスト環境を提供します。以下のような特徴があります:

  • JavaScriptを含む実際のブラウザ動作をテスト可能
  • 複雑なUIインタラクションのテスト(ドラッグ&ドロップ、モーダル操作など)
  • データベース操作と連携したE2Eテスト
  • スクリーンショット撮影機能
  • 簡潔で読みやすいAPI

インストールと初期設定

Duskのインストール

composer require --dev laravel/dusk
php artisan dusk:install

このコマンドを実行すると、以下の処理が行われます:

  1. tests/Browserディレクトリの作成
  2. 基本的なDuskテストファイルの作成
  3. ChromeDriverのダウンロード(環境に応じたバージョン)

環境設定

テスト用の環境変数を設定します。通常は.env.dusk.localファイルを作成し、テスト用の設定を記述します:

APP_URL=http://localhost:8000
DB_CONNECTION=sqlite
DB_DATABASE=:memory:

ChromeDriverの管理

ChromeDriverのバージョンを更新するには:

php artisan dusk:chrome-driver

特定のバージョンを指定する場合:

php artisan dusk:chrome-driver 120

最初のDuskテストを作成

テストファイルの生成

php artisan dusk:make LoginTest

このコマンドでtests/Browser/LoginTest.phpファイルが生成されます。

基本的なテストの記述

<?php

namespace Tests\Browser;

use Tests\DuskTestCase;
use Laravel\Dusk\Browser;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseMigrations;

class LoginTest extends DuskTestCase
{
    use DatabaseMigrations;

    /**
     * ログイン機能のテスト
     */
    public function test_user_can_login(): void
    {
        // テスト用ユーザーの作成
        $user = User::factory()->create([
            'email' => 'test@example.com',
            'password' => bcrypt('password'),
        ]);

        $this->browse(function (Browser $browser) use ($user) {
            $browser->visit('/login')
                    ->type('email', $user->email)
                    ->type('password', 'password')
                    ->press('ログイン')
                    ->assertPathIs('/dashboard')
                    ->assertSee('ダッシュボード');
        });
    }
}

テストの実行

テストを実行する前に、テスト用のWebサーバーを起動します:

php artisan serve

別のターミナルでテストを実行します:

php artisan dusk

特定のテストのみを実行する場合:

php artisan dusk tests/Browser/LoginTest.php

ブラウザの操作方法

基本的なナビゲーション

$browser->visit('/posts')          // URLにアクセス
        ->clickLink('新規投稿')     // リンクテキストをクリック
        ->click('@create-button')  // セレクタを使用してクリック
        ->back()                   // ブラウザの「戻る」
        ->forward()                // ブラウザの「進む」
        ->refresh()                // ページの更新
        ->resize(1920, 1080);      // ブラウザサイズの変更

フォーム操作

$browser->type('title', 'テスト投稿')        // inputフィールドに入力
        ->select('category', '技術ブログ')    // セレクトボックスの選択
        ->check('public')                    // チェックボックスをチェック
        ->uncheck('draft')                   // チェックボックスのチェックを外す
        ->radio('priority', 'high')          // ラジオボタンを選択
        ->attach('file', __DIR__.'/test.jpg') // ファイルをアップロード
        ->press('投稿する')                   // ボタンをクリック
        ->submitForm('#post-form');          // フォームをサブミット

マウス操作と高度な操作

$browser->mouseover('@tooltip')             // 要素にマウスオーバー
        ->drag('@item', '@target')          // ドラッグ&ドロップ
        ->dragLeft('@slider', 100)          // スライダーを左へ移動
        ->dragRight('@slider', 100)         // スライダーを右へ移動
        ->pause(1000)                       // 1秒間待機
        ->keys('selector', '{enter}')       // キーボード入力
        ->screenshot('filename')            // スクリーンショット撮影
        ->script("window.scrollTo(0, 100)"); // JavaScriptを実行

複数のブラウザを使ったテスト

$this->browse(function (Browser $first, Browser $second) {
    $first->loginAs(User::find(1))
          ->visit('/chat')
          ->waitFor('@chat-box');

    $second->loginAs(User::find(2))
           ->visit('/chat')
           ->waitFor('@chat-box')
           ->type('@message', 'こんにちは!')
           ->press('@send');

    $first->waitForText('こんにちは!')
          ->assertSee('こんにちは!');
});

アサーション(検証)

ページの検証

$browser->assertTitle('ダッシュボード')        // タイトルの検証
        ->assertPathIs('/dashboard')         // パスの検証
        ->assertPathBeginsWith('/admin')     // パスの前方一致
        ->assertPathIsNot('/login')          // パスの否定
        ->assertQueryStringHas('page', '2')  // クエリ文字列の検証
        ->assertUrl('https://example.com/dashboard'); // 完全なURLの検証

テキストと要素の検証

$browser->assertSee('ようこそ')              // テキストが表示されていることを確認
        ->assertDontSee('エラー')            // テキストが表示されていないことを確認
        ->assertSeeIn('@header', 'ダッシュボード') // 特定要素内のテキストを確認
        ->assertPresent('@save-button')     // 要素の存在を確認
        ->assertNotPresent('@delete-button') // 要素が存在しないことを確認
        ->assertVisible('@tooltip')         // 要素が表示されていることを確認
        ->assertNotVisible('@hidden-menu')  // 要素が非表示であることを確認
        ->assertEnabled('@submit')          // 要素が有効であることを確認
        ->assertDisabled('@locked-input')   // 要素が無効であることを確認
        ->assertChecked('@terms')           // チェックボックスが選択されていることを確認
        ->assertNotChecked('@newsletter')   // チェックボックスが選択されていないことを確認
        ->assertRadioSelected('priority', 'high') // ラジオボタンの選択を確認
        ->assertSelected('category', '技術ブログ'); // セレクトボックスの選択を確認

カウントと属性の検証

$browser->assertSourceHas('<h1>ダッシュボード</h1>') // ソースコードの検証
        ->assertSourceMissing('error-message')    // ソースコードに含まれないことを確認
        ->assertAttribute('button', 'disabled', 'true') // 属性値の検証
        ->assertValue('input[name=email]', 'test@example.com') // input値の検証
        ->assertCount('@items', 5)                // 要素数のカウント
        ->assertHasClass('@alert', 'alert-success') // クラスの存在確認
        ->assertClassMissing('@card', 'hidden');   // クラスの非存在確認

待機処理

基本的な待機

$browser->pause(1000)                      // 1秒間待機
        ->waitFor('@loading-indicator')     // 要素が表示されるまで待機
        ->waitForText('処理が完了しました')     // テキストが表示されるまで待機
        ->waitUntilMissing('@spinner')      // 要素が消えるまで待機
        ->waitForTextMissing('読み込み中...'); // テキストが消えるまで待機

高度な待機

$browser->waitForReload()                  // ページのリロードを待機
        ->waitForLocation('/dashboard')    // 特定のURLに移動するまで待機
        ->waitUntil('return !!window.jQuery') // JavaScriptの条件が真になるまで待機
        ->waitForVue('@counter', 'count', 5) // Vue.jsのプロパティが特定の値になるまで待機
        ->waitForDialog()                  // JavaScript確認ダイアログが表示されるまで待機
        ->waitForEvent('app-loaded');      // イベントが発生するまで待機

タイムアウトの指定

$browser->waitFor('@slow-element', 30)    // 30秒間待機(デフォルトは5秒)

ページオブジェクトの活用

ページオブジェクトパターンを使用すると、テストコードを整理し、再利用性を高めることができます。

ページクラスの作成

php artisan dusk:page Dashboard

これにより、tests/Browser/Pages/Dashboard.phpが作成されます。

<?php

namespace Tests\Browser\Pages;

use Laravel\Dusk\Browser;
use Laravel\Dusk\Page as BasePage;

class Dashboard extends BasePage
{
    /**
     * このページのURL取得
     */
    public function url()
    {
        return '/dashboard';
    }

    /**
     * ページ上の要素のセレクタを定義
     */
    public function elements()
    {
        return [
            '@create-post' => '#create-post-button',
            '@user-menu' => '.user-dropdown',
            '@logout' => '.logout-button',
        ];
    }

    /**
     * ページに特有の操作を定義
     */
    public function createNewPost(Browser $browser, $title, $content)
    {
        $browser->click('@create-post')
                ->waitFor('#post-form')
                ->type('title', $title)
                ->type('content', $content)
                ->press('投稿する')
                ->waitForText('投稿が作成されました');
    }
}

ページオブジェクトの使用

public function test_user_can_create_post()
{
    $this->browse(function (Browser $browser) {
        $browser->loginAs(User::find(1))
                ->visit(new Dashboard)
                ->createNewPost('テスト投稿', 'これはテスト投稿です。')
                ->assertPathIs('/posts');
    });
}

コンポーネントの活用

繰り返し使用するUI要素をコンポーネントとして定義することもできます。

コンポーネントの作成

php artisan dusk:component Navbar

これにより、tests/Browser/Components/Navbar.phpが作成されます。

<?php

namespace Tests\Browser\Components;

use Laravel\Dusk\Browser;
use Laravel\Dusk\Component as BaseComponent;

class Navbar extends BaseComponent
{
    /**
     * コンポーネントのルートセレクタを取得
     */
    public function selector()
    {
        return '.main-navbar';
    }

    /**
     * コンポーネントの要素のセレクタを定義
     */
    public function elements()
    {
        return [
            '@home' => 'a[href="/"]',
            '@profile' => 'a[href="/profile"]',
            '@settings' => '.settings-dropdown',
            '@logout' => '.logout-link',
        ];
    }

    /**
     * ユーザーをログアウトさせる
     */
    public function logout(Browser $browser)
    {
        $browser->click('@settings')
                ->waitFor('@logout')
                ->click('@logout')
                ->waitForLocation('/login');
    }
}

コンポーネントの使用

public function test_user_can_logout()
{
    $this->browse(function (Browser $browser) {
        $browser->loginAs(User::find(1))
                ->visit('/')
                ->within(new Navbar, function ($navbar) {
                    $navbar->logout();
                })
                ->assertPathIs('/login');
    });
}

データベースの準備

マイグレーションの実行

DatabaseMigrationsトレイトを使用して、各テスト前にマイグレーションを実行します:

use Illuminate\Foundation\Testing\DatabaseMigrations;

class PostTest extends DuskTestCase
{
    use DatabaseMigrations;
    
    // テストメソッド...
}

シーダーの実行

DatabaseSeederを実行してテスト用データを準備します:

public function setUp(): void
{
    parent::setUp();
    $this->seed();
}

テスト用ファクトリの活用

public function test_user_can_see_their_posts()
{
    $user = User::factory()->create();
    $posts = Post::factory()->count(3)->create(['user_id' => $user->id]);
    
    $this->browse(function (Browser $browser) use ($user, $posts) {
        $browser->loginAs($user)
                ->visit('/posts')
                ->assertSee($posts[0]->title)
                ->assertSee($posts[1]->title)
                ->assertSee($posts[2]->title);
    });
}

認証テスト

ログイン状態のテスト

$browser->loginAs($user)  // ユーザーとしてログイン
        ->visit('/profile')
        ->assertAuthenticated() // 認証状態を確認

ゲスト状態のテスト

$browser->visit('/profile')
        ->assertGuest()   // 非認証状態を確認
        ->assertRedirect('/login');

CSRF保護のテスト

$browser->visit('/password/reset')
        ->assertPresent('input[name="_token"]');

高度なテクニック

スクリーンショットの活用

$browser->screenshot('login_page')
        ->press('ログイン')
        ->pause(1000)
        ->screenshot('after_login');

エラー時に自動的にスクリーンショットを取得するには、DuskTestCase.phptearDownメソッドをカスタマイズします:

protected function tearDown(): void
{
    $this->captureFailuresFor(function () {
        parent::tearDown();
    });
}

JavaScript例外のキャプチャ

$browser->withoutJavaScriptExceptions()  // JS例外を無視
        ->visit('/buggy-page');

// または例外をキャッチしてテスト
$browser->assertVue('count', 5, '@counter')
        ->whenAvailable('.js-exception-indicator', function ($element) {
            $this->fail('JavaScriptエラーが発生しました');
        }, function () {
            // エラーが発生しなかった場合の処理
        });

ヘッドレスモードの使用

テストを高速化するために、ヘッドレスモードでテストを実行できます。tests/DuskTestCase.phpを編集します:

protected function driver()
{
    $options = (new ChromeOptions)->addArguments([
        '--headless',
        '--window-size=1920,1080',
    ]);

    return RemoteWebDriver::create(
        'http://localhost:9515', 
        DesiredCapabilities::chrome()->setCapability(
            ChromeOptions::CAPABILITY, $options
        )
    );
}

複数の環境でのテスト

DuskTestCase.phpを修正して、異なるブラウザサイズやデバイスでのテストを簡単に行えるようにします:

protected function driver()
{
    $options = (new ChromeOptions)->addArguments([
        '--window-size=1920,1080',
    ]);

    if ($this->hasHeadlessDisabled()) {
        $options->addArguments([
            '--disable-gpu',
            '--headless',
        ]);
    }

    // モバイルデバイスをエミュレート
    if ($this->isMobile()) {
        $options->setExperimentalOption('mobileEmulation', [
            'deviceName' => 'iPhone X'
        ]);
    }

    return RemoteWebDriver::create(
        'http://localhost:9515',
        DesiredCapabilities::chrome()->setCapability(
            ChromeOptions::CAPABILITY, $options
        )
    );
}

protected function isMobile()
{
    return isset($_SERVER['DUSK_MOBILE']) && $_SERVER['DUSK_MOBILE'] === 'true';
}

protected function hasHeadlessDisabled()
{
    return isset($_SERVER['DUSK_HEADLESS_DISABLED']) && 
           $_SERVER['DUSK_HEADLESS_DISABLED'] === 'true';
}

CIでのDuskテスト

継続的インテグレーション(CI)環境でDuskテストを実行する方法も紹介します。

GitHub Actions設定例

.github/workflows/dusk.yml

name: Laravel Dusk Tests

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  dusk-tests:
    runs-on: ubuntu-latest
    
    steps:
    - uses: actions/checkout@v3
    
    - name: Setup PHP
      uses: shivammathur/setup-php@v2
      with:
        php-version: '8.2'
        extensions: mbstring, pdo_sqlite
    
    - name: Copy .env
      run: cp .env.example .env.dusk.local
    
    - name: Install Dependencies
      run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress
    
    - name: Generate Key
      run: php artisan key:generate --env=dusk.local
    
    - name: Directory Permissions
      run: chmod -R 777 storage bootstrap/cache
    
    - name: Create Database
      run: |
        mkdir -p database
        touch database/database.sqlite
    
    - name: Update .env.dusk.local
      run: |
        echo "APP_URL=http://127.0.0.1:8000" >> .env.dusk.local
        echo "DB_CONNECTION=sqlite" >> .env.dusk.local
        echo "DB_DATABASE=database/database.sqlite" >> .env.dusk.local
    
    - name: Run Migrations
      run: php artisan migrate --env=dusk.local
    
    - name: Install Chrome Driver
      run: php artisan dusk:chrome-driver
    
    - name: Start Chrome Driver
      run: ./vendor/laravel/dusk/bin/chromedriver-linux &
    
    - name: Run Laravel Server
      run: php artisan serve --env=dusk.local &
    
    - name: Run Dusk Tests
      run: php artisan dusk --env=dusk.local
    
    - name: Upload Screenshots
      if: failure()
      uses: actions/upload-artifact@v3
      with:
        name: screenshots
        path: tests/Browser/screenshots

テストコードの最適化

ベストプラクティス

  1. 適切なセレクタを使用する:IDやdusk属性を優先する
  2. 適切な待機を設定する:非同期処理のテストでは必ずwait系のメソッドを使用
  3. テストを独立させる:各テストは他のテストに依存しないようにする
  4. ページオブジェクトとコンポーネントを活用する:コードの重複を避ける
  5. テストデータを明示的に作成する:テストの意図を明確にする

テストの整理と管理

/**
 * 認証機能に関するテストグループ
 * @group auth
 */
public function test_something() { /* ... */ }

特定のグループだけを実行:

php artisan dusk --group=auth

トラブルシューティング

よくある問題と解決策

  1. 要素が見つからない場合
    • 待機時間を増やす: $browser->waitFor('@element', 30)
    • セレクタが正しいか確認する
    • スクリーンショットを撮って状態を確認する
  2. CIでのタイムアウト
    • テストの前提条件を確認する
    • ネットワーク負荷が高い操作は待機時間を長くする
  3. ブラウザのバージョン不一致
    • ChromeDriverのバージョンを更新する: php artisan dusk:chrome-driver
  4. セッション/Cookieの問題
    • テスト間でブラウザをクリアする: $browser->visit('/logout')または$browser->refresh()

まとめ

Laravel Duskは、実際のユーザー操作をシミュレートしてE2Eテストを行うための強力なツールです。主な利点は以下の通りです:

  • 実際のブラウザを使用した信頼性の高いテスト
  • JavaScript動作の検証
  • わかりやすいAPI
  • スクリーンショット機能
  • CIとの統合

適切にDuskテストを実装することで、リグレッションを早期に発見し、アプリケーションの品質を維持することができます。特に重要なユーザーフローや複雑なJavaScript操作を含む機能に対してE2Eテストを実装すると効果的です。

Laravelのテスト体系の中でDuskをうまく活用し、単体テスト、インテグレーションテストと組み合わせた総合的なテスト戦略を構築しましょう。

コメント

タイトルとURLをコピーしました