Laravel12.xでFormRequestのユニットテストを作成します。今回の記事では基本的なテストから、少しややこしい$this->route()のようなルートパラメータを取得する必要がある場合の書き方などをご紹介します。

テスト対象

店舗情報の更新画面にて、入力データのバリデーション・認可処理を行うShopUpdateRequestが今回のテスト対象です。

入力項目は、店舗名(name)・店舗説明(description)・有効フラグ(active_flag)の3つ。rules()では必須チェックや文字数チェックなどの基本的なバリデーションと、nameに関しては他店舗と重複不可のユニーク制約も設定されています。

また、認可処理authorize()では管理者のみが実行可能なように設定されています。

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class ShopUpdateRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return auth()->user()->role === 'admin';
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        $shop = $this->route('shop');

        return [
            'name' => [
                'required',
                'string',
                'max:20',
                Rule::unique('shops')->ignore($shop),
            ],
            'description' => ['required', 'string', 'max:100'],
            'active_flag' => ['required', Rule::in(['Y', 'N'])],
        ];
    }

    public function messages(): array
    {
        return [
            'name.required'        => '名前は必須です',
            'name.max'             => '名前は20文字以内で入力してください',
            'name.unique'          => 'この名前は既に使用されています',
            'description.required' => '説明は必須です',
            'description.max'      => '説明は100文字以内で入力してください',
            'active_flag.required' => 'アクティブフラグは必須です',
            'active_flag.in'       => 'アクティブフラグは Y または N で入力してください',
        ];
    }

    protected function prepareForValidation(): void
    {
        // 店舗名の半角カタカナを全角カタカナに変換
        $this->merge([
            'name' => mb_convert_kana($this->input('name') ?? '', 'KV'),
        ]);
    }
}

そしてコントローラー側では、以下のような定義となっています。

・・・・・
use App\Http\Requests\ShopUpdateRequest;

class ShopController extends Controller
{
・・・・・
    public function update(ShopUpdateRequest $request, Shop $shop): RedirectResponse
    {
        $shop->update($request->validated());
        
        return redirect()->route('shops.show', $shop)
            ->with('success', '店舗情報を更新しました。');
    }
・・・・・
}

authorize()のテスト

まずはauthorize()のテストから。認証はadminユーザーのみ通ることができる、ということを検証します。

以下のようにRequestインスタンスから直接authorize()を呼び出してテストします。

namespace Tests\Unit\Http\Requests;

use App\Http\Requests\ShopUpdateRequest;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;

class ShopUpdateRequestTest extends TestCase
{
    use RefreshDatabase;

    #[Test]
    public function 管理者でないユーザーの場合は認証が通らない(): void
    {
        $normalUser = User::factory()->create(['role' => 'user']);
        $this->actingAs($normalUser);

        $request = new ShopUpdateRequest;
        $this->assertFalse($request->authorize());
    }

    #[Test]
    public function 管理者の場合は認証が通る(): void
    {
        $adminUser = User::factory()->create(['role' => 'admin']);
        $this->actingAs($adminUser);

        $request = new ShopUpdateRequest;
        $this->assertTrue($request->authorize());
    }
}

rules()のテスト

次に、rules()のテストです。正常系のテストは以下のように書くことができます。

・・・・・
use Illuminate\Support\Facades\Validator;
use PHPUnit\Framework\Attributes\DataProvider;
・・・・・

    #[Test]
    #[DataProvider('validationDataProvider')]
    public function 正常形のテスト(array $data): void
    {
        $rules = (new ShopUpdateRequest)->rules();

        $validator = Validator::make($data, $rules);

        $this->assertTrue($validator->passes());
    }

    public static function validDataProvider(): array
    {
        return [
            '文字数制限OK' => [
                [
                    'name'        => str_repeat('a', 20),
                    'description' => str_repeat('a', 100)
                    'active_flag' => 'Y',
                ],
            ],
        ];
    }

現時点で最新のLaravel12でも、テストの書き方は特に変わりません。このままデータプロバイダに他の正常系のパターンを追加していけばOKです。

次は異常系のテストです。エラーメッセージも同時に検証したいので、Validator::make()の第3引数にエラーメッセージの配列$request->messages()を渡しています。

・・・・・
    #[Test]
    #[DataProvider('invalidDataProvider')]
    public function 異常系のテスト(array $data, array $messages): void
    {
        $request = new ShopUpdateRequest;

        $validator = Validator::make($data, $request->rules(), $request->messages());

        $this->assertTrue($validator->fails());
        $this->assertSame($messages, $validator->errors()->toArray());
    }

    public static function invalidDataProvider(): array
    {
        return [
            '空値を許容しない' => [
                'data' => [
                    'name'        => '',
                    'description' => '',
                    'active_flag' => '',
                ],
                'messages' => [
                    'name'        => ['名前は必須です'],
                    'description' => ['説明は必須です'],
                    'active_flag' => ['アクティブフラグは必須です'],
                ],
            ],
            '文字数オーバー' => [
                'data' => [
                    'name'        => str_repeat('a', 21),
                    'description' => str_repeat('a', 101),
                    'active_flag' => 'Y',
                ],
                'messages' => [
                    'name'        => ['名前は20文字以内で入力してください'],
                    'description' => ['説明は100文字以内で入力してください'],
                ],
            ],
        ];
    }

このような形で、異常系もデータプロバイダにテストデータを追加していけばOKです。

ですが、nameプロパティで1つ問題があります。nameプロパティに設定されている以下のようなunique制約は、このテストのままでは検証できません。

        $shop = $this->route('shop');
・・・・・
        Rule::unique('shops')->ignore($shop),

$this->route()で取得しているルートパラメーターは、現状のテストではnullとなってしまうからです

テスト時は実際のHTTPリクエストとして実行するわけではないため、ルート情報が設定されていないことが原因のようです。テスト時にルートパラメータを取り扱うにはどうしたらいいでしょうか。

ルート情報を含んだリクエストインスタンスを作成する

ルートパラメータを含めたバリデーションテストをする場合、リクエストインスタンスにルート情報を設定する必要があります。

ここで、検証対象のルーティングを確認します。

PUT|PATCH   shops/{shop} ............................. shops.update › ShopController@update

ルーティング情報がわかったので、さっそくルート情報を持ったFormRequestインスタンスを作成してゆきましょう。まずはcreate()というFormRequestクラスに定義されているメソッドを使用します。

1. create()でリクエストインスタンスを作成

        $request = ShopUpdateRequest::create(
            route('shops.update', $shop),
            'PUT',
            $input
        );

第1引数にURL、第2引数にHTTPメソッド、第3引数に入力データを渡します。これで、ルーティング情報に合わせたリクエストインスタンスが作成できます。

入力情報もセットできているので、$request->input()での値取得も可能です。

よく使われる$request->merge($data)でも入力データをセットできますが、ルーティングがすでに用意されている場合は今回のようにcreate()を使うことで、より実際のHTTPリクエストに近い状況を作成することができます。

2. FormRequestクラスのsetRouteResolver()でルートパラメータを設定

リクエスト情報はセットできましたが、$this->route()を有効にするにはさらにルート情報をリクエストに適用する必要があります。インスタンスの作成に続いて、以下のようにデータを適用します。

        $request = ShopUpdateRequest::create(
         ・・・・・
        );

        // 実際のルートマッチングを取得し、リクエストに適用
        $route = Route::getRoutes()->match($request);
        $route->setParameter('shop', $currentShop);
        $request->setRouteResolver(fn () => $route);

setRouteResolver()$requestにルートを紐付けるメソッドです。これでやっと、テストコード内で$this->route('shop')でデータが取得できるようになりました。

ちゃんとデータが取得できるのかをtinkerでも確認してみます。

$ php artisan tinker                                                 
Psy Shell v0.12.8 (PHP 8.2.27 — cli) by Justin Hileman
> $shop = App\Models\Shop::factory()->create();                                                       
= App\Models\Shop {#6265
    name: "有限会社 桐山",
・・・・・

> $request = App\Http\Requests\ShopUpdateRequest::create(
    route('shops.update', $shop),    
    'PUT', 
    ['name' => 'テスト店舗', 'description' => 'テスト説明', 'active_flag' => 'Y']);

= App\Http\Requests\ShopUpdateRequest {#6306
・・・・・
  }

> $route = Route::getRoutes()->match($request);
= Illuminate\Routing\Route {#976
・・・・・

> $route->setParameter('shop', $shop);
= null

> $request->setRouteResolver(fn() => $route);
= App\Http\Requests\ShopUpdateRequest {#6306
・・・・・

> $request->route('shop');  // これで取得できるようになった!
= App\Models\Shop {#6265
    name: "有限会社 桐山",
・・・・・

問題なく取得できています!

ルートパラメータを使ったunique制約のユニットテスト

上記の方法を使った「既存のショップ名と重複不可」の検証テストコードは、以下のようになります。

・・・・・
use App\Models\Shop;
use Illuminate\Support\Facades\Route;
・・・・・

    #[Test]
    public function 既存のショップ名と重複不可(): void
    {
        // 既存のショップを作成
        Shop::factory()->create(['name' => '既存のショップ名']);

        // 更新対象のショップを作成
        $currentShop = Shop::factory()->create(['name' => '現在のショップ名']);

        // 更新リクエストのデータ
        $input = [
            'name'        => '既存のショップ名', // 既存のショップ名と重複
            'description' => 'テスト用の説明文です',
            'active_flag' => 'Y',
        ];

        // リクエストインスタンスの作成
        $request = ShopUpdateRequest::create(
            route('shops.update', $currentShop),
            'PUT',
            $input
        );

        // 実際のルートマッチングを取得し、リクエストに適用
        $route = Route::getRoutes()->match($request);
        $route->setParameter('shop', $currentShop);
        $request->setRouteResolver(fn () => $route);

        // バリデーション実行
        $validator = Validator::make($request->all(), $request->rules(), $request->messages());

        // バリデーションが失敗することをアサート
        $this->assertTrue($validator->fails());

        // エラーメッセージに重複エラーが含まれることをアサート
        $this->assertTrue($validator->errors()->has('name'));
        $this->assertEquals('この名前は既に使用されています', $validator->errors()->first('name'));
    }

テストデータ準備のため少しコードが長くなりましたが、バリデーションの実行・アサートの部分は先ほどの異常系のテストと同じです。

次回は引き続き、prepareForValidation()を含めたテストの書き方をご紹介します。

メルマガ購読の申し込みはこちらから。

By hmatsu