Laravelで単体テストを使いまわす2


背景

前回の続き

テンプレートメソッドパターンをベースにテストコードを共通化(かつ規格化)できないかと考えた

CRUDテストできる抽象クラスができたのですが、かなりロジック設計に依存しているので、どちらかと言うと自分への備忘録になってます

環境

  • PHP 7.2.13
  • laravel 5.5
  • PHPUnit 6.5.13

抽象クラス

ちなみに自分、テストの関数名は日本語をむしろ推奨する派です。

ServiceTest.php
<?php

namespace Tests\Unit\Services;

use DB;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;
use Tests\TestCase;

/**
 * Class ServiceTest
 */
abstract class ServiceTest extends TestCase
{
    use RefreshDatabase;

    /**
     * @var
     */
    protected $result;

    /**
     * エンティティの名称(単数形)
     *
     * @var string
     */
    protected $entity_name;

    /**
     * モデル名(名前空間)
     *
     * @var string
     */
    protected $model_name;

    /**
     * @var \App\Services\ModelService
     */
    protected $Service;

    /**
     * エンティティ名を返す
     *
     * @return string
     */
    abstract protected function getEntityName(): string;

    /**
     * パラメータの設定とシーダーの投入
     */
    public function setUp()
    {
        parent::setUp();

        // 設計や命名規約によりますが、エンティティの名前から依存ファイル読み込めば楽
        $this->entity_name = $this->getEntityName();
        $this->model_name  = 'App\\Models\\' . $this->entity_name;
        $this->Service     = app()->make(
            'App\\Services\\' . $this->entity_name . 'Service',
            ['App\\Repositories\\' . $this->model_name . 'Repository']
            );

        // 外部キー制約無視
        DB::statement('SET FOREIGN_KEY_CHECKS=0;');

        // 例えばシーダーは複数形の命名規約、とかの場合
        $this->seed('\\' . str_plural($this->entity_name) . 'Seeder');

        // サービスによって追加のシーダが必要な時
        $this->additionalSeed();
        DB::statement('SET FOREIGN_KEY_CHECKS=1;');
    }

    /**
     * @test
     */
    public function all()
    {
        $this->result = $this->Service->all();
        $this->assertInstanceOf(Collection::class, $this->result);
        $this->assertGreaterThanOrEqual(1, $this->result->count());
        foreach ($this->result as $item) {
            $this->assertInstanceOf($this->model_name, $item);
        }
    }

    /**
     * findのデータプロバイダー
     *
     * @return array
     */
    abstract public function dataProvider_find(): array;

    /**
     * @test
     * @dataProvider dataProvider_find
     * @param int $id
     */
    public function find(int $id)
    {
        $this->result = $this->Service->find($id);
        $this->assertInstanceOf($this->model_name, $this->result);
    }

    /**
     * @test
     */
    public function find_失敗_該当データなし()
    {
        $id  = $this->getKeyNotExist();
        $res = $this->Service->find($id);
        $this->assertNull($res);
    }

    /**
     * ユーザーリクエストのデータプロバイダー
     *
     * @return array
     */
    abstract public function dataProvider_request(): array;

    /**
     * @test
     * @dataProvider dataProvider_request
     * @param array $data
     */
    public function store(array $data)
    {
        // フォームリクエストはモックを使う

        // データプロバイダーから渡されたデータを加工できる(ファクトリー使うなど)
        // また、モックを作成する前にサービスごとに特殊な処理を記載できる
        // 下の方にあるサブクラス参照
        $data = $this->beforeMakeMockFomRequest($data);

        $before = $this->Service->all();

        /** @var \Illuminate\Foundation\Http\FormRequest $request */
        // フォームリクエストはモックモックです
        $request = $this->makeMockFomRequest($data);

        // 保存
        $this->result = $this->Service->store($request)->toArray();

        // 保存されたモデルにリクエストデータが含まれているかチェック
        $this->assertEloquentData($data);

        // テーブルにデータが保存されているかのチェック
        $after = $this->Service->all();
        $this->assertEquals($before->count() + 1, $after->count());
        $this->assertDatabaseHas((new $this->model_name())->getTable(), $data);
    }

    /**
     * @test
     * @dataProvider dataProvider_request
     * @param array $data
     */
    public function update(array $data)
    {
        // updateのテストも処理はほとんどstoreと同じ

        $id         = $this->getKeyToUpdatingModel();
        $table_name = (new $this->model_name())->getTable();
        $data       = $this->beforeMakeMockFomRequest($data);

        // update前はデータが存在しないかチェック
        $this->assertDatabaseMissing($table_name, $data);

        /** @var \Illuminate\Foundation\Http\FormRequest $request */
        $request = $this->makeMockFomRequest($data);

        // 更新
        $this->Service->update($request, $id);

        // 更新後のデータを取得
        $this->result = $this->Service->find($id)->toArray();

        // 保存されたモデルにリクエストデータが含まれているかチェック
        $this->assertEloquentData($data);

        // テーブルにデータが保存されているかのチェック
        $this->assertDatabaseHas($table_name, $data);
    }

    /**
     * フォームリクエストのモックを作成
     * @param array $data
     * @return \Mockery\Mock
     */
    protected function makeMockFomRequest(array $data)
    {
        $mock = Mockery::mock(FormRequest::class)->makePartial();
        // allが呼ばれた時にdataを返す
        $mock->shouldReceive('all')->andReturn($data);

        return $mock;
    }

    /**
     * ファクトリーから対象データの配列を生成
     *
     * @param array $attributes
     * @return array
     */
    protected function makeRequestDataFromFactory(array $attributes): array
    {
        return factory($this->model_name)->make($attributes)->toArray();
    }

    /**
     * @test
     */
    public function destroy()
    {
        $id = $this->getKeyToUpdatingModel();

        // delete前はデータが存在することをチェック
        $this->assertNotNull($this->Service->find($id));

        // 削除
        $this->Service->destroy($id);

        // 更新後のデータを取得
        $this->result = $this->Service->find($id);

        // delete後はデータが存在しないことをチェック
        $this->assertNull($this->result);
    }

    /**
     * 追加のシーダを実行する
     */
    protected function additionalSeed()
    {
        // 抽象クラスでは何もしない
    }

    /**
     * リクエスト作成の前処理
     *
     * @param array $data
     * @return array
     */
    protected function beforeMakeMockFomRequest(array $data)
    {
        // 抽象クラスでは何もしない
        return $data;
    }

    /**
     * 存在しない主キーを返す
     *
     * @return int|string
     */
    protected function getKeyNotExist()
    {
        return 9999;
    }

    /**
     * アップデート対象データの主キーを返す
     *
     * @return int|string
     */
    protected function getKeyToUpdatingModel()
    {
        return 1;
    }

    /**
     * 保存されたモデルにリクエストデータが含まれているかチェック
     *
     * @param array $data
     */
    private function assertEloquentData(array $data): void
    {
        // hiddenパラメータのものは含まれない
        $hidden = (new $this->model_name())->getHidden();
        foreach ($data as $column) {
            if (in_array($column, $hidden, true)) {
                $this->assertFalse(in_array($column, $this->result, true));
            } else {
                $this->assertTrue(in_array($column, $this->result, true));
            }
        }
    }

サブクラス

企業情報を扱うサービスのテスト

CompanyServiceTest.php
<?php

namespace Tests\Unit\Services;

/**
 * Class CompanyServiceTest
 */
class CompanyServiceTest extends ServiceTest
{
    /**
     * エンティティ名を返す
     *
     * @return string
     */
    protected function getEntityName(): string
    {
        return 'Company';
    }

    /**
     * findのデータプロバイダー
     *
     * @return array
     */
    public function dataProvider_find(): array
    {
        return [
            'id: 1'  => [1],
            'id: 2'  => [2],
        ];
    }

    /**
     * ユーザーリクエストのデータプロバイダー
     *
     */
    public function dataProvider_request(): array
    {
        return [
            'テスト:企業'  => [
                [
                    'name' => 'テスト企業',
                ],
            ],
        ];
    }

    /**
     * リクエスト作成の前処理
     *
     * @param array $data
     * @return array
     */
    protected function beforeMakeMockFomRequest(array $data)
    {
        // リクエストデータをファクトリーから生成(この例でnameだけ指定
        return $this->makeRequestDataFromFactory(['name' => $data['name']]);
    }

    /**
     * フォームリクエストのモックを作成
     *
     * @param array $data
     * @return \Mockery\Mock
     * @see \Tests\Unit\Services\ServiceTest::makeMockFomRequest()
     */
    protected function makeMockFomRequest(array $data)
    {
        // オーバーライドすることで、例えばリクエストにアップロード画像を仕込んだりできる

        $mock                = Mockery::mock(FormRequest::class)->makePartial();
        $UploadedFile        = UploadedFile::fake()->image('dummy.jpg', 100, 100);
        $this->file_path     = 'images/' . $UploadedFile->hashName();
        $data                = array_merge($data, ['image_name' => $this->file_path]);
        $mock->shouldReceive('all')->andReturn($data);
        $mock->shouldReceive('hasFile')->andReturn(true);
        $mock->shouldReceive('file')->andReturn($UploadedFile);

        return $mock;
    }

    /**
     * @test
     * @dataProvider dataProvider_request
     * @param array $data
     */
    public function store(array $data)
    {
        // 追加アサーションなどあればオーバーライドしちゃう

        parent::store($data);

        $this->assertDatabaseHas((new $this->model_name())->getTable(), ['image_name' => $this->file_path]);
        Storage::disk('testing')->assertExists($this->file_path);
    }

}

Laravel(Eloquent)特化

こうやってみると、基本的にはLaravel(Eloquent)に特化しているので、
設計思想以外は使えんかと思います。

要は
「共通化できる箇所は抽象クラスで共通化して、差分はサブクラスでオーバーライドしちゃおう」
といった思想です。

ポイント

  • 抽象クラスをテンプレートにし継承することで、サブクラスの方は基本的に何も実装しなくてもCRUDのテストが実行できる(実行を約束される)
  • ただし、テスト対象のクラスが規格化されていないとダメ。クラス毎にメソッド名がバラバラだったり命名規則がなかったら死ぬ
  • テスト対象となるデータをパラメータresultで保持している(それによりサブクラスでアサーションの追加をおこなったり、テストを続行 できる)
  • フォームリクエストはモックにしており、そのモックの性能差も各サブクラスに委任している
  • 結果的にテストを共通化しておくと、ビジネスロジックも自ずと共通化される

あとがき

こんなこともできる、ってだけで、必ずしも正しい仕組みなのかはわからない。
ただ、ロジックにルールはあった方が綺麗なので、テストにもルールを(半)強制化した方が良いと感じました。