Laravel ゆるーくはじめるテストのチュートリアル

2024/07/30に公開

macOS14.5、Laravel11.9で検証。

前提としてフロントエンドはReactInertia.jsTypeScriptで開発。テストはDuskを使ったブラウザテストとPHPUnitFeatureテストを行う。

プロジェクトセットアップ

プロジェクト名を指定してLaravelプロジェクトをローカルにダウンロード。ここではlarvel-testing-exampleという名前にする。

$ curl -s "https://laravel.build/laravel-testing-example" | bash

プロジェクト起動

$ cd larvel-testing-example
$ ./vendor/bin/sail up

マイグレーションとシーディング

$ ./vendor/bin/sail artisan migrate --seed

フロントエンドはReactTypeScriptを使用する。認証パッケージのLaravel/Breezeを使うとこれらのセットアップが容易なのでここではこれをインストールして開発サーバーを起動。

$ ./vendor/bin/sail composer require laravel/breeze --dev
$ ./vendor/bin/sail artisan breeze:install react --typescript
$ ./vendor/bin/sail npm run dev

http://localhost/loginにアクセスしてtest@example.com / passwordでログイン後、dashboard画面に遷移できることを確認する

また、サンプルで用意されているUnitテストとFeatureテストが成功することを確認しておく

$ ./vendor/bin/sail test

   PASS  Tests\Unit\ExampleTest
  ✓ that true is true       0.01s  
  ・・・
  Tests:    25 passed (61 assertions)
  Duration: 1.46s

Duskセットアップ

続いて、ブラウザテストを行うためにduskをインストールする。

duskをインストール

$ ./vendor/bin/sail composer require laravel/dusk --dev
$ ./vendor/bin/sail artisan dusk:install

.envから.env.dusk.localを複製して以下を編集

.env.dusk.local
APP_URL=http://laravel.test
DB_DATABASE=testing
MAIL_MAILER=log

テストを実行するとtests/Browser以下にサンプルで用意されているテストが実行されるので成功することを確認しておく

$ ./vendor/bin/sail dusk
   PASS  Tests\Browser\ExampleTest
  ✓ basic example                                                            
    ・・・ 
  Tests:    1 passed (1 assertions)
  Duration: 2.10s

ブラウザテストを追加する

breezeをインストールすると認証機能が使えるようになるので、ここではログインのテストを追加する。

$ ./vendor/bin/sail artisan dusk:make LoginTest

以下の例はname属性がemailpaswwordなフォームにそれぞれ値をセットしてログインボタン押下でダッシュボード画面に遷移することを確認するテスト。

tests/Browser/LoginTest.php
<?php

namespace Tests\Browser;

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

class LoginTest extends DuskTestCase
{
    use DatabaseMigrations;

    public function testログインできること(): void
    {
        $user = User::factory()->create();

        $this->browse(function (Browser $browser) use ($user) {
            $browser->visit('/login')
                ->waitFor('button', 5)
                ->type('email', $user->email)
                ->type('password', 'password')
                ->click('button')
                ->pause(1000)
                ->assertPathIs('/dashboard');
        });
    }
}

ビルド後、テストすると追加したテストが実行されることが確認できる

$ ./vendor/bin/sail npm run build

$ ./vendor/bin/sail dusk
  ・・・
   PASS  Tests\Browser\LoginTest
  ✓ ログインできること
  ・・・

メール送信

breezeで用意されているプロフィール画面にメール送信ボタンを追加して、ボタン押下でログイン中のユーザーにメールが送信されることをテストする。

※URLはhttp://localhost/profile

実装

$ ./vendor/bin/sail artisan make:mail UserNotify

また、Mailableを使ってメール処理を実装するとテストを簡単に行うことができる。

app/Mail/UserNotify.php
<?php

namespace App\Mail;

use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;

class UserNotify extends Mailable
{
    use Queueable, SerializesModels;

    public $user;

    public function __construct($user)
    {
        $this->user = $user;
    }

    public function envelope(): Envelope
    {
        return new Envelope(
            subject: 'User Notify',
        );
    }

    public function content(): Content
    {
        return new Content(
            view: 'emails.user_notify',
        );
    }

    public function attachments(): array
    {
        return [];
    }
}

メールの内容を用意する

resources/views/emails/user_notify.blade.php
テストメール

また、ここでは動作確認のみ行うのでメールの出力先をログとする

.env
MAIL_MAILER=log

画面とコントローラ部分は以下のとおり修正を加える。

フロントエンド

resources/js/Pages/Profile/Edit.tsx
<div className="・・・">
  <SendEmailForm className="・・・" />
</div>
resources/js/Pages/Profile/Partials/SendEmailForm.tsx
import PrimaryButton from '@/Components/PrimaryButton';
import { useForm, usePage } from '@inertiajs/react';
import { Transition } from '@headlessui/react';
import { FormEventHandler } from 'react';
import { PageProps } from '@/types';

export default function SendEmailForm({ className = '' }: { className?: string }) {
    const user = usePage<PageProps>().props.auth.user;

    const { patch, processing, recentlySuccessful } = useForm();

    const submit: FormEventHandler = (e) => {
        e.preventDefault();

        patch(route('profile.sendEmail'));
    };

    return (
        <section className={className}>
            <header>
                <h2 className="・・・">Send Test Email</h2>
                <p className="・・・">
                    ログイン中のユーザーにメールを送信します
                </p>
            </header>

            <form onSubmit={submit} className="・・・">
                <div className="・・・">
                    <PrimaryButton disabled={processing}>Send Email</PrimaryButton>
                    <Transition show={recentlySuccessful}>
                        <p className="・・・">Sended.</p>
                    </Transition>
                </div>
            </form>
        </section>
    );
}

ルーティングとコントローラー

routes/web.php
Route::middleware('auth')->group(function () {
    ・・・
    Route::patch('/profile/send-email', [ProfileController::class, 'sendEmail'])->name('profile.sendEmail');
});
app/Http/Controllers/ProfileController.php
 class ProfileController extends Controller
 {
    ・・・
    public function sendEmail(Request $request): Response
    {
        $user = $request->user();

        Mail::to($user->email)->send(new UserNotify($user));

        return Inertia::render('Profile/Edit', [
            'mustVerifyEmail' => $request->user() instanceof MustVerifyEmail,
            'status' => session('status'),
        ]);
    }
 }

ここまで実装したら、プロフィール画面に追加されたSend Emailボタン押下で、ログにテストメールが出力されることが確認できる

テスト

ブラウザテストを実装する。試したところ、メールの内容やメールが送信されことまでをduskでテストすることができないのでここでは画面の動きのみする。

$ ./vendor/bin/sail artisan dusk:make ProfileTest
tests/Browser/ProfileTest.php
<?php

namespace Tests\Browser;

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

class ProfileTest extends DuskTestCase
{
    use DatabaseMigrations;

    public function testテストメール送信ボタンが動作すること(): void
    {
        $user = User::factory()->create();

        $this->browse(function (Browser $browser) use ($user) {
            $browser->loginAs($user)
                ->visit('/profile')
                ->waitForText('Send Test Email')
                ->press('SEND EMAIL')
                ->waitForText('Sended.');
        });
    }
}

テストがパスすることを確認する

$ ./vendor/bin/sail dusk --filter=testテストメール送信ボタンが動作すること
   PASS  Tests\Browser\ProfileTest
  ✓ テストメール送信ボタンが動作すること           1.86s
  ・・・

続いて、メール送信部分のテストをtests/Feature/ProfileTest.phpに追加する。ここではログイン中のユーザーにメールが送信されることだけをテストするが、メールのタイトルや本文をテストすることも可能。

tests/Browser/ProfileTest.php
・・・
use App\Mail\UserNotify;
use Illuminate\Support\Facades\Mail;

class ProfileTest extends TestCase
{
    ・・・
    public function testテストメール送信ボタンが動作すること()
    {
        // 実際にメール送信は行わない
        Mail::fake();

        $user = User::factory()->create();

        $this->actingAs($user)
             ->patch('/profile/send-email')
             ->assertStatus(200);

        Mail::assertSent(UserNotify::class, function ($mail) use ($user) {
            // メールのタイトルと本文を取得できる
            // $subject = $mail->subject;
            // $body = $mail->render();
            return $mail->hasTo($user->email);
        });
    }
}

テストがパスすることを確認する

$ ./vendor/bin/sail test --filter=testテストメール送信ボタンが動作すること
   PASS  Tests\Browser\ProfileTest
  ✓ テストメール送信ボタンが動作すること           1.86s
  ・・・

外部APIへのリクエスト

breezeのプロフィール画面にRequest Apiボタンを追加して、ボタン押下外部APIへリクエストを送信後、レスポンスが取得できることをテストする。

実装

フロントエンド

resources/js/Pages/Profile/Edit.tsx
<div className="・・・">
  <RequestExternalApiForm className="・・・" />
</div>
resources/js/Pages/Profile/Partials/SendEmailForm.tsx
import PrimaryButton from '@/Components/PrimaryButton';
import { useForm, usePage } from '@inertiajs/react';
import { Transition } from '@headlessui/react';
import { FormEventHandler } from 'react';
import { PageProps } from '@/types';

export default function RequestExternalApiForm({ className = '' }: { className?: string }) {
    const user = usePage<PageProps>().props.auth.user;

    const { patch, processing, recentlySuccessful } = useForm();

    const submit: FormEventHandler = (e) => {
        e.preventDefault();

        patch(route('profile.requestExternalApi'));
    };

    return (
        <section className={className}>
            <header>
                <h2 className="・・・">Request External Api</h2>
                <p className="・・・">
                    外部APIへリクエストします
                </p>
            </header>

            <form onSubmit={submit} className="・・・">
                <div className="・・・">
                    <PrimaryButton disabled={processing}>Request Api</PrimaryButton>
                    <Transition show={recentlySuccessful}>
                        <p className="・・・">Requested.</p>
                    </Transition>
                </div>
            </form>
        </section>
    );
}

ルーティングとコントローラー

routes/web.php
Route::middleware('auth')->group(function () {
    ・・・
    Route::patch('/profile/request-external-api', [ProfileController::class, 'requestExternalApi'])->name('profile.requestExternalApi');
});

ここでは外部APIとしてjsonplaceholderを利用する。レスポンスは確認のためログに出力する。また、Inertiaのレスポンスに外部APIから取得したtitleフィールドを返すこととする。

app/Http/Controllers/ProfileController.php
class ProfileController extends Controller
{
    ・・・
    public function requestExternalApi(Request $request): Response
    {
        $response = Http::get('https://jsonplaceholder.typicode.com/todos/1');

        $body = json_decode($response->getBody(), true);

        Log::info($body);

        return Inertia::render('Profile/Edit', [
            'mustVerifyEmail' => $request->user() instanceof MustVerifyEmail,
            'status' => session('status'),
            'title' => $body['title'], // ★レスポンスにAPIから取得したtitleを返すようにする
        ]);
    }
}

ここまで実装したら、プロフィール画面に追加されたRequest Apiボタン押下で、ログに外部APIのレスポンスが出力されることが確認できる

storage/logs/laravel.log
[2024-07-29 08:23:59] local.INFO: array (
  'userId' => 1,
  'id' => 1,
  'title' => 'delectus aut autem',
  'completed' => false,
)  

テスト

ブラウザテストは前述のメール送信のテストとほぼ同じ内容のため、ここでは割愛する。試したところ、APIをモック化する部分をduskでテストすることができないのでFeatureテストのみを実装する

tests/Browser/ProfileTest.php
<?php
・・・
use Illuminate\Support\Facades\Http;

class ProfileTest extends DuskTestCase
{
    ・・・
    public function test外部APIからデータを取得すること()
    {
        Http::fake([
            'https://jsonplaceholder.typicode.com/todos/1' => Http::response([
                'title' => 'Test Title',
            ], 200)
        ]);

        $user = User::factory()->create();

        $response = $this->actingAs($user)
                        ->patch('/profile/request-external-api')
                        ->assertStatus(200);

        $response->assertInertia(function($page) {
            $page->component('Profile/Edit')
                ->where('title', 'Test Title'); // ★モックでセットした値がレスポンスとして返ってくることをテストする
        });
    }
}

テストがパスすることを確認する

$ ./vendor/bin/sail test --filter=test外部APIからデータを取得すること
   PASS  Tests\Browser\ProfileTest
  ✓ テストメール送信ボタンが動作すること           1.86s
  ・・・

バッチ処理

実装

ここでは、登録中のユーザーにメールを送信するコマンドを実装する

$ ./vendor/bin/sail artisan make:command SendEmails
app/Console/Commands/SendEmails.php
<?php

namespace App\Console\Commands;

use App\Mail\UserNotify;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Mail;

class SendEmails extends Command
{
    protected $signature = 'app:send-emails';

    protected $description = 'Command description';

    public function handle()
    {
        User::all()->each(function($user) {
            Mail::to($user->email)->send(new UserNotify($user));
        });
    }
}

コマンドを実行してメール送信に成功するとログに出力される

$ ./vendor/bin/sail artisan app:send-emails

テスト

$ ./vendor/bin/sail artisan make:test SendEmailsTest

ここではユーザーを10人登録して、全員にメールが送信されることをテストする

tests/Feature/SendEmailsTest.php
<?php

namespace Tests\Feature;

use App\Console\Commands\SendEmails;
use App\Mail\UserNotify;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;

class SendEmailsTest extends TestCase
{
    use RefreshDatabase;

    public function testユーザーにメール送信されること(): void
    {
        Mail::fake();

        $user = User::factory(10)->create();

        Artisan::call(SendEmails::class);

        Mail::assertSent(UserNotify::class, 10);
}

テストがパスすることを確認する

$ ./vendor/bin/sail test --filter=testユーザーにメール送信されること
   PASS  Tests\Feature\SendEmailsTest
  ✓ ユーザーにメール送信されること           0.38s
  ・・・

GitHub Actionsでテスト

公式ドキュメントにGitHub Actionsでテストするためのサンプルがあるのでこれを参考にする。

https://laravel.com/docs/11.x/dusk#running-tests-on-github-actions

動いたサンプル

.github/workflows/ci.yaml
name: CI
on: [push]
jobs:

  dusk-php:
    runs-on: ubuntu-latest
    steps:
      - name: Create Database
        run: |
          sudo systemctl start mysql
          mysql -uroot -proot -e "CREATE DATABASE testing"

      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.2'

      - name: Composer Install
        run: |
          sed -i \
              -e 's|APP_URL=http://laravel.test|APP_URL=http://localhost|g' \
              -e 's|DB_HOST=mysql|DB_HOST=localhost|g' \
              -e 's|DB_USERNAME=sail|DB_USERNAME=root|g' \
              -e 's|DB_PASSWORD=password|DB_PASSWORD=root|g' \
              .env.dusk.local
          cp .env.dusk.local .env
          composer install --no-progress --prefer-dist --optimize-autoloader

      - name: Npm Build
        run: |
          npm install
          npm run build

      - name: Upgrade and Start Chrome Driver
        run: |
          php artisan dusk:chrome-driver --detect
          ./vendor/laravel/dusk/bin/chromedriver-linux &

      - name: Run Laravel Server
        run: sudo php artisan serve --port=80 --no-reload &

      - name: Run Dusk Tests
        run: php artisan dusk

GitLab Runnerでテスト

GitLab Runnerduskを使ったテストをするためのDockerイメージとサンプルを作ってくれている人がいるのでこれを利用する。

https://github.com/chilio/laravel-dusk-ci

これを使ったサンプル

.gitlab-ci.yml

stages:
  - build
  - test

variables:
  MYSQL_ROOT_PASSWORD: root
  MYSQL_USER: sail
  MYSQL_PASSWORD: password
  MYSQL_DATABASE: testing
  DB_HOST: mysql
  DB_CONNECTION: mysql

build:
  stage: build
  image: chilio/laravel-dusk-ci:stable
  script:
    - composer install --no-progress --prefer-dist --optimize-autoloader
    - npm install
    - npm run build
  cache:
      key: ${CI_BUILD_REF_NAME}
      paths:
        - vendor
        - node_modules
  artifacts:
    paths:
      - public/build/*

test:
  stage: test
  cache:
    key: ${CI_BUILD_REF_NAME}
    paths:
      - vendor
      - node_modules
    policy: pull

  services:
    - name: mysql:8.4
      alias: mysql-test

  image: chilio/laravel-dusk-ci:stable
  script:
    - sed -i 's|APP_URL=http://laravel.test|APP_URL=http://localhost|g' .env.dusk.local
    - cp .env.dusk.local .env
    - configure-laravel
    - start-nginx-ci-project
    - php artisan dusk:install
    - php artisan dusk --colors --debug

  artifacts:
    paths:
      - ./storage/logs
      - ./tests/Browser/screenshots
      - ./tests/Browser/console
      - ./public/build
    when: always

その他

できるところはブラウザテストで、それが難しい場合はFeatureテストを書くというゆるーい方針で書きました。

今回使用したリポジトリ
https://github.com/nrikiji/laravel-testing-example

Discussion