はじめに
本記事は、All About(株式会社オールアバウト) Advent Calendar 2023の11日目の投稿です。
フォームリクエスト1のテストを書いているときに、リクエストに対してルーティングパラメータを含めたURIの設定がうまくできずに手こずったので書き留めておこうと思います。
環境
- PHP 8.1.23
- PHPUnit 9.5.27
- Laravel 8.83.27
やりたいこと
フォームリクエストのバリデーションルールで、特定のIDの場合はuniqueルールを無視するようにした箇所をテストで動作確認したい!から始まりました。
public function rules()
{
return [
"email" => [Rule::unique("users")->ignore($this->route("user"))]
];
}
$this->route("user")
をテストコードで設定すればできそう...
試したこと
リクエストの内容と、モデルが現在保持しているuniqueなカラムの値が重複してもバリデーションエラーにならないことを確認するテストを作成します。
まず、リクエストと重複するレコードを持つモデルをfactoryで作成して、
$user = User::factory()->state(["email" => "user@example.com"])->create();
リクエストの準備をします。
$request = new Request(["email" => $user->email]);
次に、リクエストにURIを設定(/user/{id}みたいになってほしい)し、
$request->server->set('REQUEST_URI', "user/" . $user->id);
UpdateRequestでバリデーションされたらメッセージを取得するようにします。
$errors = []; // エラーにならなかった場合にcatchされないので事前に定義しておく
try {
$this->app->instance("request", $request);
$this->app->make(UpdateRequest::class);
} catch (ValidationException $e) {
$errors = $e->errors();
}
最後に、期待値としてエラーメッセージが取得されたかどうかを見ます。
今回は、バリデーションエラーにならないことを確認したいので,emailの項目でエラーメッセージが追加されていないことを確認します。
$this->assertEquals([], Arr::get($errors, "email.0"));
しかし、このテストパターンではuniqueルールに引っかかってエラーメッセージが出力されるため失敗します。
テストが失敗する原因
結論から言うと、UpdateRequest.php
内のignoreで使用している$this->route()
の値を取得できていなかったため、factoryで作成した$user
のidと値が一致せずにuniqueルールに引っかかった形になりました。
では、なぜ$this->route()
の値を取得できていなかったのでしょうか。
テストコード内や実装内で、URIの値をdumpすれば出力されていることは確認できるため、URIのセット自体はできていそうです。
今回使用している、Illuminate\Http\Request
のroute()
の実装を見てみると、
/**
* Get the route handling the request.
*
* @param string|null $param
* @param mixed $default
* @return \Illuminate\Routing\Route|object|string|null
*/
public function route($param = null, $default = null)
{
$route = call_user_func($this->getRouteResolver());
if (is_null($route) || is_null($param)) {
return $route;
}
return $route->parameter($param, $default);
}
getRouteResolver()
を呼んで、リクエストに対応するrouteを解決できたらパラメータを返してくれそうです。
getRouteResolver()
はただのゲッターみたいなので、
/**
* Get the route resolver callback.
*
* @return \Closure
*/
public function getRouteResolver()
{
return $this->routeResolver ?: function () {
//
};
}
setRouteResolver()
で値をセットしてあげればroute()
で取得できそうです。
/**
* Set the route resolver callback.
*
* @param \Closure $callback
* @return $this
*/
public function setRouteResolver(Closure $callback)
{
$this->routeResolver = $callback;
return $this;
}
しかし、RouteResolverを追加してもまだテストは動作しませんでした。
どうやら、フォームリクエストでバリデーションを行うまでにはまだ足りない箇所があったみたいです。
こちらの記事2に記載の通り、Illuminate/Foundation/Providers/FormRequestServiceProvider.php
のboot()
を参照してみると、
/**
* Bootstrap the application services.
*
* @return void
*/
public function boot()
{
$this->app->afterResolving(ValidatesWhenResolved::class, function ($resolved) {
$resolved->validateResolved();
});
$this->app->resolving(FormRequest::class, function ($request, $app) {
$request = FormRequest::createFrom($app['request'], $request);
$request->setContainer($app)->setRedirector($app->make(Redirector::class));
});
}
FormRequestの解決時に、リクエストのオブジェクトのコンテナとして$app
を設定し、リクエストのリダイレクトを行えるようにリダイレクタの設定を行うといった設定が必要だったみたいです。
解決策
これらを踏まえて、修正したテストがこちらです。
/** @test */
public function 自分自身で保持しているemailと重複してもエラーにならない()
{
$user = User::factory()->state(["email" => "user@example.com"])->create();
// フォームリクエストにrouteResolverをセット
$request = UpdateRequest::create(uri: "user/{$user->id}", method: "POST", parameters: ["email" => $user->email]);
$request->setRouteResolver(fn () => (new Route("post", "user/{id}", []))->bind($request));
// アプリケーションコンテナとリダイレクタを登録
$request->setContainer($this->app)->setRedirector($this->app->make(Redirector::class));
// エラーにならなかった場合にcatchされないので事前に定義しておく
$errors = [];
try {
// バリデーション後の結果を取得
$request->validateResolved();
} catch (ValidationException $e) {
$errors = $e->errors();
}
$this->assertEquals([], Arr::get($errors, "email.0"));
}
これでuniqueルールにignore()
を適用させた場合のテストが実行できるようになりました。
おわりに
結局自分だけの力では動作させる箇所まで辿り着けなかったので、最終的には調べたり周りに相談したりしての解決になりました。
解決後に、割と近い内容で書かれていた記事を見つけたので参考3として載せておきます。
勉強にはなりましたが、まだ理解が曖昧な部分も残っているので内容に不備があればご指摘頂けると幸いです🙏
※mockを使っても良かったのかもしれませんが、LaravelのドキュメントでRequestのmockは推奨されていないため、今回は使わずにテストしています。