はじめに
ここ最近Laravel案件をやっています。
ここではRequestクラスをそのまま使わずに、Requestクラスを注入したラッパー(このラッパーをParameterクラス
と呼びます)を用意しつつビジネスロジック層で利用するという設計を取り入れています。
※ このParameterクラスはRequestクラスからrouteパラメータやGetパラメータやPostパラメータを受け取って、ビジネスロジック層で使いやすい形に変換してパラメータを返す役割を持ちます
まぁこの設計が良いのか悪いのかはさておき、このParameterクラスのテストを書くにあたって詰まった箇所(ルートパラメータを取得する箇所でつまづきました!)および、その解決方法を見いだせたのでメモとして残しておきます。
念の為、バージョンを書いておくと
- php
- 7.3.11
- Laravel
- 6.18.7
となりますが、この付近のバージョンであれば(7系も)そんなに変わらないかなと思います。
以下、ParameterクラスがRequestクラスに依存しているという前提で話を進めますがRequestクラスに依存するものが違うクラス(UseCase層とか)でも問題ありません。
かんたんな仕様
先にかんたんな仕様を書きます。
- Idで特定したユーザーの投稿一覧を表示する
- Getパラメータで投稿日時を絞れる
startDate
とendDate
を受け取る
- かんたんの為、バリデーションは省きます
routes/web.php
<?php Route::get('/user/{id}/posts', 'UserPostController@index')->name('user.posts.index');
UserPostParameter.php
<?php declare(strict_types=1); namespace App\Http\Parameters; use Carbon\CarbonImmutable; use Exception; use Illuminate\Http\Request; class UserPostParameter { /** @var int */ private $userId; /** @var ?CarbonImmutable */ private $startDate; /** @var ?CarbonImmutable */ private $endDate; public function __construct(Request $request) { $this->userId = (int) $request->route('id'); $startDate = $request->get('startDate'); $this->startDate = $startDate ? $this->tryCreateDate($startDate) : null; $endDate = $request->get('endDate'); $this->endDate = $endDate ? $this->tryCreateDate($endDate) : null; } public function getUserId(): int { return $this->userId; } public function getStartDate(): ?CarbonImmutable { return $this->startDate; } public function getEndDate(): ?CarbonImmutable { return $this->endDate; } private function tryCreateDate(string $date): ?CarbonImmutable { try { return new CarbonImmutable($date); } catch (Exception $e) { return null; } } }
app/Http/Controllers/UserPostController.php
<?php declare(strict_types=1); namespace App\Http\Controllers; use App\Http\Parameters\UserPostParameter; class UserPostController extends Controller { public function index(UserPostParameter $parameter) { // Do something... dd($parameter); } }
上記ファイルを用意した上で、{your url}/user/1/posts/?startDate=20200524
にアクセスすると
App\Http\Parameters\UserPostParameter {#719 ▼ -userId: 1 -startDate: Carbon\CarbonImmutable @1590246000 {#662 ▶} -endDate: null }
と表示されるかと思います。
今回はこのUserPostParameter.php
のテストを書きます。
困ったこと
冒頭に書いた、ちょっと書き方に悩んだ部分というのはRequestクラスからルートパラメータを取る部分です。以下に例をあげます。
※ ルートパラメータ(routeパラメータ)
Route::get('/user/{id}/posts', 'UserPostController@index')
の{id}
部分です
失敗する例
tests/UserPostParameterTest.php
<?php declare(strict_types=1); namespace Tests; use App\Http\Parameters\UserPostParameter; use Carbon\CarbonImmutable; use Illuminate\Http\Request; class UserPostParameterTest extends TestCase { /** * @test */ public function ルートで指定されたUserIdが取得できる() { $request = $this->createRequest( ['id' => 1] ); $target = new UserPostParameter($request); $this->assertSame(1, $target->getUserId()); } /** * @test */ public function startDateが取得できる() { $expected = new CarbonImmutable('2020-05-24'); $request = $this->createRequest( ['id' => 1], ['startDate' => '2020-05-24'] ); $target = new UserPostParameter($request); $this->assertEquals($expected, $target->getStartDate()); $this->assertNull($target->getEndDate()); } /** * @test */ public function endDateが取得できる() { $expected = new CarbonImmutable('2020-05-24'); $request = $this->createRequest( ['id' => 1], ['endDate' => '2020-05-24'] ); $target = new UserPostParameter($request); $this->assertEquals($expected, $target->getEndDate()); $this->assertNull($target->getStartDate()); } /** * @param array $queryParameters * @param array $routeParameters * @return Request */ private function createRequest( array $routeParameters, array $queryParameters = [] ): Request { return Request::create( route('user.posts.index', $routeParameters), Request::METHOD_GET, $queryParameters ); } }
こんなテストを書いて実行してみます。
$ vendor/bin/phpunit tests/UserPostParameterTest.php PHPUnit 8.5.3 by Sebastian Bergmann and contributors. F.. 3 / 3 (100%) Time: 1.18 seconds, Memory: 28.00 MB There was 1 failure: 1) Tests\UserPostParameterTest::ルートパラメータで指定されたUserIdが取得できる Failed asserting that 0 is identical to 1.
なんということでしょう。Getパラメータのテストは大丈夫なようですが、ルートパラメータが見事に取得できていないようです。
※ RequestクラスをMockにすればいいじゃん!という声が聞こえてきますが、MockはあくまでもMockだし、それだとつまらないし、ので続けます
なぜこれだとルートパラメータが取れないのか
うーーむ、どうやら上記の書き方だとルートパラメータは取れないらしい。ブラウザからだと取れるのに、、これは困った。
というわけで、Requestクラスのroute()
で何が行われているか見てみます。
vendor/laravel/framework/src/Illuminate/Http/Request.php
/** * 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); }
うん。なんだかよくわからないけどルートのリゾルバがいて、そいつが解決できたらidの値を返してくれそうです。
試しに、
dd($param, $default); return $route->parameter($param, $default);
としてみると、ブラウザからのアクセス時には
"id" null
と表示されるのに対して、test実行時には何も表示されませんでした。
もう少し調べてみると、
$route = call_user_func($this->getRouteResolver());
の結果が
- ブラウザからの実行時
Illuminate\Routing\Route
- テスト実行時
null
となることがわかりました。
ここで、ブラウザ実行時とテスト実行時での違いが分かります。
getRouteResolverを覗いてみると(同クラスにある)、
public function getRouteResolver() { return $this->routeResolver ?: function () { // }; }
となっていて、ただのゲッターのようです。じゃあどこでセットされているのかですが、おそらく同クラスにある
public function setRouteResolver(Closure $callback) { $this->routeResolver = $callback; return $this; }
にてセットされるのだと思います。ではではどこでこのセッターが呼ばれるかですが、vendor/laravel/framework/src/Illuminate/Foundation/Http/Kernel.php
からdispatchされて辿ってくるようです(すいません。そこまで厳密に追えていません。)。
このセッターですが、テスト実行時に呼ばれていませんでした。つまり、テスト時にはrouteResolver
はセットされていません。
実際のHTTPリクエストのコンテキストのみで、Laravelはルートリゾルバーを設定するようです。
なぜこれだとルートパラメータが取れないのか
の答えですが、RequestクラスのrouteResolver
がnullなのでルートが解決できないからになると思います。
ルートを解決させる
そうなってくると、setRouteResolver
を使ってrouteResolver
をセットしてテストすればいいよねってなります。
ほいでどうすりゃいいんだろ?ってググったらすぐに答えが見つかりました。
php – Simulate a http request and parse route parameters in Laravel testcase – Stack Overflow
Requestクラスを作ったら、setRouteResolver
にIlluminate\Routing\Route
を返却するクロージャを渡せばよさそうです。
これを参考に以下のとおりテストコードを修正して、
<?php declare(strict_types=1); namespace Tests; use App\Http\Parameters\UserPostParameter; use Carbon\CarbonImmutable; use Illuminate\Http\Request; use Illuminate\Routing\Route; // 追加 class UserPostParameterTest extends TestCase { /** * @test */ public function ルートで指定されたUserIdが取得できる() { $request = $this->createRequest( ['id' => 1] ); $target = new UserPostParameter($request); $this->assertSame(1, $target->getUserId()); } /** * @test */ public function startDateが取得できる() { $expected = new CarbonImmutable('2020-05-24'); $request = $this->createRequest( ['id' => 1], ['startDate' => '2020-05-24'] ); $target = new UserPostParameter($request); $this->assertEquals($expected, $target->getStartDate()); $this->assertNull($target->getEndDate()); } /** * @test */ public function endDateが取得できる() { $expected = new CarbonImmutable('2020-05-24'); $request = $this->createRequest( ['id' => 1], ['endDate' => '2020-05-24'] ); $target = new UserPostParameter($request); $this->assertEquals($expected, $target->getEndDate()); $this->assertNull($target->getStartDate()); } /** * @param array $routeParameters * @param array $parameters * @return Request */ private function createRequest( array $routeParameters, array $parameters = [] ): Request { $request = Request::create( route('user.posts.index', $routeParameters), Request::METHOD_GET, $parameters ); // 追加 $request->setRouteResolver(function () use ($request) { return (new Route(Request::METHOD_GET, '/user/{id}/posts', [])) ->bind($request); }); return $request; } }
テストを実行してみます。
vendor/bin/phpunit tests/UserPostParameterTest.php PHPUnit 8.5.3 by Sebastian Bergmann and contributors. ... 3 / 3 (100%) Time: 4.12 seconds, Memory: 28.00 MB OK (3 tests, 5 assertions)
GJ!!
ルートプレフィックスを使っている場合
ルートプレフィックスを使っている場合もついでに書きます。
routes/web.php
<?php Route::prefix('user')->group(function () { Route::get('/{id}/posts', 'UserPostController@index')->name('user.posts.index'); });
というように、プレフィックスを使ってルーティングをまとめている場合ですが、このようにテストコードを修正します。
/** * @param array $routeParameters * @param array $parameters * @return Request */ private function createRequest( array $routeParameters, array $parameters = [] ): Request { $request = Request::create( route('user.posts.index', $routeParameters), Request::METHOD_GET, $parameters ); // 修正 $request->setRouteResolver(function () use ($request) { return (new Route(Request::METHOD_GET, '/{id}/posts', [ 'prefix' => 'user', ]))->bind($request); }); return $request; }
Routeのコンストラクタの第3引数にうまいこと値を渡せばよさそうです。
uriをプレフィックス込みで指定してあげれば、ぶっちゃけ修正はいらないですが。
$request->setRouteResolver(function () use ($request) { return (new Route(Request::METHOD_GET, '/user/{id}/posts', [])) ->bind($request); });
Mockで解決
いや、はい、もちろんMockで解決してもいいんです。
/** * @test */ public function ルートで指定されたUserIdが取得できる() { $requestClass = Request::class; $requestMock = Mockery::mock($requestClass . '[route]') ->shouldReceive('route') ->with('id') ->once() ->andReturn('1') ->getMock(); $target = new UserPostParameter($requestMock); $this->assertSame(1, $target->getUserId()); }
いま書いてみると、ぶっちゃけこっちで全然いいな。。
あと、 HTTPテスト 6.x Laravel での確認でもいいですよね。
(本来であればこっちでやるべきなんでしょうね)
まぁ、いろいろな解決方法があるよということで。
Mockに関して注意
Mockに関して、これはこれで動くと思いますが、ドキュメントを見ると
You should not mock the Request facade. Instead, pass the input you desire into the HTTP helper methods such as get and post when running your test. Likewise, instead of mocking the Config facade, call the Config::set method in your tests. Request ファサードをモックしないでください。代わりに、テストを実行するときに、必要な入力を get や post などの HTTP ヘルパー メソッドに渡します。同様に、Config ファサードをモックする代わりに、テストで Config::set メソッドを呼び出します。
とあるので、やめたほうがいいかもです。
おわりに
というわけで、Requestクラスに依存するテスト(Requestクラスのルートパラメータを取得するテスト)に詰まった際の解決方法を書いてみました。
何かのお役に立てれば幸いです。