🦀

laravel8¦Form Requestのunitテストで$thisもprepareForValidationもテストする

13 min read

やりたいこと

テストコードを書くのはなかなか大変ですね…!
特に最初はFormRequest内の$this->input()がうまく取得できず解決に時間がかかってしまいました。。
最近テストで難しかったこと、そして解決したことについてまとめていきます。

今回やっていくことはこちら💡 FormRequestのunitテストで...

  • DBのデータを使用してテストする
  • prepareForValidationもテストする($this->input()も解決)
    • 認証ユーザーのデータを使用している場合のテスト
    • sessionへ入力値がある場合のテスト

環境

  • laravel 8
  • php 7.3

DBのデータを使用してテストする

use RefreshDatabaseを書くことでDBが自動でマイグレーション、トランザクション、ロールバックしてくれるのですが DBにあるデータと確認するexistsなどをruleで使っている場合、テストがうまくいかなくなります( ´~` )

public function rules()
{
    return [
        'userId' => 'required|exists:users,id'
    ];
}

use RefreshDatabase;は自動採番した番号はリセットされないため、手動でid管理しよう!と思っても不可能ではないですが楽をしたいですよね( ´~` )

当初は@dataProviderでなんとかしようとしたのですがなんとかできず、、、
setUp()をオーバーライドしてDBのデータを使用してテストできるようにします。

UserRequestTest
class UserRequestTest extends TestCase
{
    use RefreshDatabase;

    private static $params = [];

    public function setUp() : void
    {
        parent::setUp();

        $user = User::create([
            'name' => 'ユーザー1',
            'email' => 'test@example.com',
        ]);
        self::$params = [
            [
                true,
                'values' => [
                    'userId' => $user->id,
                ],
            ],[
                false,
                'values' => [
                    'userId' => '',
                ],
            ],[
                false,
                'values' => [
                    'userId' => ($user->id + 1),
                ],
            ]
        ];
    }

    /**
     * @test
     */
    public function testValidation()
    {
        $rules = (new UserRequest())->rules();
        foreach (self::$params as $param) {
            $validator = \Validator::make($param['values'], $rules);
            $this->assertEquals($param[0], $validator->passes());
        }
    }
}
💡setUp()メソッドとは..?

4. フィクスチャ — PHPUnit latest Manualに詳しくあるのですが、テストが実行される際の事前準備や事後処理をいい感じに行ってくれているものといった感じでしょうか

動く順番は下のようになっています🌙


setUpBeforeClass()

--------テストメソッドごとに実行される範囲 ↓
setUp()

tearDown()
--------テストメソッドごとに実行される範囲 ↑

tearDownAfterClass()


@dataProviderはテストの直前に動くと思っていたのですが
2. PHPUnit 用のテストの書き方のNoteの箇所にある通り、すべてのデータプロバイダを実行してから静的メソッドsetUpBeforeClass()setUp()メソッドの最初の呼び出しが発生するそうなので テストに入る頃にはDBの中身がなくなっているようです。

prepareForValidationもテストする

前項のテストはruleのチェックをしていたため FormRequest内にprepareForValidationがある場合、prepareForValidationはチェックされません( ´~` )

こちらFormRequestのユニットテストを参考にさせていただきまして🙏
今度はprepareForValidationもチェックされるようにしていきます。

💡prepareForValidationとは?

Laravel 8.x バリデーション 検証のための入力準備にある通り、ruleにあるバリデーションを実行する前にデータを加工することができます。

public function prepareForValidation() : void
{
    $this->merge([
        'userName' => \Auth::guard('user')->user()->name
    ]);
}

mergeするとrequestにuserNameが追加されます💡

前提

例えばSMS認証のFormRequestがあったとして...

  • userに紐づいたテーブルにSMSに送った6桁の番号を保存しておく
  • FormRequest内で入力された番号と、テーブルに保存された6桁の番号があっているか確認する

想像しやすいようにざっくり図を作ってみたのですが、例えなのでDBの作りはゆるゆるです😹
ER図

認証ユーザーのデータを使用している場合のテスト

以前、laravel5.8¦独自のバリデーションを作成!という記事内にrule内で独自のバリデーションをする方法を書いたのですが(クロージャのバリデーションのこと)
このバリデーションが単に入力値だけでなく、認証されたユーザー情報との掛け合わせで判定するものだったとします。

SmsAuthenticationRequest
class SmsAuthenticationRequest extends FormRequest
{
    public function authorize()
    {
        return true;
    }

    public function rules()
    {
        return [
            'code' => ['bail', 'required', 'digits:6',
                function ($attribute, $value, $fail) {
                    if ($this->input('code') !== $this->input('key')) {
                        $fail('入力値に誤りがあります。');
                    }
                }
            ]
        ];
    }

    public function prepareForValidation() : void
    {
        $this->merge([
            'key' => \Auth::guard('user')->user()->sms->key
        ]);
	 // smsはuser_sms_authenticationsのリレーション名
    }
}

前項の"DBのデータを使用してテストする"ようなテストを作成し、今回のFormRequestをテストしようとした場合
$params['values']にcodeとkey両方含めたとしても$this->input('code')$this->input('key')も nullになります。
イメージとしては$thisが$paramの内容になっているかと思ったのですが、、、

テストのポイントは...💡

  • prepareForValidationのAuth::guardでエラーにならないように認証済にする
  • DBに保存されている値と、prepareForValidationで加工された値が同じかも確認する
  • $this->input()が nullにならないようにしてテストが途中でエラーにならないようにする。
SmsAuthenticationRequestTest
class SmsAuthenticationRequestTest extends TestCase
{
    use RefreshDatabase;

    private static $successParams = [];
    private static $user;

    public function setUp() : void
    {
        parent::setUp();

        self::$user = User::create([
            'name' => 'ユーザー1',
            'email' => 'test@example.com',
        ]);

	// smsはuser_sms_authenticationsのことです
	$key = (new SmsKeyRegistrationService())->generateSecretKey();
        Sms::create([
                'user_id' => self::$user->id,
                'key' => $key,
         ]);

        self::$successParams = [
            [
                'key' => $key
            ],
        ];
    }

    /**
     * @test
     */
    public function testSuccessValidation()
    {
        foreach (self::$successParams as $param) {
            $this->assertTrue($this->executeRequest($param));
        }
    }

    protected function executeRequest($param)
    {
        $this->actingAs(self::$user, 'user')
            ->post('/check');

        $this->app->resolving(SmsAuthenticationRequest::class, function ($resolved) use ($param) {
            $resolved->merge($param);
        });

        try {
            $request = app(SmsAuthenticationRequest::class);
            $this->assertSame(self::$user->sms->key, $request->get('key'));
            return true;
        } catch (\ValidationException $e) {
            return false;
        }
    }
}
  • $this->actingAs()でユーザーを認証済にし、第2引数にガード名を記載
  • ->post()はSmsAuthenticationRequestのパスを指定
  • tryの中のassertSameでDBに保存されている値と、prepareForValidationで加工された値が同じかをテスト

なぜ$this->input()がうまく機能するようになったかというとlaravelのコンテナの順番のようです🤔
Testing Laravel form requests
@dataProviderを使用する場合もコンテナの順番の関係?でヘルパーが使えないのに似ているような…
 
🦢 🦢 🦢

sessionへ入力値がある場合のテスト

設定を変更する際にSMS認証をして問題なければ設定を変更する場合に2画面入力する必要があるとします。

設定変更する画面に入った時点で認証したらいい話ですが例ということで...下記のイメージになります。
 入力画面 → submit → SMS認証画面 → FormRequestで全てチェック → 問題なければ更新

入力画面で入力した内容をひとまずsessionに保存し、SMS認証画面後のFormRequestでまとめてチェックしていきます。

SmsAuthenticationCheckRequest
class SmsAuthenticationCheckRequest extends FormRequest
{
    public function authorize()
    {
        return true;
    }

    public function rules()
    {
        return [
            'name' => 'required|string',
            'address' => 'required|string',
            'code' => ['bail', 'required', 'digits:6',
                function ($attribute, $value, $fail) {
                    if ($this->input('code') !== $this->input('key')) {
                        $fail('入力値に誤りがあります。');
                    }
                }
            ]
        ];
    }

    public function prepareForValidation() : void
    {
        $name = $this->session()->pull('confirmName');
        $address = $this->session()->pull('confirmAddress');

        if (!empty($name)) {
            $this->merge(['name' => $name]);
        }

        if (!empty($address)) {
            $this->merge(['address' => $address]);
        }
	
	$this->merge([
            'key' => \Auth::guard('user')->user()->sms->key
        ]);
    }

    protected function getRedirectUrl()
    {
        return '/user/setting';
    }
}

少しずれますが、FormRequestでエラーの場合ひとつ前の画面に戻るため、今回のパターンではSMS認証画面に戻ることになります。
入力画面自体に戻したい場合はgetRedirectUrlで入力画面に戻るように指定しましょう🍬

テストのポイントは...💡

  • sessionに保存した内容もバリデーションされているか確認する
  • prepareForValidationのsession()->pull()でsessionが消えたかも確認する
SmsAuthenticationCheckRequestTest
class SmsAuthenticationCheckRequestTest extends TestCase
{
    use RefreshDatabase;

    private static $user;
    private static $successParams = [];

    public function setUp() : void
    {
        parent::setUp();

        $user = User::create([
            'name' => 'ユーザー1',
            'email' => 'test@example.com',
        ]);
	$key = (new SmsKeyRegistrationService())->generateSecretKey();
        Sms::create([
                'user_id' => self::$user->id,
                'key' => $key,
         ]);
        self::$successParams = [
            [
                'code' => $key
            ],
        ];
    }

    /**
     * @test
     */
    public function testSuccessValidation()
    {
        foreach (self::$successParams as $param) {
            $this->assertTrue($this->executeRequest($param, 'new name', 'new address'));
            $this->assertFalse(session()->has('confirmName'));
            $this->assertFalse(session()->has('confirmAddress'));
        }
    }

    protected function executeRequest($param, $name, $address)
    {
        $response = $this->actingAs(self::$user, 'user')
            ->withSession([
                'confirmName' => $name,
                'confirmAddress' => $address,
            ])
            ->post('/user/setting/confirm');

        $this->app->resolving(SmsAuthenticationCheckRequest::class, function ($resolved) use ($param) {
            $resolved->merge($param);
        });

        try {
            app(SmsAuthenticationCheckRequest::class);
            $this->assertSame(self::$user->sms->key, $request->get('key'));
            return true;
        } catch (\ValidationException $e) {
            return false;
        }
    }
}

ほとんど変わらないのですが、

  • $this->actingAs()の後にwithSessionを使いセッションに保存します
  • $this->assertFalse(session()->has('confirmName'))でsessionが消えたかも確認します。
     
    🦢 🦢 🦢

おまけ1: $this->input()がある場合にもっと簡単にテストする

単にバリデーションルール内でクロージャを使用し、$this->input('secret')と入力値codeをチェックする場合、requestを作ると解決できます。

$symfonyRequest = \Request::create(
    action('\App\Http\Controllers\SmsAuthenticationController@store'),
    'POST',
    [
        'key' => self::$user->sms->key,
        'code' => $param['code']
    ] // ここに$this->input()したい値を含めたバリデーションしたい値を入力
);
$request = SmsAuthenticationRequest::createFromBase($symfonyRequest);

$this->assertTrue((\Validator::make($request->all(), $request->rules()))->passes());

これなぜできるのかうまく説明できないので、誤りであればご指摘ください😹

おまけ2: failのテストもしたい

今までのテストはtrueのテストしかしていませんが、falseの場合も確認したいと思います、、、が!
app(SmsAuthenticationCheckRequest::class)でチェックされるわけですが、私はtry〜catchでfalseがうまく返ってきませんでした( ´~` )
try〜catchを修正すればいいのですがparamをまとめて作った方が簡単なこともあり、今回はおまけ1の方法を使いfalseのチェックをしています。

まずfailParamsをsetUp()内に作成🗒

SmsAuthenticationCheckRequestTest
self::$failParams = [
    [
        'name' => '',
        'address' => 'new address',
        'code' => $key
    ],[
        'name' => 'new name',
        'address' => '',
        'code' => $key
    ],[
        'name' => 'new name',
        'address' => 'new address',
        'code' => ''
    ],[
        'name' => 'new name',
        'address' => 'new address',
        'code' => 1234567
    ]
];

次にfail用のテストを作成🗒

SmsAuthenticationCheckRequestTest
/**
 * @test
 */
public function testFailValidation()
{
    foreach (self::$failParams as $param) {
        $symfonyRequest = \Request::create(
             action('\App\Http\Controllers\SmsAuthenticationCheckRequestController@store'),
            'POST',
            [
                'key' => self::$user->sms->key,
		'name' => $param['name'],
		'address' => $param['address'],
                'code' => $param['code']
            ]
        );
        $request = SmsAuthenticationCheckRequest::createFromBase($symfonyRequest);

        $this->assertFalse((\Validator::make($request->all(), $request->rules()))->passes());
    }
}

🦢 🦢 🦢

終わりに

結構調べましたがテスト奥が深すぎる、、、テスト初心者なのでだいぶ混乱しました。
いろんな方法でテストできると思いますし今回は一例ということで精進したいところです( ´~` )
今回ユーザーを毎回作っていたのですがfactory使ってくださいね…!

Discussion

ログインするとコメントできます