カテゴリー
Linux

Laravel のバリデーションクラスであるフォームリクエストをユニットテストするためのノウハウ

フォームリクエストバリデーション – バリデーション 7.x Laravel にあるように、バリデーションロジックを含んだカスタムリクエストクラスを作成して利用することはよくあります。

しかし、フォームリクエストをどうやってユニットテストすれば良いのかについては、 Laravel ドキュメントに記載がありません。

自分自身がさまざま調べ、現在行っているユニットテストの方法を記したいと思います。

テスト対象のフォームリクエストクラス作成とその周辺

フォームリクエストクラスとそのファイルを 公式ドキュメント に従って作成します。

<コントローラー名><アクション名>Request という命名規約で作成しています。

  • <コントローラー名>: 正確には、 "Controller" を除いたコントローラー名としている。例えば、 UserController ならば、 User を使用している。
  • <アクション名>: Store または Update としている。ただし、両方で共通の場合は Save としている。ちなみに、 Save を使うことが多いように感じる。
php artisan make:request UserSaveRequest

rules メソッドを編集し、次のようなバリデーションルールを作りました。

use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;

    public function rules(): array
    {
        return [
            'name' => 'required|max:255',
            'email' => [
                'required',
                'max:255',
                'email:rfc,spoof',
                Rule::unique('users')->ignore(Auth::id()),
            ],
            'zip' => 'nullable|regex:/^\d{7}$/',
        ];
    }

バリデーション全体に関してとなりますけれども、エラーメッセージを日本語化すべく、公式が提供している言語ファイルを追加します。ちなみに、つい最近まで存在を知りませんでしたし、検索してみると他の方法で日本語化を試行錯誤しているページもありましたので、あまりこの公式ページは発見されていないのだと思います。

このページにある通り、コマンドを実行するだけです。プロジェクトのルートディレクトリで実行しますけれども、それは普段 php artisan を実行するディレクトリのことです。

実行すると、バリデーション以外の言語ファイルも生成されます。それらは削除してもよいですけれども、私はそれらも含めるようにしています。

テスト作成

php artisan make:test UserSaveRequestTest

以下、各テストをテストを作った順に説明していきます。

なお、テストコードの構成は、次のページが大いに参考になりました♪

テスト 1. 必須エラーとなること

最初に書いたテストは、正常系ではなく、必須エラーです。全てのフィールドに null を設定してリクエストすれば良いので楽です。

最初に挙げるポイントは、 Laravel ではなく PHPUnit に関することです。メソッドの PHPDoc に @test アノテーションをつけることでメソッド名先頭に test をつける必要がなくなり自由な名前とすることができます。

フォームリクエストクラスを作成すると、コントローラークラスの引数に設定するだけで、コントローラーのアクションメソッドにはバリデーションロジックを何も書く必要がなく、とても便利です。

ですがテストはコントローラーで書かなくて済んでいたバリデーションロジックをあえて書くことでテストを作ります。今までコントローラーでバリデーションロジックを書いた経験がなかったので、最初はどうやってテストを書いて良いか全くわかりませんでした。テストメソッド内の具体的な内容は次のページがとても参考になりました。特に laravel/framework のページは、公式ですのでとても安心感があります。

テストメソッドの中を具体的に見ていきます。

$data$request->all() の返り値を想定しています。 フォームリクエストクラスをインスタンス化し、 rules メソッドを実行して、先ほど作成したバリデーションルールを得ます。ちなみに、テストメソッドの中でのフォームリクエストクラスの役割はこの部分のみです。 Validator ファサードの make メソッドを使用し、バリデータインスタンスを生成します。make メソッドの第1引数は、バリデーションを行うデータです。第2引数はそのデータに適用するバリデーションルールです。

ここまでのことは、 バリデータの生成 – バリデーション 7.x Laravel に書いてある通りです。

次にバリデーションが成功ならば true 失敗ならば false を返す $validator->passes() を実行しています。

このテストでは、必須エラーとなることを確認する、つまりバリデーションが失敗するはずですので、 $validator->passes() の結果は false となるはずです。したがって $this->assertFalse($result); でテストをしています。

さらに、失敗の中身も期待通りかを次で判定していきます。 今回の例では、 name および email では必須エラーとなって欲しい一方で、 zip ではエラーは発生してほしくありません。 バリデーションルール全体の中でどのルールが失敗となったのかを $validator->failed() で得ることができます。ちなみに、 $validator->fails() とは異なるメソッドです。

$validator->failed() の返り値には少しクセがあります。フォームリクエストクラスの rules メソッドでは小文字で書いたバリデーションルールの 1 文字目が、ここでは大文字です。 正直なところ、ルール名やそれに対応する配列の中身がわからないため、メソッドの適当に記述し、さらに logger($validator->failed()); を仕込んでてから一度テストを実行しました。そうすると大抵は失敗しますので、ログの中身も確認しながらテストを書き上げていきました。

また、どのルールが失敗したのかをテストする一方で、エラーメッセージが一致するかどうかはテストしませんでした。これは 参考にしたページ がそのようにしていましたし、さらに考えてみるとエラーメッセージは多言語化の対応で如何様にも変わってしまいテストの判定に使い辛いためであると思います。

use App\Http\Requests\UserSaveRequest;
use Illuminate\Support\Facades\Validator;

    /**
     * @test
     * @return void
     */
    public function 必須エラーとなること(): void
    {
        $data = [
            'name' => null,
            'email' => null,
            'zip' => null,
        ];
        $request = new UserSaveRequest();
        $rules = $request->rules();

        $validator = Validator::make($data, $rules);

        $result = $validator->passes();
        $this->assertFalse($result);
        $expectedFailed = [
            'name' => ['Required' => [],],
            'email' => ['Required' => [],],
        ];
        $this->assertEquals($expectedFailed, $validator->failed());
    }

テスト 2. 桁数エラーとなること

テストメソッド内の構成は "テスト 1. 必須エラーとなること" と同じです。したがってコピペした後、必要な箇所のみ修正して書き上げました。

ポイントとしては、 $data のデータは許容する桁数よりも 1 だけ多い桁数にして、境界をテストするようにした点です。

    /**
     * @test
     * @return void
     */
    public function 桁数エラーとなること(): void
    {
        $data = [
            'name' => str_repeat('a', 256),
            'email' => str_repeat('a', 244) . '@example.com',
            'zip' => str_repeat('a', 8),
        ];
        $request = new UserSaveRequest();
        $rules = $request->rules();

        $validator = Validator::make($data, $rules);

        $result = $validator->passes();
        $this->assertFalse($result);
        $expectedFailed = [
            'name' => ['Max' => [255],],
            'email' => ['Max' => [255],],
            'zip' => ['Regex' => ['/^\d{7}$/'],],
        ];
        $this->assertEquals($expectedFailed, $validator->failed());
    }

テスト 3. フォーマットエラーとなること

こちらのテストも、構造はコピペで作成しました。

    /**
     * @test
     * @return void
     */
    public function フォーマットエラーとなること(): void
    {
        $data = [
            'name' => '名前',
            'email' => 'aaa',
            'zip' => 'aaa',
        ];
        $request = new UserSaveRequest();
        $rules = $request->rules();

        $validator = Validator::make($data, $rules);

        $result = $validator->passes();
        $this->assertFalse($result);
        $expectedFailed = [
            'email' => ['Email' => ['rfc', 'spoof',],],
            'zip' => ['Regex' => ['/^\d{7}$/'],],
        ];
        $this->assertEquals($expectedFailed, $validator->failed());
    }

ところが、テストメソッドの外の問題で、テストは失敗してしまいました。 PHP の intl 拡張が足りなかったためです。

発生したエラーは次の通りです。

root@380b2a8d7ce7:/var/www/html# php artisan test --env=laravel

... 略 ...

   FAIL  Tests\Feature\UserSaveRequestTest
  ✕ 桁数エラーとなること

  Tests:  1 failed, 2 passed, 1 pending

   LogicException

  The Egulias\EmailValidator\Validation\SpoofCheckValidation class requires the Intl extension.

  at vendor/egulias/email-validator/EmailValidator/Validation/SpoofCheckValidation.php:20
    16|
    17|     public function __construct()
    18|     {
    19|         if (!extension_loaded('intl')) {
  > 20|             throw new \LogicException(sprintf('The %s class requires the Intl extension.', __CLASS__));
    21|         }
    22|     }
    23|
    24|     /**

      +1 vendor frames
  2   [internal]:0
      Illuminate\Validation\Validator::Illuminate\Validation\Concerns\{closure}("spoof")

      +4 vendor frames
  7   tests/Feature/UserSaveRequestTest.php:31
      Illuminate\Validation\Validator::passes()
root@380b2a8d7ce7:/var/www/html#

dnsとspoofバリデータを使用するには、PHPのintl拡張が必要です。

と公式ドキュメントにありますので、 PHP の intl 拡張をインストールしました。 Dockerfile の記述を修正いたしましたけれども、ポイントのみ取りあげます。

RUN apt-get update && apt-get install -y \
  libicu-dev \
  && docker-php-ext-install \
  intl \
  && apt-get clean \
  && rm -rf /var/lib/apt/lists/*

以上でこのテストは無事実行されました。

テスト 4. 正常系

メールのユニークエラーのテストが残っていますけれどもデータベースを用意しなくて済むテストを先に終わらせようと思い、正常系を書きました。

これはユーザー登録時を想定したテストになります。自分自身のユーザーを登録する前のタイミングでは、自分自身のユーザー情報は当然登録されておりません。 ですので email はデータベースに存在しない物をテストデータとして使用すればよく、そもそもデータベースのテーブルのレコードが 0 件の状態でテストすることによって任意の email でテストできるようになっています。

バリデーション成功のテストですので、確認すべきは $result = $validator->passes(); の値が true であることのみ、つまり $this->assertTrue($result); となります。

    /**
     * @test
     * @return void
     */
    public function 正常系(): void
    {
        $data = [
            'name' => '名前',
            'email' => 'email@example.com',
            'zip' => '0123456',
        ];
        $request = new UserSaveRequest();
        $rules = $request->rules();

        $validator = Validator::make($data, $rules);

        $result = $validator->passes();
        $this->assertTrue($result);
    }

テスト 5. メールのユニークエラーとなること

このテストではデータベースを用意する必要があります。そこで use RefreshDatabase; を行っています。

Laravel 7 のデフォルトでは phpunit.xml を見ると、ユニットテストのデータベースは、インメモリの SQLite です。ですので、マイグレーションでは SQLite でエラーの発生しない書き方をする必要がある点に注意です。もしくは、別のデータベース環境をユニットテストで使うよう、環境を整える必要があります。

と言いつつ、今の最新コミットである次のページを見てみると、該当部分がコメント化されています。何かあったのでしょうか。ともかく、私はインメモリの SQLite を使用してテストをいたしました。

データベースの準備はできておりますので、テストコードを書いていきます。

ファクトリを使った $user = factory(User::class)->create(); というコードを書くことでデータベースのレコードとモデルを生成し、モデルの email をフォームリクエストの引数に設定します。

これでデータベースに存在する email をリクエストしたことになりますので、バリデーションのユニークルールに引っ掛かり、失敗となります。

ちなみに、ファクトリのコードは、 Laravel 7 インストール時に存在する database/factories/UserFactory.php を変更することなくそのまま使用しました。

use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;

    use RefreshDatabase;

    /**
     * @test
     * @return void
     */
    public function メールのユニークエラーとなること(): void
    {
        $user = factory(User::class)->create();

        $data = [
            'name' => '名前',
            'email' => $user->email,
            'zip' => '0123456',
        ];
        $request = new UserSaveRequest();
        $rules = $request->rules();

        $validator = Validator::make($data, $rules);

        $result = $validator->passes();
        $this->assertFalse($result);
        $expectedFailed = [
            'email' => ['Unique' => ['users', 'NULL', 'NULL', 'id',],],
        ];
        $this->assertEquals($expectedFailed, $validator->failed());
    }

テスト 6. メール未変更でも正常終了すること

最後のテストです。テストコードとしては分量は今までとほとんど変わりませんでしたけれども、これが一番複雑な状況です。

ちょうどバリデーションルールの unique のところに全く同じ状況が書かれていますので引用いたします。

メールアドレスは一意であることを確認したいと思います。しかし、もしユーザーが名前フィールドだけ変更し、メールフィールドを変更しなければ、そのユーザーがすでにそのメールアドレスの所有者として登録されているために起きるバリデーションエラーを避けたいと思うでしょう。

このルールを実現する、そしてテストコードを書く、ということを成し遂げるのに難しかったのは次の 2 点でした。

  • バリデーションルールを実装する際、どうやってログイン中のユーザーをフォームリクエストクラスの rule メソッド内で取得すればよいのかわからなかった。
  • テストコードを書く際、実際にリクエスストを行わないのにどうやってログイン中の状態を作りあげればよいのかわからなかった。

1 つ目の点に関しては、 unique:テーブル,カラム,除外ID,IDカラム – バリデーション 7.x Laravel を読んでもわからないため、別途調べる必要がありました。答えは 認証済みユーザーの取得 – 認証 7.x Laravel にあり、 Auth::id() でログイン中のユーザー id を取得することができました。

2 つ目の点に関しては、もし HTTP テストならば $response = $this->actingAs($user)->get('/'); といった形でログイン状態を作り出すことができます。このことは セッション/認証 – HTTPテスト 7.x Laravel に記述されています。今回のテストは、バリデーションに関する内容で、 HTTP リクエストは行う必要がありません。どうすればよいでしょうか? 同じようにすればよいのでしょうか?

結論をいうと、同じようにすれば大丈夫でした。具体的には $this->actingAs($user); と書くことで、そのテストメソッド内の以降のコードでは認証済状態となるようでした。

あとは、ファクトリで生成したレコードの email をリクエストに指定し、バリデーション成功となることを確認するだけです。

    /**
     * @test
     * @return void
     */
    public function メール未変更でも正常終了すること(): void
    {
        $user = factory(User::class)->create();
        $this->actingAs($user);

        $data = [
            'name' => '名前',
            'email' => $user->email,
            'zip' => '0123456',
        ];
        $request = new UserSaveRequest();
        $rules = $request->rules();

        $validator = Validator::make($data, $rules);

        $result = $validator->passes();
        $this->assertTrue($result);
    }

おわりに

Laravel のフォームリクエストクラスの rules メソッドをユニットテストする方法を検索してみますと、あまり見つかりませんでした。ですので自分なりに身につけてきたことを整理してみようと思い、投稿しました。

以上です。

コメントを残す