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()
を含めたテストの書き方をご紹介します。