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