カテゴリー
Linux

Laravel 6 、 PHPUnit をデータプロバイダを利用したリファクタリングによりコードの重複を減らす方法

はじめに

Laravel のバリデーションクラスであるフォームリクエストをユニットテストするためのノウハウ – oki2a24 のリファクタリングを行いました。

PHPUnit のデータプロバイダを利用して、重複をできるだけ取り除いた内容となります。

本投稿での Laravel 等のバージョンです。

$ php artisan --version
Laravel Framework 6.20.30
$ 
$ composer show | grep phpunit/phpunit
phpunit/phpunit                           9.5.7     The PHP Unit Testing framework.
$ 

まとめ

  • データプロバイダ – 2. PHPUnit 用のテストの書き方 — PHPUnit latest Manual
  • データプロバイダのデートとしてファクトリを使用して DB データを生成する場合は、データプロバイダを利用するのは難しい。データプロバイダメソッド内のファクトリメソッドで DB レコードとそのモデルデータを用意しても、それを利用するテストメソッドの前に DB レコードは削除されてしまうため。具体的には、テストはデータプロバイダメソッドの実行後に、 refreshDatabase() を実行し、次にデータプロバイダを利用するテストメソッドを実行するため。
  • データプロバイダを利用するテストメソッドを、別のテストメソッドから呼び出すことは可能だった。テスト失敗した場合も、期待通り別のテストメソッドで失敗したと出力されていた。

リファクタリング対象のコード

のコードをそのまま作成し、今回のリファクタリング対象といたしました。もちろん作業開始前にユニットテストがすべて合格することを確認済みです。

リファクタリング前 (データプロバイダ) を使う前のユニットテストコード全体

  • エラーとなるのを確認するテストと正常系の 2 種類のテストがある。
  • エラー系には、事前に DB レコードを生成するテストも 1 つある。
  • 大雑把に言うとエラー系テストはバリデーション対象となる入力データと、どの項目でどんなエラーが発生したかの検証データの部分のみが異なる。
<?php

namespace Tests\Feature;

use App\Http\Requests\UserSaveRequest;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Validator;
use Tests\TestCase;

class UserSaveRequestTest extends TestCase
{
    use RefreshDatabase;

    /**
     * @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());
    }

    /**
     * @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());
    }

    /**
     * @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());
    }

    /**
     * @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);
    }

    /**
     * @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());
    }

    /**
     * @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);
    }
}

リファクタリング後 (データプロバイダ) を使った後のユニットテストコード全体

  • メールのユニークエラーとなること はデータプロバイダをうまく使えなかった。詳細は本投稿の、まとめ、解説。困ったこととデータプロバイダの動きとワークアラウンド、を参照のこと。
  • メールのユニークエラーとなること はデータプロバイダを利用するメソッドを直接呼び出すことでコードの再利用を行った。
  • 正常系はパターンが 1 つのみだったため、データプロバイダを利用しなかった。
<?php

namespace Tests\Feature;

use App\Http\Requests\UserSaveRequest;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Validator;
use Tests\TestCase;

class UserSaveRequestTest extends TestCase
{
    use RefreshDatabase;

    /**
     * @test
     * @dataProvider dataProvider
     * @return void
     */
    public function エラーとなること(array $data, array $failedValidationRules): void
    {
        // 準備
        $request = new UserSaveRequest();
        $rules = $request->rules();
        $validator = Validator::make($data, $rules);

        // 実行
        $result = $validator->passes();

        // 検証
        $this->assertFalse($result);
        $this->assertEquals($failedValidationRules, $validator->failed());
    }

    /**
     * データプロバイダとしてテストに与えるデータを返します。
     */
    public function dataProvider()
    {
        return [
            '必須エラーとなること' => [
                [
                    'name' => null,
                    'email' => null,
                    'zip' => null,
                ],
                [
                    'name' => ['Required' => [],],
                    'email' => ['Required' => [],],
                ],
            ],
            '桁数エラーとなること' => [
                [
                    'name' => str_repeat('a', 256),
                    'email' => str_repeat('a', 244) . '@example.com',
                    'zip' => str_repeat('a', 8),
                ],
                [
                    'name' => ['Max' => [255],],
                    'email' => ['Max' => [255],],
                    'zip' => ['Regex' => ['/^\d{7}$/'],],
                ],
            ],
            'フォーマットエラーとなること' => [
                [
                    'name' => '名前',
                    'email' => 'aaa',
                    'zip' => 'aaa',
                ],
                [
                    'email' => ['Email' => ['rfc', 'spoof',],],
                    'zip' => ['Regex' => ['/^\d{7}$/'],],
                ],
            ],
        ];
    }

    /**
     * @test
     * @return void
     */
    public function メールのユニークエラーとなること(): void
    {
        $user = factory(User::class)->create();
        $data = [
            'name' => '名前',
            'email' => $user->email,
            'zip' => '0123456',
        ];
        $failedValidationRules = [
            'email' => ['Unique' => ['users', 'NULL', 'NULL', 'id',],],
        ];

        $this->エラーとなること($data, $failedValidationRules);
    }

    /**
     * @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);
    }

    /**
     * @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);
    }
}

解説。困ったこととデータプロバイダの動きとワークアラウンド

調べてみたところ、テストクラス内で、データプロバイダメソッドは 1 回のみ実行されました。

つまりこうです。

  1. データプロバイダメソッドを実行
  2. データプロバイダのテストメソッドを実行。 (データプロバイダメソッドは実行しない)
  3. データプロバイダのテストメソッドを実行。 (データプロバイダメソッドは実行しない)
  4. データプロバイダのテストメソッドを実行。 (データプロバイダメソッドは実行しない)
  5. 以下略。

これで充分な場合も多いと思います。 しかしデータプロバイダのデータが素直でない場合、今回はデータベースのデータを使いたい場合、うまくいきませんでした。

  1. データプロバイダメソッドを実行。ここで DB データを作るために parent::setUp() を実行して refreshDatabase() を実行させた。
  2. refreshDatabase() を実行。次にデータプロバイダのテストメソッドを実行。 (データプロバイダメソッドは実行しない)
  3. refreshDatabase() を実行。次にデータプロバイダのテストメソッドを実行。 (データプロバイダメソッドは実行しない)
  4. refreshDatabase() を実行。次にデータプロバイダのテストメソッドを実行。 (データプロバイダメソッドは実行しない)
  5. 以下略。

このような順番となったためです。データベースプロバイダで DB データを作ったとしても、テストメソッドの実行前に refreshDatabase() により削除されてしまいました。

テストメソッドから、データプロバイダのテストメソッドを呼ぶようにしてはどうでしょうか?これは、うまくいきました。わざとテスト失敗するようにしてみたところ、失敗したのはテストメソッド (データプロバイダのテストメソッドの呼び出し元) と出力されましたので使えそうです。

おわりに

今まで別の調べ物をしていた時に、 PHPUnit のデータプロバイダを使った解説を何度かみてきました。便利そうだと思いつつ取り組んだことはありませんでしたが、今回やっと試してみることにしました。

意外とハマってしまい、使いやすいパターンと使いにくいパターンを明らかにすることができました。

ずっと同じ構造のテストの時は強力な手段と思いますので、時と場合を選んで使っていきたいと思います。

以上です。

コメントを残す