Laravel 10.x FormRequest 検証ルールのテスト

## 概要

LaravelにはHTTPテストの機能があり、このテストを行うとルーティング、フォームリクエスト、コントローラ、レスポンスをまとめてテストできるのでFormRequest単体でテストする機会は少ないかもしれません。

ただ、FormRequestの実装が複雑になるとHTTPテストのコストが高くなるのでFormRequest単体でテストしたいよね。ということがありました。

FormRequstクラスの中で `$this->input()` や `$this->route()` などを使って複雑な検証ルールになってる場合は参考にしてください。

※この記事では認可のテストは行なっていません。

## 環境

PHP: 8.2.8
Laravel: 10.15.0

## 関連記事

https://qiita.com/ucan-lab/items/9a294163b0fd8ba25aca

## 準備

```shell-session
$ touch tests/Unit/Http/Requests/RequestTestCase.php
```

```php:tests/Unit/Http/Requests/RequestTestCase.php
<?php

declare(strict_types=1);

namespace Tests\Unit\Http\Requests;

use Tests\TestCase;

abstract class RequestTestCase extends TestCase
{
    public function setUp(): void
    {
        parent::setUp();

        $this->createApplication();
    }
}
```

FormRequest のテスト用の親クラスを作ります。
Unitテストではヘルパーメソッドやファサードは使えないので、 `setUp()` をオーバーライドして `$this->createApplication();` を呼び出しています。(これするくらいだったらFeatureテストでよかったかも...)

`tests/TestCase.php` で `setUp()` をオーバーライドしても良いですが、FormRequestクラス以外のテストに影響するのが嫌なのでFormRequest用の親クラスを用意しました。

### バリデーションエラーメッセージの日本語化

https://qiita.com/ucan-lab/items/ac15e47f46da9de25772

こちらを実施しました。

## ArticleStoreRequest

テスト対象のFormRequestクラスを作成します。
記事の投稿をするフォームリクエストの例です。

```php:routes/web.php
// ルーティングの例
Route::post('/articles', ArticleStoreController::class)->name('articles.store');
```

```shell-session
$ php artisan make:request ArticleStoreRequest
```

```php:app/Http/Requests/ArticleStoreRequest.php
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;

final class ArticleStoreRequest extends FormRequest
{
    public function rules(): array
    {
        $maxTitleLength = $this->input('category_id') === 1 ? 50 : 30;

        return [
            'title' => ['required', 'string', 'min:3', "max:$maxTitleLength"],
            'body' => ['required', 'string', 'max:5000'],
            'category_id' => ['nullable', 'integer'],
            'tag_ids' => ['nullable', 'array', 'max:5'],
            'tag_ids.*' => ['required', 'integer'],
            'photos' => ['nullable', 'array', 'max:3'],
            'photos.*.file' => ['required', 'file', 'mimes:jpg,png', 'max:'. 5 * 1024],
            'photos.*.description' => ['nullable', 'string', 'max:100'],
            'published_at' => ['required', 'date_format:Y-m-d H:i:s'],
        ];
    }

    public function attributes(): array
    {
        return [
            'title' => 'タイトル',
            'body' => '内容',
            'category_id' => 'カテゴリID',
            'tag_ids' => 'タグID',
            'photos' => '写真',
            'photos.*.file' => '写真ファイル',
            'photos.*.description' => '写真説明',
            'published_at' => '公開日',
        ];
    }
}
```

- category_id が 1 の時は title を 50文字、それ以外は 30 文字まで入力できる

### ArticleStoreRequestTest

```shell-session
$ php artisan make:test --unit Http/Requests/ArticleStoreRequestTest
```

```php:tests/Unit/Http/Requests/ArticleStoreRequestTest.php
<?php

declare(strict_types=1);

namespace Tests\Unit\Http\Requests;

use App\Http\Requests\ArticleStoreRequest;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Http\UploadedFile;
use Symfony\Component\HttpFoundation\Request;

final class ArticleStoreRequestTest extends RequestTestCase
{
    /**
     * @dataProvider dataValidationSuccessful
     */
    public function testValidationSuccessful(array $data): void
    {
        $validator = $this->validate($data);

        if ($validator->fails()) {
            dump($validator->errors());
        }

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

    public static function dataValidationSuccessful(): array
    {
        return [
            '最小のテスト' => [
                'data' => self::minAttributes(),
            ],
            '最大のテスト' => [
                'data' => [
                    'title' => str_repeat('あ', 30),
                    'body' => str_repeat('あ', 5000),
                    'category_id' => 123,
                    'tag_ids' => [1, 2, 3, 4, 5],
                    'photos' => [
                        [
                            'file' => UploadedFile::fake()->image('dummy.png')->size(5 * 1024),
                            'description' => str_repeat('あ', 100),
                        ],
                        [
                            'file' => UploadedFile::fake()->image('dummy.jpg')->size(5 * 1024),
                            'description' => str_repeat('あ', 100),
                        ],
                        [
                            'file' => UploadedFile::fake()->image('dummy.jpeg')->size(5 * 1024),
                            'description' => str_repeat('あ', 100),
                        ],
                    ],
                    'published_at' => '2023-01-01 00:00:00',
                ],
            ],
            'カテゴリIDが1の時はタイトル50文字まで入力できる' => [
                'data' => self::minAttributes([
                    'title' => str_repeat('あ', 50),
                    'category_id' => 1,
                ]),
            ],
        ];
    }

    /**
     * @dataProvider dataValidationFailure
     */
    public function testValidationFailure(array $data, array $messages): void
    {
        $validator = $this->validate($data);

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

    public static function dataValidationFailure(): array
    {
        return [
            '必須項目テスト' => [
                'data' => [],
                'messages' => [
                    'title' => ['タイトルは必須項目です。'],
                    'body' => ['内容は必須項目です。'],
                    'published_at' => ['公開日は必須項目です。'],
                ],
            ],
            '超過のテスト' => [
                'data' => [
                    'title' => str_repeat('あ', 51),
                    'body' => str_repeat('あ', 5001),
                    'category_id' => 123,
                    'tag_ids' => [1, 2, 3, 4, 5, 6],
                    'photos' => [
                        [
                            'file' => UploadedFile::fake()->image('dummy.png')->size(5 * 1024 + 1),
                            'description' => str_repeat('あ', 101),
                        ],
                        [
                            'file' => UploadedFile::fake()->image('dummy.jpg')->size(5 * 1024 + 1),
                            'description' => str_repeat('あ', 101),
                        ],
                        [
                            'file' => UploadedFile::fake()->image('dummy.jpeg')->size(5 * 1024 + 1),
                            'description' => str_repeat('あ', 101),
                        ],
                        [
                            'file' => UploadedFile::fake()->image('dummy.png')->size(5 * 1024 + 1),
                            'description' => str_repeat('あ', 101),
                        ],
                    ],
                    'published_at' => '2023-01-01 00:00:00',
                ],
                'messages' => [
                    'title' => ['タイトルの文字数は、30文字以下である必要があります。'],
                    'body' => ['内容の文字数は、5000文字以下である必要があります。'],
                    'tag_ids' => ['タグIDの項目数は、5個以下である必要があります。'],
                    'photos' => ['写真の項目数は、3個以下である必要があります。'],
                    'photos.0.file' => ['写真ファイルは、5120 KB以下のファイルである必要があります。'],
                    'photos.1.file' => ['写真ファイルは、5120 KB以下のファイルである必要があります。'],
                    'photos.2.file' => ['写真ファイルは、5120 KB以下のファイルである必要があります。'],
                    'photos.3.file' => ['写真ファイルは、5120 KB以下のファイルである必要があります。'],
                    'photos.0.description' => ['写真説明の文字数は、100文字以下である必要があります。'],
                    'photos.1.description' => ['写真説明の文字数は、100文字以下である必要があります。'],
                    'photos.2.description' => ['写真説明の文字数は、100文字以下である必要があります。'],
                    'photos.3.description' => ['写真説明の文字数は、100文字以下である必要があります。'],
                ],
            ],
            '超過のテスト(カテゴリ1の時はタイトルに51文字でエラー)' => [
                'data' => self::minAttributes([
                    'title' => str_repeat('あ', 51),
                    'category_id' => 1,
                ]),
                'messages' => [
                    'title' => ['タイトルの文字数は、50文字以下である必要があります。'],
                ],
            ],
            'ファイル拡張子テスト' => [
                'data' => self::minAttributes([
                    'photos' => [
                        [
                            'file' => UploadedFile::fake()->image('dummy.gif'),
                        ],
                        [
                            'file' => UploadedFile::fake()->create('dummy.pdf'),
                        ],
                        [
                            'file' => UploadedFile::fake()->create('dummy.txt'),
                        ],
                    ],
                ]),
                'messages' => [
                    'photos.0.file' => ['写真ファイルには、以下のファイルタイプを指定してください。jpg, png'],
                    'photos.1.file' => ['写真ファイルには、以下のファイルタイプを指定してください。jpg, png'],
                    'photos.2.file' => ['写真ファイルには、以下のファイルタイプを指定してください。jpg, png'],
                ],
            ],
        ];
    }

    private function validate(array $data): Validator
    {
        $request = ArticleStoreRequest::create(
            route('articles.store'),
            Request::METHOD_POST,
            $data
        );

        return validator($data, $request->rules(), $request->messages(), $request->attributes());
    }

    private static function minAttributes(array $attributes = []): array
    {
        return $attributes + [
            'title' => str_repeat('あ', 3),
            'body' => 'あ',
            'published_at' => '2023-01-01 00:00:00',
        ];
    }
}
```

テストを実行する。

```shell-session
$ php artisan test tests/Unit/Http/Requests/ArticleStoreRequestTest.php

   PASS  Tests\Unit\Http\Requests\ArticleStoreRequestTest
  ✓ validation successful with data set "最小のテスト"                                                                    0.44s
  ✓ validation successful with data set "最大のテスト"                                                                    0.05s
  ✓ validation successful with data set "カテゴリ i dが1の時はタイトル50文字まで入力できる"                                              0.08s
  ✓ validation failure with data set "必須項目テスト"                                                                      0.08s
  ✓ validation failure with data set "超過のテスト"                                                                       0.06s
  ✓ validation failure with data set "超過のテスト(カテゴリ1の時はタイトルに51文字でエラー)"                                                0.06s
  ✓ validation failure with data set "ファイル拡張子テスト"                                                                   0.07s

  Tests:    7 passed (11 assertions)
  Duration: 1.09s
```

### 補足: ArticleStoreRequestTest

#### 入力値が検証ルールに含まれている場合

他のFormRequestのテスト記事を読むとこのように書かれていることが多かったです。

```php
$request = new ArticleStoreRequest();
validator($data, $request->rules(), $request->messages(), $request->attributes());
```

この場合だと `ArticleStoreRequest` の `rules()` で `$this->input('category_id')` とリクエストの入力値が使われているとここの値がnullになってしまいます。

```php
$request = new ArticleStoreRequest();
$request->merge($data);
validator($data, $request->rules(), $request->messages(), $request->attributes());
```

この問題は `$request->merge($data);` で解決できます。
ただ、 `create()` メソッドが用意されているので、こちらを使ってあげるのが良さそうです。

```shell-session
$request = ArticleStoreRequest::create(
    route('articles.store'),
    Request::METHOD_POST,
    $data
);
```

`$request->rules()` の中で `$this->input('category_id')` が使われていても入力値を取得できます。
ついでにルート名がちゃんと存在してくれるかもテストできるのでよかったです。

#### 検証ルール成功テストでコケた時の原因(tips)

```php
    public function testValidationSuccessful(array $data): void
    {
        $validator = $this->validate($data);

        if ($validator->fails()) {
            dump($validator->errors()); // 失敗時にdumpすることで何の原因で落ちたかがわかって便利
        }

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

#### アップロードファイルのテスト(tips)

`UploadedFile::fake()->image('dummy.png')->size(5 * 1024)`
`UploadedFile::fake()->create('dummy.txt')`

こんな感じでアップロードファイルのデータを作成できます。
※pngの画像データを作る場合はgd拡張ライブラリをインストールしておく必要はあります。

## ArticleTitleUpdateRequest

```php:routes/web.php
// ルーティングの例
Route::put('/articles/{article}/title', ArticleTitleUpdateController::class)->name('articles.title.update');
```

```shell-session
$ php artisan make:request ArticleTitleUpdateRequest
```

```php:app/Http/Requests/ArticleTitleUpdateRequest.php
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use App\Models\Article;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;

final class ArticleTitleUpdateRequest extends FormRequest
{
    public function rules(): array
    {
        /** @var Article $article */
        $article = $this->route('article');

        $maxTitleLength = $article->category_id === 1 ? 50 : 30;

        return [
            'title' => ['required', 'min:3', "max:$maxTitleLength"],
        ];
    }

    public function attributes(): array
    {
        return [
            'title' => 'タイトル',
        ];
    }
}
```

- `$this->route('article')` モデルルートバインディングを使って、動的に検証ルールを作成しています。

### ArticleTitleUpdateRequestTest

```shell-session
$ php artisan make:test --unit Http/Requests/ArticleTitleUpdateRequestTest
```

```php:tests/Unit/Http/Requests/ArticleTitleUpdateRequestTest.php
<?php

declare(strict_types=1);

namespace Tests\Unit\Http\Requests;

use App\Http\Requests\ArticleTitleUpdateRequest;
use App\Models\Article;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Support\Facades\Route;
use Symfony\Component\HttpFoundation\Request;

final class ArticleTitleUpdateRequestTest extends RequestTestCase
{
    /**
     * @dataProvider dataValidationSuccessful
     */
    public function testValidationSuccessful(array $articleAttributes, array $data): void
    {
        $article = Article::factory()->create($articleAttributes);
        $validator = $this->validate($data, $article);

        if ($validator->fails()) {
            dump($validator->errors());
        }

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

    public static function dataValidationSuccessful(): array
    {
        return [
            '最小のテスト' => [
                'articleAttributes' => [
                    'category_id' => 123,
                ],
                'data' => [
                    'title' => str_repeat('あ', 3),
                ],
            ],
            '最大のテスト' => [
                'articleAttributes' => [
                    'category_id' => 123,
                ],
                'data' => [
                    'title' => str_repeat('あ', 30),
                ],
            ],
            '最大のテスト(カテゴリ1の時はタイトルに50文字入力できる)' => [
                'articleAttributes' => [
                    'category_id' => 1,
                ],
                'data' => [
                    'title' => str_repeat('あ', 50),
                ],
            ],
        ];
    }

    /**
     * @dataProvider dataValidationFailure
     */
    public function testValidationFailure(array $articleAttributes, array $data, array $messages): void
    {
        $article = Article::factory()->create($articleAttributes);
        $validator = $this->validate($data, $article);

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

    public static function dataValidationFailure(): array
    {
        return [
            '必須項目テスト' => [
                'articleAttributes' => [
                    'category_id' => 123,
                ],
                'data' => [
                    'title' => null,
                ],
                'messages' => [
                    'title' => ['タイトルは必須項目です。'],
                ],
            ],
            '超過のテスト' => [
                'articleAttributes' => [
                    'category_id' => 123,
                ],
                'data' => [
                    'title' => str_repeat('あ', 31),
                ],
                'messages' => [
                    'title' => ['タイトルの文字数は、30文字以下である必要があります。'],
                ],
            ],
            '超過のテスト(カテゴリ1の時はタイトルに51文字でエラー)' => [
                'articleAttributes' => [
                    'category_id' => 1,
                ],
                'data' => [
                    'title' => str_repeat('あ', 51),
                ],
                'messages' => [
                    'title' => ['タイトルの文字数は、50文字以下である必要があります。'],
                ],
            ],
        ];
    }

    private function validate(array $data, Article $article): Validator
    {
        $request = ArticleTitleUpdateRequest::create(
            route('articles.title.update', $article->id),
            Request::METHOD_PUT,
            $data
        );

        $request->setRouteResolver(static function () use ($request, $article) {
            $route = Route::getRoutes()->match($request);
            $route->setParameter('article', $article);

            return $route;
        });

        return validator($data, $request->rules(), $request->messages(), $request->attributes());
    }
}
```

テストを実行する。

```
$ php artisan test tests/Unit/Http/Requests/ArticleTitleUpdateRequestTest.php

   PASS  Tests\Unit\Http\Requests\ArticleTitleUpdateRequestTest
  ✓ validation successful with data set "最小のテスト"                                                                                                                     0.56s
  ✓ validation successful with data set "最大のテスト"                                                                                                                     0.31s
  ✓ validation successful with data set "最大のテスト(カテゴリ1の時はタイトルに50文字入力できる)"                                                                                             0.30s
  ✓ validation failure with data set "必須項目テスト"                                                                                                                       0.07s
  ✓ validation failure with data set "超過のテスト"                                                                                                                        0.26s
  ✓ validation failure with data set "超過のテスト(カテゴリ1の時はタイトルに51文字でエラー)"                                                                                                 0.09s

  Tests:    6 passed (9 assertions)
  Duration: 1.83s
```

### 補足: ArticleTitleUpdateRequestTest

#### FormRequest単体テストの時はモデルルートバインディングが効かない

Laravelのモデルルートバインディング機能は `\Illuminate\Routing\Middleware\SubstituteBindings` のミドルウェアで行われています。
そのため、`XxxRequest::create()` とインスタンスを作っただけではモデルの解決ができません。

```php
    private function validate(array $data, Article $article): Validator
    {
        $request = ArticleTitleUpdateRequest::create(
            route('articles.title.update', $article->id),
            Request::METHOD_PUT,
            $data
        );

        $request->setRouteResolver(static function () use ($request, $article) {
            $route = Route::getRoutes()->match($request);
            $route->setParameter('article', $article);

            return $route;
        });

        return validator($data, $request->rules(), $request->messages(), $request->attributes());
    }
```

FormRequestクラスの `setRouteResolver` メソッドを使って、モデルルートバインディングを解決できました。

## 参考

https://oki2a24.com/2021/07/06/4-ways-to-unit-test-even-if-accessing-uri-parameter-defined-in-route-in-rules-method-of-the-form-request-class-in-laravel6/
https://blog.shimabox.net/2020/05/24/laravel-testing_classes_that_depend_on_the_request_class/
https://stackoverflow.com/questions/37347415/laravel-access-model-instance-in-form-request-when-using-route-model-binding