🐘

LaravelのFormRequestのUnitテストの書き方

に公開

はじめに

株式会社ハックツ、エンジニアのみき(@take_cantik)です。⛏️
今回は、LaravelのFormRequestのUnitテストの書き方についての記事です。
よく記事などで見るテストケースに違和感を覚え、今回自分なりにやり方を考えたのでここにまとめようと思います。

結論

仕様単位でテストケースを作成しよう!

前提

この記事では、下記の仕様の「ユーザー更新のリクエスト」のバリデーションテストを扱います。

  • 氏名(name): 必須、2文字以上、32文字未満
  • 表示名(displayName): 任意項目、32文字未満

よく見るFormRequestのテスト

LaravelのFormRequest(バリデーション)のテストのやり方について調べると、
以下のように、チェックを行うメソッドを作成し、データプロバイダーでチェックしたいデータを羅列し、テストを行っていることが多いです。
似たようパターンとして、成功と失敗でデータプロバイダーを分け、成功パターンのテストと失敗パターンのテストで行っているのも見ますね。

コード例

UpdateUserRequestTest.php
<?php
/** @noinspection NonAsciiCharacters */

namespace Tests\Unit\Request;

use App\Http\Request\UpdateUserRequest;
use Illuminate\Http\Exceptions\HttpResponseException;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;

class UpdateUserRequestTest extends TestCase
{
    /**
     * @return void
     */
    public function setUp(): void
    {
        parent::setUp();
    }

    /**
     * @param bool $expected
     * @param array $data
     * @return void
     */
    #[Test]
    #[DataProvider('validationProvider')]
    public function testValidation(bool $expected, array $data): void
    {
        $this->assertSame($expected, $this->validate($data));
    }

    /**
     * @param array $data
     * @return bool
     */
    protected function validate(array $data): bool
    {
        $this->app->resolving(UpdateUserRequest::class, function($resolved) use ($data) {
            $resolved->merge($data);
        });

        try {
            app(UpdateUserRequest::class);

            return true;
        } catch (HttpResponseException) {
            return false;
        }
    }

    /**
     * @return array
     */
    public static function validationProvider(): array
    {
        return [
            '【OK】氏名が2文字' => [
                true,
                [
                    'name' => str_repeat('あ', 2),
                    'displayName' => 'テスト表示名',
                ]
            ],
            '【OK】氏名が31文字' => [
                true,
                [
                    'name' => str_repeat('あ', 31),
                    'displayName' => 'テスト表示名',
                ]
            ],
            '【OK】表示名が存在しない' => [
                true,
                [
                    'name' => 'テスト太郎',
                ]
            ],
            '【OK】表示名が31文字' => [
                true,
                [
                    'name' => 'テスト太郎',
                    'displayName' => str_repeat('あ', 31),
                ]
            ],
            '【NG】氏名が存在しない' => [
                false,
                [
                    'displayName' => 'テスト表示名',
                ]
            ],
            '【NG】氏名が1文字' => [
                false,
                [
                    'name' => 'あ',
                    'displayName' => 'テスト表示名',
                ]
            ],
            '【NG】氏名が32文字' => [
                false,
                [
                    'name' => str_repeat('あ', 32),
                    'displayName' => 'テスト表示名',
                ]
            ],
            '【NG】表示名が32文字' => [
                false,
                [
                    'name' => 'テスト太郎',
                    'displayName' => str_repeat('あ', 32),
                ]
            ],
        ];
    }
}

テスト結果は以下のような感じ

【PASS】Tests\Unit\Request\UpdateUserRequestTest
 validation with data set "【ok】氏名が2文字"       0.18s  
 validation with data set "【ok】氏名が31文字"      0.02s  
 validation with data set "【ok】表示名が存在しない" 0.02s  
 validation with data set "【ok】表示名が31文字"    0.02s  
 validation with data set "【ng】氏名が存在しない"   0.03s  
 validation with data set "【ng】氏名が1文字"       0.02s  
 validation with data set "【ng】氏名が32文字"      0.06s  
 validation with data set "【ng】表示名が32文字"    0.03s  

  Tests:    8 passed (8 assertions)
  Duration: 0.46s

このやり方だと、正直分かりにくいなと思います。

例えば、今回で言う「氏名が2文字でOK」はそれ単体のテストを見た時に、そのテストが通ったとして何が担保したいの?となります。
また、1つの仕様が変わった際に修正するテストケースが複数になってしまい、何を修正すべきかはパッと見分かりづらいと感じます。

仕様ベースのテストケース

ということで、上記らの問題を解決するために、仕様ごとにテストケースを作成してみます。
今回は、以下のような仕様です。

  • 氏名(name): 必須、2文字以上、32文字未満
  • 表示名(displayName): 任意項目、32文字未満

ここから、以下のようにテストケースを考えます。

  • 氏名が必須であること
  • 氏名が2文字以上であること
  • 氏名が32文字未満であること
  • 表示名が任意項目であること
  • 表示名が32文字未満であること

これをテストコードに落とし込んでみます。

UpdateUserRequestTest.php
<?php
/** @noinspection NonAsciiCharacters */

namespace Tests\Unit\Request;

use App\Http\Request\UpdateUserRequest;
use Illuminate\Http\Exceptions\HttpResponseException;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;

class UpdateUserRequestTest extends TestCase
{
    /**
     * @return void
     */
    public function setUp(): void
    {
        parent::setUp();
    }

    /**
     * @return void
     */
    #[Test]
    public function 氏名が必須であること(): void
    {
        $dataWithName = [
            'name' => 'テスト太郎',
            'displayName' => 'テスト表示名',
        ];
        $actualWithName = $this->validate($dataWithName);
        $this->assertTrue($actualWithName);

        $dataWithoutName = [
            'displayName' => 'テスト表示名',
        ];
        $actualWithoutName = $this->validate($dataWithoutName);
        $this->assertFalse($actualWithoutName);
    }


    /**
     * @return void
     */
    #[Test]
    public function 氏名が2文字以上であること(): void
    {
        $dataWith2Char = [
            'name' => str_repeat('あ', 2),
            'displayName' => 'テスト表示名',
        ];

        $actualWith2Char = $this->validate($dataWith2Char);
        $this->assertTrue($actualWith2Char);

        $dataWith1Char = [
            'name' => str_repeat('あ', 1),
            'displayName' => 'テスト表示名',
        ];
        $actualWith1Char = $this->validate($dataWith1Char);
        $this->assertFalse($actualWith1Char);
    }

    /**
     * @return void
     */
    #[Test]
    public function 氏名が32文字未満であること(): void
    {
        $dataWith31Char = [
            'name' => str_repeat('あ', 31),
            'displayName' => 'テスト表示名',
        ];

        $actualWith31Char = $this->validate($dataWith31Char);
        $this->assertTrue($actualWith31Char);

        $dataWith32Char = [
            'name' => str_repeat('あ', 32),
            'displayName' => 'テスト表示名',
        ];
        $actualWith32Char = $this->validate($dataWith32Char);
        $this->assertFalse($actualWith32Char);
    }

    /**
     * @return void
     */
    #[Test]
    public function 表示名が任意項目であること(): void
    {
        $dataWithDisplayName = [
            'name' => 'テスト太郎',
            'displayName' => 'テスト表示名',
        ];
        $actualWithDisplayName = $this->validate($dataWithDisplayName);
        $this->assertTrue($actualWithDisplayName);

        $dataWithoutDisplayName = [
            'name' => 'テスト太郎',
        ];
        $actualWithoutDisplayName = $this->validate($dataWithoutDisplayName);
        $this->assertTrue($actualWithoutDisplayName);
    }


    /**
     * @return void
     */
    #[Test]
    public function 表示名が32文字未満であること(): void
    {
        $dataWith31Char = [
            'name' => 'テスト太郎',
            'displayName' => str_repeat('あ', 31),
        ];

        $actualWith31Char = $this->validate($dataWith31Char);
        $this->assertTrue($actualWith31Char);

        $dataWith32Char = [
            'name' => 'テスト太郎',
            'displayName' => str_repeat('あ', 32),
        ];
        $actualWith32Char = $this->validate($dataWith32Char);
        $this->assertFalse($actualWith32Char);
    }

    /**
     * @param array $data
     * @return bool
     */
    protected function validate(array $data): bool
    {
        $this->app->resolving(UpdateUserRequest::class, function($resolved) use ($data) {
            // キャッシュが残るため、mergeではなくreplaceを使用
            $resolved->replace($data);
        });

        try {
            app(UpdateUserRequest::class);

            return true;
        } catch (HttpResponseException) {
            return false;
        }
    }
}

テスト結果は以下の通り

【PASS】  Tests\Unit\Request\UpdateUserRequestTest
 氏名が必須であること        0.19s  
 氏名が2文字以上であること    0.02s  
 氏名が32文字未満であること   0.03s  
 表示名が任意項目であること   0.02s  
 表示名が32文字未満であること 0.02s  

  Tests:    5 passed (10 assertions)
  Duration: 0.36s

こちらの方が圧倒的に分かりやすいですね。
各テストが何を担保しているか明確ですし、何か1つ仕様が変わった際にも変えるべき箇所が該当するいずれか1つのテストケースとなるので、変更もやりやすいです。

このように、仕様単位でテストケースを作成することによって、多くの恩恵を受けることができます。

さいごに

今回は、LaravelのFormRequestのUnitテストの書き方について書きました。
この考え方については、FormRequestだけではなくて、どのテストでも有用だと思っているので、ぜひ参考にしてみてください。

では、また別の記事で👾

Hackz Inc.

Discussion