Laravelのミドルウェアをテストする方法を考えてみた

テストするときにミドルウェアを無効化する記事はあっても、ミドルウェアそのものをテストする記事があまり無かったので、書いてみます。

# 前提
- Laravel 6.18.10
- PHP 7.3.15

# ミドルウェア

ここでは、リクエストに含まれるバージョンをチェックするミドルウェアを作ってみます。[^1] API という前提で書きますが、基本的な考え方は Web でも同じだと思います。

[^1]: ミドルウェアには、コントローラの **前** に動くタイプと、 **後** に動くタイプの2種類がありますが、ここでは **前** に動くタイプを作ります。

リクエストに含まれている `version` が `MIN_VERSION` [^2] 以上である場合は、許可します。

一方、そもそも `version` が送信されなかったり、 `version` が `MIN_VERSION` よりも小さい場合は、エラーの JSON レスポンスを返します。[^3]

[^2]: 本来、 `MIN_VERSION` は DB 等から取得するべきですが、ここでは簡略化します。
[^3]: ここではミドルウェアで JSON レスポンスを返していますが、これが良いのかどうか、あまり自信がありません。特に、Web の場合はエラー画面を表示することになりますが、ミドルウェアで直接ビューを表示せずに、一回リダイレクトしてからコントローラで表示すべきという Stack Overflow の [回答](https://stackoverflow.com/a/50408853/13116462) もありました。


```php
namespace App\Http\Middleware;

use Closure;

class VersionCheck
{
    /**
     * 利用可能な最低バージョン
     */
    private const MIN_VERSION = '1.0.1';

    /**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure  $next
     * @return mixed
     */
    public function handle($request, Closure $next)
    {
        $version = $request->get('version');

        if ($version === null || $version < self::MIN_VERSION) {
            return response()->json([
               'message' => 'このバージョンは利用できません。'
            ], 400);
        }

        return $next($request);
    }
```

# テスト
ミドルウェアが出来たので、テストコードを作っていきます。

## 正常系
まずは、リクエストを作ります。

```php
$request = app()->make('request');
$request->merge(['version' => '1.0.1']);
```

そして、ミドルウェアを実行します。

```php
$middleware = new VersionCheck();
$middleware->handle($request, function () {
    $this->assertTrue(true);
});
```

本来、 `handle()` の第2引数は、バージョンチェックの次に動くミドルウェアを指定します。

このことは、ミドルウェアの雛形を見ると、よく分かるかと思います。

```php
public function handle($request, Closure $next)
{
    // ...

    // 処理が全部終わったら $next で渡された関数を実行する。
    return $next($request);
}
```

そのため、 `handle()` の第2引数の中で `assertTrue(true)` と書くことで、 **バージョンチェックが全て終了して次のミドルウェアへ進むこと** をテストすることができます。

しかし、これではまだ不十分です。 **エラーレスポンスが返ってこないこと** をテストできていないからです。

バージョンチェックが正常な場合、本来ならば、ミドルウェアの後に動くコントローラがレスポンスを返します。

**しかし、ここではミドルウェアしか動かしていないため、レスポンスは返ってきません。** つまり、 `handle()` の戻り値が `null` であることをチェックすれば十分ということになります。

ということで、正常系のテストコードは以下のようになります。

```php
/**
 * @test
 */
public function 利用可能なバージョン（最低バージョンと等しい）である場合は正常終了すること()
{
    $request = app()->make('request');
    $request->merge(['version' => '1.0.1']);

    $middleware = new VersionCheck();
    $response = $middleware->handle($request, function () {
        $this->assertTrue(true);
    });

    // エラーレスポンスが返却されないこと
    $this->assertNull($response);
}
```

## 異常系
正常系と違って、異常系ではエラーレスポンスのテストが必要になります。

ここで問題となるのは、 `$response->assertStatus()` のようなメソッドが **使えない** ということです。

通常、コントローラのテストコードで `get()` や `post()` を記載すると、自動的に `TestResponse` クラスのインスタンスが返却されます。 `assertStatus()` は  `TestResponse` クラスのメソッドなので、特に意識することなく使うことができます。

ところが、ミドルウェアの `handle()` は `TestResponse` ではなく `Response` クラスのインスタンスを返却します。そのため、 `assertStatus()` を使うことはできません。

もちろん、以下のように書くことはできます。

```php
$this->assertSame(400, $response->getStatusCode());
$this->assertSame([
    'message' => 'このバージョンは利用できません。'
], json_decode($response->getContent(), true));
```

しかし、これは、かなり冗長に感じられます。

そこで、`get()` や `post()` がどのように動いていくかを見ていきました。

その結果、トレイト  `Illuminate\Foundation\Testing\Concerns\MakesHttpRequests` の  `createTestResponse()` を呼び出して、 `Response` を `TestResponse` に格納し直していることが分かりました。

そして、テストクラスの親クラス `Illuminate\Foundation\Testing\TestCase` は `MakesHttpRequests` を `use` しているので、テストクラスからも `createTestResponse()` を呼び出せることが分かりました。

こうして、テストコードはこのように書き直すことができました。

```php
$middleware = new VersionCheck();
$originalResponse = $middleware->handle($request, function () {
     $this->assertTrue(true);
});

$response = $this->createTestResponse($originalResponse);

$this->assertNotNull($response);
$response->assertStatus(400);
$response->assertExactJson([
    'message' => 'このバージョンは利用できません。'
]);
```

## 完成形

```php
namespace Tests\Feature\Middleware;

use Tests\TestCase;
use App\Http\Middleware\VersionCheck;

class VersionCheckTest extends TestCase
{
    /**
     * @test
     */
    public function 利用可能なバージョン（最低バージョンと等しい）である場合は正常終了すること()
    {
        $request = app()->make('request');
        $request->merge(['version' => '1.0.1']);

        $middleware = new VersionCheck();
        $response = $middleware->handle($request, function () {
            $this->assertTrue(true);
        });

        // エラーレスポンスが返却されないこと
        $this->assertNull($response);
    }

    /**
     * @test
     */
    public function 利用可能なバージョン（最低バージョンより大きい）である場合は正常終了すること()
    {
        $request = app()->make('request');
        $request->merge(['version' => '1.0.2']);

        $middleware = new VersionCheck();
        $response = $middleware->handle($request, function () {
            $this->assertTrue(true);
        });

        // エラーレスポンスが返却されないこと
        $this->assertNull($response);
    }

    /**
     * @test
     */
    public function バージョンが送信されない場合はエラーレスポンスが返却されること()
    {
        $request = app()->make('request');

        $middleware = new VersionCheck();
        $originalResponse = $middleware->handle($request, function () {
            $this->assertTrue(true);
        });

        $response = $this->createTestResponse($originalResponse);

        // エラーレスポンスが返却されること
        $this->assertNotNull($response);

        // ステータスコードが400であること
        $response->assertStatus(400);

        // JSONレスポンスが期待どおりであること
        $response->assertExactJson([
            'message' => 'このバージョンは利用できません。'
        ]);
    }

    /**
     * @test
     */
    public function 利用不可能なバージョンである場合はエラーレスポンスが返却されること()
    {
        $request = app()->make('request');
        $request->merge(['version' => '1.0.0']);

        $middleware = new VersionCheck();
        $originalResponse = $middleware->handle($request, function () {
            $this->assertTrue(true);
        });

        $response = $this->createTestResponse($originalResponse);

        // エラーレスポンスが返却されること
        $this->assertNotNull($response);

        // ステータスコードが400であること
        $response->assertStatus(400);

        // JSONレスポンスが期待どおりであること
        $response->assertExactJson([
            'message' => 'このバージョンは利用できません。'
        ]);
    }
```

# 感想
ミドルウェアは便利な反面、どのように書くべきかという「ベストプラクティス」を、あまり見つけることができませんでした。

「もっと良い方法があるよ！」という方は、こっそり教えていただければと思います。

# 参考
- Laravel 公式ドキュメント
  - [ミドルウェア](https://readouble.com/laravel/6.x/ja/middleware.html)
  - [HTTPレスポンス](https://readouble.com/laravel/6.x/ja/responses.html)
- [Testing Middleware in Laravel with PHPUnit](https://semaphoreci.com/community/tutorials/testing-middleware-in-laravel-with-phpunit)