Laravel入門 異常系テストと例外処理(第18回)

2021-01-01
7 min read

今回は、異常終了や予期しないエラーへの対策を施し、テストを行っていきます。

現状、予期しないエラー発生時にはどうなるか?

存在しないタスクを要求するとどのようになるかを確かめます。下記にアクセスしてください。

http://localhost/tasks/999

上記にアクセスすると表示される画面は下記になります。

task検索エラー

エラーが表示されてしまいます。 これは、TaskControllershowメソッドの下記のコードのためです。

$task = Task::Find($id);

引数に指定したidFindを行うのですが、データベースに該当のレコードがないため$taskは null となります。 そのため$taskの値を表示する箇所でエラーとなります。

エラーを防ぐためには、$taskが null の場合、404エラー(Not Found)を表示する処理が必要となります。

予期しないエラー発生時の処理

Laravel では、検索が失敗する事を考慮したFindOrFailという便利なメソッドがあります。 Task::Find($id)を下記のように書き換えます。

$task = Task::FindOrFail($id);

書き換え後に再度、http://localhost/tasks/999 にアクセスしてください。

404

シンプルにエラーコード 404 とメッセージが表示されています。

このページの情報だけでは、ユーザーは何をすればよいかがわからないためもう少し手を加えるべきですが、今は残課題としてリストアップしておき、このままにしておきましょう。

Laravel でのエラー処理の公式ドキュメントの翻訳は下記になります。

https://readouble.com/laravel/7.x/ja/errors.html

予期しないエラーが発生するケース

検索時の予期しないエラーを含めそのほかにも考えられるものを列挙しました。

  • 新規登録ができない
  • 指定したデータが存在しない
  • 指定したデータが更新できない
  • 指定したデータが削除できない

メソッド単位に実装を改善

TaskControllerのメソッド別に予期しないエラーが発生する箇所を修正していきます。

create

該当なし。

store

$task->save()時に失敗した場合の処理を記述します。

//$task->save();
$result = $task->save();

if (!$result) {
     abort(422);
}

save() メソッドの戻り値は、saveに成功したときはTRUE、失敗したときはFALESEになります。

edit

Task::FindTask::FindOrFailに変更します。

//$task = Task::Find($id);
$task = Task::FindOrFail($id);

update

Task::FindTask::FindOrFailに変更します。

//$task = Task::Find($id);
$task = Task::FindOrFail($id);

$task->save()時に失敗した場合の処理を記述します。

//$task->save()
if (!$task->save()) {
    abort(422, 'fail update.');
}

updateStatus

update時と同様の修正をします。

//$task = Task::Find($id);
$task = Task::FindOrFail($id);

$task->save()時に失敗した場合の処理を記述します。

//$task->save()
if (!$task->save()) {
    abort(422, 'fail update.');
}

destroy

destroyメソッドは、削除件数が戻り値として返される。 そのため削除件数が 1 件未満の場合は、削除対象がないか、削除に失敗したのどちらかのためエラーとする。

//Task::destroy($id);
$deletedRows = Task::destroy($id);
if ($deletedRows < 1) {
    abort(422, 'fail destroy.');
}

テスト

対象が存在しない場合のテストと保存や更新などのデータベースのレコード操作の失敗のテストに分けます。

存在しない場合のテスト実装

php artisan make:test NotFoundlTest

生成後にuse RefreshDatabaseなど必要なものを追記します。

showに対してのテスト

showメソッドに対してのテストを実装します。

  public function testShow()
  {

      $response = $this->get(route('task.edit', [ 'id' => 999 ]));
      $response->assertStatus(404);
  }

updateに対して更新する対象のデータが存在しない

updateメソッドに対して、更新する対象のデータが存在しない場合のテストを実装します。


  public function testUpdateShow()
  {

      $data = [
        'status' => 1,
        'title' => "title",
        'description' => "wゑエ😀𩸽"
      ];

      $response = $this->put(route('task.update', [ 'id' => 999 ]), $data);
      $response->assertStatus(404);
  }

updateStatusに対して更新する対象のデータが存在しない

updateStatusメソッドに対して更新する対象のデータが存在しない場合のテストを実装します。

public function testUpdateStatusNotFound()
{
    $response = $this->get(route('task.updateStatus', ['id' => 999,'afterstatus' => 4]));
    $response->assertStatus(404);
}

destroyに対して削除する対象のデータが存在しない

destroyメソッドに対して削除する対象のデータが存在しない場合のテストを実装します。

public function testDeleteShow()
{
    $response = $this->delete(route('task.delete', [ 'id' => 999 ]));
    $response->assertStatus(422);
}

レコード操作失敗のテスト実装

テストクラスを作成します。

php artisan make:test AbnormalTest

生成後にuse RefreshDatabaseなど必要なものを追記します。

<?php

namespace Tests\Feature;

use Mockery;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Tests\TestCase;
use App\Task;

class AbnormalTest extends TestCase
{
    use RefreshDatabase;

    protected function setUp(): void
    {
        parent::setUp();
    }

    public function tearDown(): void
     {
         parent::tearDown();
         Mockery::close();
     }
}

さらに、上記のようにuse Mockeryを追加してください。 モックを作成するためのツールのMockeryを使用するためにインポートを行います。

Mockeryについては詳細は Laravel の公式ドキュメントの翻訳を参照してください。

https://readouble.com/mockery/1.0/ja/index.html

storeに対してのテスト

storeメソッドに対してのテストを実装します。

public function testStore()
{

    $mock = Mockery::mock('overload:App\Task');
    $mock->shouldReceive('fill')->once()->andReturn(true);
    $mock->shouldReceive('save')->once()->andReturn(false);

    $data = [
      'status' => 1,
      'title' => "title",
      'description' => "description"
    ];

    $response = $this->post('/tasks', $data);
    $response->assertStatus(422);
}

ポイントは、1 行目でモックを生成しているところです。 テスト対象のstoreメソッドは、メソッド内で、App\Taskのオブジェクトを生成しています。 下記の$task = new Task()です。

public function store(Request $request)
{

    $request->validate([
        'title' => ['max:20','required'],
        'description' => ['max:200']
    ]);

    $task = new Task();
    $task->fill($request->all());
    $task->status = 1;
    $result = $task->save();
    if (!$result) {
         abort(422);
    }

    return redirect(route('home'));
}

その後に、$taskオブジェクトに対して、fill()save()メソッドを呼んでいます。 今、取り組もうとしているのが異常系のテストですので、save()メソッドを呼んだときの戻り値が false にならないとテストが行えません。

こういうときに、モックを使用することで、常にfalse を戻せます。 テストコードの下記行で、saveが呼ばれたときに戻り値として false を返すように設定しています。

$mock->shouldReceive('save')->once()->andReturn(false);

結果、abort(422)が常に実行されるようなります。

updateに対してのテスト

updateメソッドに対して更新に失敗するテストを実装します。

public function testUpdate()
{

    $taskmodel = new class {
        public function fill($array)
        {
            return true;
        }
        public function save()
        {
            return false;
        }
    };

    $mock = Mockery::mock('overload:App\Task');
    $mock->shouldReceive('FindOrFail')->once()->andReturn($taskmodel);

    $data = [
    'status' => 1,
    'title' => "title",
    'description' => "wゑエ😀𩸽"
    ];

    $response = $this->put("/tasks/1", $data);
    $response->assertStatus(422);
}

ポイントは、FindOrFailメソッドが呼ばれた際に、モッククラスを返却するようにしている箇所です。 モッククラスは、saveメソッドの戻り値として false を返す無名クラスです。

updateStatusに対してのテスト

updateStatusメソッドに対して更新に失敗するテストを実装します。

public function testUpdateStatus()
{

    $taskmodel = new class {
        public $status;

        public function save()
        {
            return false;
        }
    };

    $mock = Mockery::mock('overload:App\Task');
    $mock->shouldReceive('FindOrFail')->once()->andReturn($taskmodel);

    $response = $this->get(route('task.updateStatus', ['id' => 1,'afterstatus' => 4]));
    $response->assertStatus(422);
}

destroyに対してのテスト

destroyメソッドに対して削除に失敗するテストを実装します。

public function testDelete()
{
    $mock = Mockery::mock('overload:App\Task');
    $mock->shouldReceive('destroy')->once()->andReturn(0);
    $response = $this->delete(route('task.delete', [ 'id' => 999 ]));
    $response->assertStatus(422);
}

overload について

ところで、モックを作る際に、必ずoverloadというものを指定しています。

$mock = Mockery::mock('overload:App\Task');

テスト対象のstoreメソッド内でnewしているオブジェクトを置き換える場合は、overloadを指定する必要があります。 overloadは、非常に便利なのですがテスト対象のコードにて使用されるメソッドすべてを実装する必要があります。 例えば、前述の「updateに対してのテスト」のモックでは、saveメソッドに加えて、fillメソッドも実装しています。

これを回避するためには、App\Taskのオブジェクトの生成は Laravel に任せて、コントローラ生成時か、メソッド呼び出し時に自動で渡されるようにコードを書くようにします。(コンストラクタインジェクション or メソッドインジェクション or ルートモデルバインディング)

その場合は、overloadは不要で、下記のようにモックを生成できます。

$mock = Mockery::mock('App\Task') 

そして、makePartial()にてパーシャルモックを生成できます。

$mock = Mockery::mock('App\Task')->makePartial()

パーシャルモックは、元のクラスのメソッド、プロパティをコピーしたインスタンスを生成します。 すなわち、モックしたいメソッド以外の実装が不要になります。

overloadについての公式の詳しい解説は下記にあります。

http://docs.mockery.io/en/latest/cookbook/mocking_hard_dependencies.html

テスト実行

下記のコマンドでテストを実行します。

$ ./vendor/bin/phpunit tests/Feature/NotFoundlTest.php --testdox
$ ./vendor/bin/phpunit tests/Feature/AbnormalTest.php --testdox

一部のテストのみ行う場合は下記のように--filterをつけます。

$ ./vendor/bin/phpunit  tests/Feature/AbnormalTest.php --filter 'testStore'

エラーの場合

テストコードを実行して、ステータスコードが 500 の失敗となった場合、setUp()に下記を追加してください。

protected function setUp(): void
{
    parent::setUp();
    $this->withoutExceptionHandling(); // 追加
}

より詳しいエラーメッセージが表示されます。