Laravelでupdate用FormRequestのバリデーション単体テストを考える
はじめに
Laravelで登録処理のバリデーションをテストするときは、比較的シンプルに考えられます。
たとえば、タグ名を登録する場合、次のようなルールを書きます。
'name' => 'required|string|max:50|unique:tags,name',
これは、
tagsテーブルのnameに、同じ値が存在してはいけない
という意味です。
しかし、更新処理では少し考え方が変わります。
なぜなら、更新対象のデータは、すでにDBに存在しているからです。
この記事では、Laravelでupdate用FormRequestのバリデーション単体テストを行うときに、なぜRoute設定が必要になるのか、どのようにテストを組み立てるのかを整理します。
この記事で扱うこと
- updateの単体テストが少し複雑になる理由
- 更新用FormRequestのuniqueルール
- FeatureテストとFormRequest単体テストの違い
- 単体テストでRoute設定が必要な理由
- update用単体テストの基本形
- makeValidatorの処理内容
- 確認すべきテストケース
- よくある失敗
- 逆引き表
- まとめ
updateの単体テストが少し複雑になる理由
新規登録のバリデーションでは、次のように考えれば十分です。
tagsテーブルに同じnameが存在しないこと
たとえば、登録用Requestで次のようなルールを書いたとします。
'name' => 'required|string|max:50|unique:tags,name',
この場合、tags テーブルの name カラムに同じ値があれば、バリデーションエラーになります。
しかし、更新の場合は少し違います。
たとえば、DBに次のタグが存在しているとします。
id: 1
name: Laravel
このタグを更新するときに、名前を変更せず、そのまま Laravel として送信した場合は、本来エラーにしたくありません。
更新前: Laravel
更新後: Laravel
これは、自分自身の名前をそのまま維持しているだけだからです。
しかし、単純に次のルールだけを書くと問題が起きます。
'unique:tags,name'
このルールでは、Laravelは次のように判断します。
tagsテーブルに Laravel が存在している
↓
重複している
↓
バリデーションエラー
つまり、自分自身の名前なのに、重複エラーになってしまいます。
そのため、更新処理では次の考え方が必要です。
tagsテーブルのnameは一意であること
ただし、今更新している自分自身は重複チェックから除外する
更新用FormRequestのuniqueルール
更新用の TagUpdateRequest では、次のように書きます。
use Illuminate\Validation\Rule;
public function rules(): array
{
$tag = $this->route('tag');
return [
'name' => [
'required',
'string',
'max:50',
Rule::unique('tags', 'name')->ignore($tag->id),
],
];
}
重要なのは、次の2つです。
$tag = $this->route('tag');
Rule::unique('tags', 'name')->ignore($tag->id)
$this->route('tag') の意味
$tag = $this->route('tag');
これは、
今更新しているタグをRouteから取得する
という意味です。
たとえば、ルートが次のようになっているとします。
Route::put('/tags/{tag}', [TagController::class, 'update'])
->name('tags.update');
この {tag} に対応するタグが、更新対象のタグです。
PUT /tags/1
↓
{tag} は id:1 のTag
↓
$this->route('tag') で取得できる
ignore($tag->id) の意味
Rule::unique('tags', 'name')->ignore($tag->id)
これは、次の意味です。
tagsテーブルのnameは一意であること
ただし、id が $tag->id のレコードは除外する
たとえば、次のデータがあるとします。
id: 1, name: Laravel
id:1のタグを更新するとき、ignore(1) と指定すると、重複チェックではid:1のレコードを除外します。
そのため、次の更新は通ります。
id:1 Laravel
↓
id:1 Laravel
一方で、別のタグにすでに使われている名前へ変更しようとすると、エラーになります。
id:1 name: Laravel
id:2 name: PHP
id:2 の PHP を Laravel に変更する
↓
Laravel は id:1 で使われている
↓
バリデーションエラー
FeatureテストとFormRequest単体テストの違い
更新処理のテストには、大きく分けて2つの考え方があります。
| テスト | 確認すること |
|---|---|
| Featureテスト | 実際のルートを通して、更新処理全体が動くか |
| FormRequest単体テスト | FormRequestのバリデーションルールだけが正しく動くか |
Featureテストでは、次のように実際のルートを通ります。
$this->put(route('tags.update', $tag), [
'name' => 'Laravel',
]);
この場合、Laravelが自動でRoute情報を扱ってくれます。
tags/{tag}
↓
{tag} に $tag が入る
↓
TagUpdateRequest内で $this->route('tag') が使える
一方、FormRequest単体テストでは、Requestを直接作ります。
$request = new TagUpdateRequest();
この場合、実際のURLやRouteを通っていません。
そのため、何もしないまま rules() を呼ぶと、Requestは「今どのタグを更新しているのか」を判断できません。
単体テストでRoute設定が必要な理由
更新用Requestでは、次のようにRouteから更新対象のタグを取得しています。
$tag = $this->route('tag');
しかし、単体テストでは実際のルートを通らずにRequestを直接作ります。
$request = new TagUpdateRequest();
このままだと、Requestの中で次の処理をしても、Laravelは更新対象のタグを判断できません。
$this->route('tag')
そこで、単体テスト側で仮のRouteを作り、Requestに設定します。
単体テストで仮のRouteを作る
↓
tags/{tag} の {tag} に更新対象のTagを設定する
↓
Requestに「このRouteを使ってください」と教える
↓
TagUpdateRequest内で $this->route('tag') が使える
これが、update用FormRequestの単体テストでRoute設定が必要になる理由です。
update用単体テストの基本形
TagUpdateRequest のバリデーション単体テストは、次のような形で書けます。
<?php
namespace Tests\Unit\Validation;
use App\Http\Requests\TagUpdateRequest;
use App\Models\Tag;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Routing\Route;
use Illuminate\Support\Facades\Validator;
use Tests\TestCase;
class TagUpdateRequestTest extends TestCase
{
use RefreshDatabase;
private function makeValidator(array $data, Tag $tag)
{
$request = new TagUpdateRequest();
$route = new Route(['PUT'], 'tags/{tag}', []);
$route->bind($request);
$route->setParameter('tag', $tag);
$request->setRouteResolver(function () use ($route) {
return $route;
});
return Validator::make(
$data,
$request->rules(),
$request->messages()
);
}
private function validData(array $override = []): array
{
return array_merge([
'name' => 'Laravel',
], $override);
}
}
このテストは、実際にControllerのupdate処理を動かすものではありません。
確認しているのは、あくまで次の内容です。
TagUpdateRequestのrules()が、更新時の入力値を正しく判定できるか
makeValidatorの処理内容
ここでは、makeValidator() の中身を分解して見ていきます。
Requestオブジェクトを作る
$request = new TagUpdateRequest();
これは、TagUpdateRequest の rules() と messages() を使うために、Requestオブジェクトを作っています。
FormRequest単体テストでは、Controllerを通さずにRequestを直接作ります。
仮のRouteを作る
$route = new Route(['PUT'], 'tags/{tag}', []);
これは、単体テスト用に仮の更新Routeを作っています。
意味は次の通りです。
PUT tags/{tag}
['PUT'] は、このルートがPUTメソッド用であることを表します。
更新処理では通常 PUT や PATCH を使うため、ここでは PUT を指定しています。
RouteとRequestを結びつける
$route->bind($request);
これは、
このRouteは、このRequestに対応しています
とLaravelに教える処理です。
この処理をしないままRouteパラメータを扱おうとすると、次のようなエラーになることがあります。
Route is not bound.
そのため、setParameter() を使う前に、RouteとRequestを結びつけておきます。
{tag} に更新対象のTagを設定する
$route->setParameter('tag', $tag);
これは、
tags/{tag} の {tag} に、更新対象の $tag を入れる
という意味です。
たとえば、次のタグを作ったとします。
$tag = Tag::create([
'name' => 'PHP',
]);
この場合、仮Routeには次の情報が設定されます。
{tag} = id:1, name:PHP のTagモデル
これにより、Request内で次のように取得できます。
$this->route('tag')
RequestにRouteを教える
$request->setRouteResolver(function () use ($route) {
return $route;
});
これは、Requestに対して、
route() が呼ばれたら、この仮Routeを返してください
と設定しています。
これにより、TagUpdateRequest の中で次のコードが動きます。
$tag = $this->route('tag');
処理の流れは次のようになります。
テスト側で $tag を作る
↓
仮Routeの {tag} に $tag を設定する
↓
RequestにそのRouteを設定する
↓
TagUpdateRequest内で $this->route('tag') を呼ぶ
↓
更新対象の $tag を取得できる
Validator::make() でバリデーションを実行する
return Validator::make(
$data,
$request->rules(),
$request->messages()
);
ここでは、LaravelのValidatorを使って、FormRequestのルールを直接確認しています。
$data
↓
テストしたい入力値
$request->rules()
↓
TagUpdateRequestのバリデーションルール
$request->messages()
↓
TagUpdateRequestのエラーメッセージ
このようにすることで、ControllerやRoute全体を通さずに、FormRequestのバリデーションだけを確認できます。
validData() の役割
private function validData(array $override = []): array
{
return array_merge([
'name' => 'Laravel',
], $override);
}
validData() は、正常な入力データを作るための補助メソッドです。
$override を使うことで、一部の値だけを差し替えられます。
たとえば、タグ名を空にしたい場合は、次のように書けます。
$this->validData([
'name' => '',
]);
これにより、毎回すべての入力データを書く必要がなくなります。
確認すべきテストケース
タグ更新のバリデーション単体テストでは、最低限次の項目を確認するとよいです。
1. タグ名を別の有効な名前に変更できる
2. タグ名が空の場合はエラーになる
3. タグ名は50文字まで通過する
4. タグ名が51文字以上ならエラーになる
5. 自身の名前維持は可能である
6. 他で既に使用されているタグ名への変更はエラーになる
特にupdateで重要なのは、次の2つです。
自身の名前維持は可能である
他で既に使用されている名前への変更は拒否する
タグ名を変更できるテスト
public function test_タグ名を変更できる(): void
{
$tag = Tag::create([
'name' => 'PHP',
]);
$validator = $this->makeValidator(
$this->validData([
'name' => 'Laravel',
]),
$tag
);
$this->assertFalse($validator->fails());
}
このテストでは、次の状況を作っています。
現在のタグ:
id:1, name:PHP
変更後の入力:
name:Laravel
DBには Laravel というタグが存在していません。
そのため、この入力は有効です。
このテストでは、
PHPタグをLaravelに変更する入力はバリデーションを通過する
ことを確認しています。
自身の名前維持は可能であるテスト
public function test_更新時に自身の名前維持は可能である(): void
{
$tag = Tag::create([
'name' => 'Laravel',
]);
$validator = $this->makeValidator(
$this->validData([
'name' => 'Laravel',
]),
$tag
);
$this->assertFalse($validator->fails());
}
このテストでは、次の状況を作っています。
現在のタグ:
id:1, name:Laravel
変更後の入力:
name:Laravel
普通の unique:tags,name だけなら、Laravel がDBに存在するため、エラーになります。
しかし、更新用Requestでは次のように書いています。
Rule::unique('tags', 'name')->ignore($tag->id)
これにより、自分自身のidは重複チェックから除外されます。
そのため、名前を変更せずに更新する入力はバリデーションを通過します。
他で使われている名前への変更は拒否するテスト
public function test_他で既に使用されているタグ名への変更はバリデーションエラーになる(): void
{
$existingTag = Tag::create([
'name' => 'Laravel',
]);
$targetTag = Tag::create([
'name' => 'PHP',
]);
$validator = $this->makeValidator(
$this->validData([
'name' => $existingTag->name,
]),
$targetTag
);
$this->assertTrue($validator->fails());
$this->assertArrayHasKey(
'name',
$validator->errors()->toArray()
);
}
このテストでは、DBに次の2件があります。
id:1, name:Laravel
id:2, name:PHP
そして、id:2 の PHP を Laravel に変更しようとしています。
id:2 PHP
↓
id:2 Laravel
この場合、Laravel はすでに id:1 で使われています。
更新対象は id:2 なので、id:2 自身は重複チェックから除外されます。
しかし、id:1 の Laravel は除外されません。
そのため、バリデーションエラーになります。
このテストでは、次のことを確認しています。
他のタグで既に使われている名前には変更できない
空欄ならエラーになるテスト
public function test_タグ名が空の場合はバリデーションエラーになる(): void
{
$tag = Tag::create([
'name' => 'PHP',
]);
$validator = $this->makeValidator(
$this->validData([
'name' => '',
]),
$tag
);
$this->assertTrue($validator->fails());
$this->assertArrayHasKey(
'name',
$validator->errors()->toArray()
);
}
このテストでは、required が正しく動くかを確認しています。
nameが空
↓
requiredに違反
↓
バリデーションエラー
50文字までは通過するテスト
public function test_タグ名は50文字まで通過する(): void
{
$tag = Tag::create([
'name' => 'PHP',
]);
$validator = $this->makeValidator(
$this->validData([
'name' => str_repeat('a', 50),
]),
$tag
);
$this->assertFalse($validator->fails());
}
このテストでは、max:50 の境界値を確認しています。
50文字
↓
max:50以内
↓
通過
51文字以上ならエラーになるテスト
public function test_タグ名が51文字以上の場合はバリデーションエラーになる(): void
{
$tag = Tag::create([
'name' => 'PHP',
]);
$validator = $this->makeValidator(
$this->validData([
'name' => str_repeat('a', 51),
]),
$tag
);
$this->assertTrue($validator->fails());
$this->assertArrayHasKey(
'name',
$validator->errors()->toArray()
);
}
このテストでは、max:50 を超えた場合にエラーになることを確認しています。
51文字
↓
max:50を超える
↓
バリデーションエラー
よくある失敗
1. uniqueだけを書いてしまう
更新用Requestで次のように書くと、自分自身の名前も重複として扱われます。
'name' => 'required|string|max:50|unique:tags,name',
更新用では、自分自身を除外する必要があります。
Rule::unique('tags', 'name')->ignore($tag->id)
2. $this->route('tag') がnullになる
単体テストでRoute設定をしていないと、次の処理で更新対象のタグを取得できません。
$this->route('tag')
その結果、次のようなエラーになることがあります。
Attempt to read property "id" on null
単体テストでは、仮Routeを作り、Requestに設定する必要があります。
$route = new Route(['PUT'], 'tags/{tag}', []);
$route->bind($request);
$route->setParameter('tag', $tag);
$request->setRouteResolver(function () use ($route) {
return $route;
});
3. Route is not bound. が出る
次のようなエラーが出る場合があります。
Route is not bound.
これは、RouteとRequestが正しく結びついていない状態で、Routeパラメータを扱おうとしている可能性があります。
その場合は、次の処理があるか確認します。
$route->bind($request);
4. assertFalseとassertTrueを逆にしてしまう
バリデーションの成功・失敗を確認するときは、fails() の意味に注意します。
$validator->fails()
これは、
バリデーションに失敗したか
を返します。
そのため、通過してほしい場合は次のように書きます。
$this->assertFalse($validator->fails());
エラーになってほしい場合は、次のように書きます。
$this->assertTrue($validator->fails());
5. テストの目的がDB更新確認になってしまう
FormRequest単体テストで確認するのは、DBの値が更新されたかどうかではありません。
確認するのは、入力値がバリデーションを通過するか、エラーになるかです。
FormRequest単体テスト
↓
rules() が正しく判定するかを確認する
Featureテスト
↓
実際に更新処理が成功するかを確認する
この2つを分けて考えることが大切です。
逆引き表
| やりたいこと | 書き方 |
|---|---|
| 更新対象のタグを取得する | $this->route('tag') |
| 自分自身をuniqueチェックから除外する | Rule::unique('tags', 'name')->ignore($tag->id) |
| FormRequestを直接作る | $request = new TagUpdateRequest(); |
| 仮のPUTルートを作る | new Route(['PUT'], 'tags/{tag}', []) |
| RouteとRequestを結びつける | $route->bind($request) |
{tag} に更新対象を設定する |
$route->setParameter('tag', $tag) |
| Requestに仮Routeを設定する | $request->setRouteResolver(...) |
| バリデーションを実行する | Validator::make($data, $request->rules(), $request->messages()) |
| 通過することを確認する | $this->assertFalse($validator->fails()) |
| エラーになることを確認する | $this->assertTrue($validator->fails()) |
| エラー項目を確認する | $this->assertArrayHasKey('name', $validator->errors()->toArray()) |
まとめ
update用FormRequestの単体テストが少し複雑になる理由は、更新対象のモデルをRequest内で使う必要があるからです。
更新用Requestでは、次のように現在更新しているタグをRouteから取得します。
$tag = $this->route('tag');
そして、次のように自分自身を重複チェックから除外します。
Rule::unique('tags', 'name')->ignore($tag->id)
Featureテストであれば、実際のルートを通るため、LaravelがRoute情報を自動で扱ってくれます。
しかし、FormRequest単体テストではRequestを直接作るため、テスト側で仮のRouteを作って設定する必要があります。
$route = new Route(['PUT'], 'tags/{tag}', []);
$route->bind($request);
$route->setParameter('tag', $tag);
$request->setRouteResolver(function () use ($route) {
return $route;
});
この処理は、簡単に言うと次の意味です。
この単体テストでは、$tag を「今更新しているタグ」として扱ってください
update用FormRequestの単体テストでは、特に次の2つを確認することが重要です。
自分自身の名前はそのまま使える
他のレコードで使われている名前には変更できない
この2つを確認できれば、更新時の unique バリデーションの考え方を正しくテストできます。