【Laravel】Requestクラスに依存するクラスのテスト

PHPUnit,PHP

はじめに

ここ最近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パラメータで投稿日時を絞れる
    • startDateendDateを受け取る
  • かんたんの為、バリデーションは省きます

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クラスを作ったら、setRouteResolverIlluminate\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 での確認でもいいですよね。

まぁ、いろいろな解決方法があるよということで。

おわりに

というわけで、Requestクラスに依存するテスト(Requestクラスのルートパラメータを取得するテスト)に詰まった際の解決方法を書いてみました。
何かのお役に立てれば幸いです。

スポンサーリンク