menu-icon

LaravelのassertJSONの代替手段について考える

前回の記事でLaravelのassertJsonというアサーションの挙動ついて紹介しました。個人的に好ましい挙動ではないので、今回はassertJsonを使わずにJSONレスポンスの検証を行う方法について考えます。

なお調査と検証に使用した環境は以下です。

  • PHP 7.4
  • Laravel 8.x

結論

最初に結論を述べてしまうと

  1. assertExactJsonを使う
  2. assertExactJsonが使えない場合、独自アサーションを使う

ということになります。

1. assertExactJsonを使う

LaravelにはJSONが完全に一致しているかを検証するassertExactJsonというアサーションも存在します。こちらを使えば、JSONとして完全に一致していることが保証されるので確実です。

2. assertExactJsonが使えない場合、独自アサーションを使う

ということで、基本的にassertExactJsonを使えば問題ないです。しかし、処理が実行時刻や乱数に依存している場合はassertExactJsonは使えない場合があります。

assertExactJsonが使えない例

routes/api.php
<?php

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

Route::get('/users', function (Request $request) {
    return response()->json([
        'status' => 'OK',
        'users' => [
            [
                'id' => 1,
                'created_at' => '2020-12-01 00:00:01',
            ],
            [
                'id' => 2, 
                'created_at' => '2020-12-02 00:00:01',
            ],
            [
                'id' => 3,
                'created_at' => date('Y-m-d H:i:s'),
            ],
        ],
    ]);
});

上記のusers APIでは users.3.created_atが実行時刻依存になっていて固定が難しいです(この例は露骨ですが、残念ながらこれに近いものは世の中に存在します……)。こうなるとassertExactJsonは使えません。

このAPIの検証を既存のアサーションで実現しようとすると、

  • assertJsonusers.3.created_at以外を検証
  • users.3.created_atだけ個別に確認(存在 or 型など)

といった形になるのではないでしょうか。それは煩雑ですし、やがてリファクタリングをしてレスポンスの固定が可能となり、assertExactJsonへ移行した際に、assertJson起因で別の問題が生じる可能性があるのでやっかいです。

独自アサーション

以上で見たように、assertExactJsonが使えない場合、Laravel側で用意されているアサーションでは検証が不便と感じたので、独自のアサーションを実装してみました。特徴は以下の通りです。

  • 明示した部分では、型のみの検証にするなど、柔軟に対応できるようにした
  • 利便性やassertExactJsonへ自然な移行を考えて、引数をarray $dataのみとした

実装

Illuminate\Testing\TestResponseを拡張する形で、レスポンス検証用の独自アサーションを実装します。

tests/TestResponse.php
<?php

namespace Tests;

use Illuminate\Support\Arr;
use Illuminate\Testing\TestResponse as IlluminateTestResponse;
use PHPUnit\Framework\Constraint\Constraint;
use PHPUnit\Framework\Assert as PHPUnit;

class TestResponse extends IlluminateTestResponse
{
    /**
     * Assert that the response has the given JSON considering PHPUnit constraints.
     *
     * @param  array  $data
     * @return $this
     */
    public function assertExactJsonPartially(array $data)
    {
        foreach (Arr::dot($data) as $key => $expected) {
            if (!$expected instanceof Constraint) {
                continue;
            }

            if (!Arr::has($this, $key)) {
                PHPUnit::fail("This response does not have the key '{$key}'");
            }
            
            $actual = Arr::get($this, $key);
            PHPUnit::assertThat($actual, $expected, "Failed asserting for '{$key}'");

            // Replace to actual value for assertExactJson
            Arr::set($data, $key, $actual);
        }

        return $this->assertExactJson($data);
    }
}

assertExactJsonをベースに、PHPUnitのConstraintを指定した部分では、指定したConstraintに応じた検証を行い、値が一致するかの検証は(事実上)行わないようにしました。例えば、PHPUnit\Framework\Constraint\IsTypeを渡せば、型チェックのみとなりますし、PHPUnit\Framework\Constraint\IsAnythingを渡せば、存在さえしていれば何でも良いことになります。

TestResponseの拡張ができたら、テストクラス側でcreateTestResponseをオーバーライドして、拡張したTestResponseクラスを生成するようにします。

tests/TestCase.php
<?php

namespace Tests;

use Illuminate\Foundation\Testing\TestCase as BaseTestCase;

abstract class TestCase extends BaseTestCase
{
    use CreatesApplication;
    
    /**
     * Create the test response instance from the given response.
     *
     * @param  \Illuminate\Http\Response  $response
     * @return TestResponse
     */
    protected function createTestResponse($response)
    {
        return TestResponse::fromBaseResponse($response);
    }
}

試してみる

先のusers APIに対してのテストとして、作成したアサーションを使ってみます。

tests/Feature/ApiTest.php
<?php

namespace Tests\Feature;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class ApiTest extends TestCase
{         
    /**
     * @test
     * 
     * @return void
     */
    public function api_users(): void
    {
        $expected = [
            'status' => 'OK',
            'users' => [
                [
                    'id' => 1,
                    'created_at' => '2020-12-01 00:00:01',
                ],
                [
                    'id' => 2, 
                    'created_at' => '2020-12-02 00:00:01',
                ],
                [
                    'id' => 3,
                    'created_at' => $this->isType('string'),
                ],
            ],
        ];

        $this->getJson('/api/users')
            ->assertExactJsonPartially($expected);
    }
}

IsTypeを用いて id = 3のuserのcreated_atは文字列であれば何でもOKにしてみました。

実行してみると、無事にテストが通ります。

$ ./vendor/bin/phpunit tests/Feature/ApiTest.php 
PHPUnit 9.4.2 by Sebastian Bergmann and contributors.

.                                                                   1 / 1 (100%)

Time: 00:00.068, Memory: 18.00 MB

OK (1 test, 2 assertions)

まとめ

assertJsonを使わずにJSONレスポンスの検証を行う方法を考えました。アサーションを独自に実装することで、assertExactJsonが使えない状況でも、ある程度詳細な検証が可能になり、安心してリファクタリングに取り組めるようになったかなと思います。