🐘

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

2024/12/10に公開

はじめに

株式会社ハックツ、エンジニアのみき(@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