Laravelでのテスト駆動開発方針とテスト規約

本記事は、[サムザップ Advent Calendar 2020 #1](https://qiita.com/advent-calendar/2020/sumzap1) の12/13の記事です。

## はじめに

タイトルがお堅いのですが内容は、

* **開発初期のプロジェクトなので、せっかくだから、ガッツリテストを書きましょう！**
* **テスト方針、規約を決めて、テストの品質を保ちましょう！**
* サンプルコード
* 補足（Privateメソッドのテスト、モックの利用、Facatoryって素敵）

上記になります。

テストは、_Laravel_ デフォルトの _PHPUnit_ を使用します。

### 環境

* _Laravel_

```
$ php artisan --version
Laravel Framework 6.19.1
```

* _php_

```
$ php -v
PHP 7.4.11 (cli) (built: Oct  1 2020 19:44:09) ( NTS )
Copyright (c) The PHP Group
Zend Engine v3.4.0, Copyright (c) Zend Technologies
    with Zend OPcache v7.4.11, Copyright (c), by Zend Technologies
    with Xdebug v2.9.8, Copyright (c) 2002-2020, by Derick Rethans
```

* _PHPUnit_

```
$ phpunit --version
PHPUnit 8.5.8 by Sebastian Bergmann and contributors.
```

## テスト方針
テスト方針は、ざっくりですが、以下の2点としました。

* テストの実行時間を考慮して、テスト種別でモック（※1）を利用したテストとデータベース等にアクセスするテストと分割する。
* テストケースはメソッドごとに記述する。

__※1__ モックとは、
>モックとは主にコード内のロジックを検証するために必要な「1. テスト条件を設定」するための手段です。
>テスト対象が別クラスに依存している場合、テスト条件はそれらのインスタンスの振る舞いによって決まることが多いでしょう。モックは依存クラスの振る舞いをテスト時に、開発者が定義したものに「すり替える」ことを指します。

[手軽なLaravelテストコード (3種類のモック手法)](https://qiita.com/dublog/items/803516e2ecf998230f9a) からの引用

### テスト種別

#### _Integration_

Action（Controller)のテスト。データベースアクセスを伴う、APIの疎通。
HTTPステータスコード、APIのレスポンスの検証を行う。

#### _Feature_

Action（Controller)のテスト。データベースアクセスは行わず、モックを利用したテスト。
APIのリクエストのパラメータの検証。

#### _Unit_

Repository、Service（Facede）、Domain/Service（ビジネスロジック）のテスト。
Repositoryのテストはデータベースのアクセスを行う。それ以外はモックを利用したテスト。
メソッドの戻り値の検証。

※ 原則、全てのテスト種別で、正常系、異常系のテストを記述する。

## サンプルコード

_QiitaSample_ テーブルにアクセスする _Repository_ の _Unit_ テストのサンプルです。

#### _QiitaSample Table_

```
mysql> desc qiita_samples;
+------------+---------------------+------+-----+---------+-------+
| Field      | Type                | Null | Key | Default | Extra |
+------------+---------------------+------+-----+---------+-------+
| id         | bigint(20) unsigned | NO   | PRI | NULL    |       |
| user_id    | bigint(20) unsigned | NO   | UNI | NULL    |       |
| name       | varchar(255)        | NO   |     | NULL    |       |
| value      | varchar(255)        | NO   |     | NULL    |       |
| created_at | timestamp           | YES  |     | NULL    |       |
| updated_at | timestamp           | YES  |     | NULL    |       |
| deleted_at | timestamp           | YES  |     | NULL    |       |
+------------+---------------------+------+-----+---------+-------+
7 rows in set (0.02 sec)
```

#### _Eloquent Model_

```
<?php

declare(strict_types = 1);

namespace App\Domain\Eloquent\User;

use App\Domain\Eloquent\BaseModel as Model;
use Illuminate\Database\Eloquent\SoftDeletes;

class QiitaSample extends Model
{
    use SoftDeletes;

    public static $rules = [
        'user_id' => 'required',
        'name' => 'required',
        'value' => 'required',
    ];

    protected $fillable = [
        'id',
        'user_id',
        'name',
        'value',
    ];

    protected $connection = 'qiita_sample';

    protected $guarded = [];

    protected $casts = [
        'user_id' => 'integer',
        'name' => 'string',
        'value' => 'string',
    ];
}
```

#### _Entity Model_

```
<?php

declare(strict_types = 1);

namespace App\Domain\Entities\User;

use App\Domain\Entities\Base as Entity;

class QiitaSample extends Entity
{
    protected $fillable = [
        'id',
        'user_id',
        'name',
        'value',
    ];

    protected $casts = [
        'id' => 'integer',
        'user_id' => 'integer',
        'name' => 'integer',
        'value' => 'integer',
    ];

    protected $dates = [];

    protected $guarded = [];

    protected $guarded_for_update = [
        'id',
        'user_id',
    ];

    public function getId(): int
    {
        return $this->getAttribute('id');
    }

    public function getUserId(): int
    {
        return $this->getAttribute('user_id');
    }

    public function getName(): string
    {
        return $this->getAttribute('name');
    }

    public function getValue(): string
    {
        return $this->getAttribute('value');
    }
}
```

#### _Repository Interface_

```
<?php

declare(strict_types = 1);

namespace App\Domain\RepositoryInterfaces\User;

interface QiitaSampleRepositoryInterface
{
    /**
     * @param array $user_id
     */
    public function findByUserId(int $user_id);

    /**
     * @param array $user_ids
     */
    public function getByUserIds(array $user_ids);
}
```

#### _Repository_

_Repository_ の取得系のメソッドは基本的に _Entity_ 、 _Collection_ （要素は _Entitiy_ ）を戻り値としています。

```
<?php

declare(strict_types = 1);

namespace App\Infrastructures\Repositories\User;

use App\Domain\Eloquent\User\QiitaSample as QiitaSampleModel;
use App\Domain\Entities\User\QiitaSample as QiitaSampleEntity;
use App\Domain\RepositoryInterfaces\User\QiitaSampleRepositoryInterface;
use Illuminate\Support\Collection;

class QiitaSampleRepository implements QiitaSampleRepositoryInterface
{
     /**
     * @param int $user_id
     */
    public function findByUserId(int $user_id): ?QiitaSampleEntity
    {
        $qiita_sample = QiitaSampleModel::where('user_id', $user_id)->first();

        return QiitaSampleEntity::getInstance($qiita_sample);
    }

    /**
     * @param array $user_ids
     */
    public function getByUserIds(array $user_ids): Collection
    {
        $qiita_samples = QiitaSampleModel::whereIn('user_id', $user_ids)->get();

        return $qiita_samples->map(fn ($qiita_sample) => QiitaSampleEntity::getInstance($qiita_sample));
    }
}
```

#### _Factory_

データベースに挿入するテストデータを管理。

```
<?php

/** @var \Illuminate\Database\Eloquent\Factory $factory */

use App\Domain\Eloquent\User\QiitaSample as Model;
use Faker\Generator as Faker;

$factory->define(Model::class, function (Faker $faker) {
    return [
        'id' => 10002,
        'user_id' => 1001,
        'name' => '太郎',
        'value' => 'QiitaSample太郎',
    ];
}, 'qiita_sample_1');
```

#### _Test_ コード

Testのファイル名は、[テスト対象のファイル名]Test.phpとします。
今回は、 _QiitaSampleRepository.php_ のテストなので、 _QiitaSampleRepositoryTest.php_ とします。
基本的なテストコードのフォーマットは以下となります。（一部、苦肉の策もありますが）

```
<?php

declare(strict_types = 1);

namespace Tests\Unit\Infrastructures\Repositories\User;

use App\Domain\Eloquent\User\QiitaSample as QiitaSampleModel;
use App\Domain\Entities\User\QiitaSample as QiitaSampleEntity;
use App\Infrastructures\Repositories\User\QiitaSampleRepository;
use Tests\TestCase;

// テスト対象が QiitaSampleRepository なので、クラス名は QiitaSampleRepositoryTest します。
class QiitaSampleRepositoryTest extends TestCase
{
    protected $repository;

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

        $this->repository = app(QiitaSampleRepository::class);
    }

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

    // dataProviderを利用し、テストメソッドの引数は、$params, $expected, $message とします。
    /**
     * @dataProvider provider_test_findByUserId
     * @param mixed $params
     * @param mixed $expected
     * @param mixed $message
     */
    // 対象のメソッド名のプレフィックスに[test_]を付けたメソッド名にします。
    public function test_findByUserId($params, $expected, $message): void
    {
        // 例外
        if ($expected->call($this) instanceof \Throwable) {
            $this->expectException(get_class($expected->call($this)));
        }

        // テストデータの挿入・削除をします。(parent::tearDown() で挿入したデータのtruncateをします。)
        $this->bulkInsertIterable(QiitaSampleModel::class, $params['qiita_sample_factories']->call($this));

        // テスト対象のメソッド
        $result = $this->repository->findByUserId($params['user_id']);

        // テスト結果の検証
        $this->assertEquals($expected->call($this), $result, $message);
    }

    public function provider_test_findByUserId(): array
    {
        return [
            // テストパターンを記述します。
            [
                'params' => [
                    'user_id' => 1001,
                    // テストデータの挿入の定義
                    'qiita_sample_factories' => fn () => [
                        factory(QiitaSampleModel::class, 'qiita_sample_1')->make(),
                    ],
                ],
                // 実行結果との比較
                'expected' => fn () => QiitaSampleEntity::getInstance(factory(QiitaSampleModel::class, 'qiita_sample_1')->make()->toArray()),
                // テスト内容
                'message' => '正常: 取得できる',
            ],
            [
                'params' => [
                    'user_id' => 9999,
                    'qiita_sample_factories' => fn () => [],
                ],
                'expected' => fn () => null,
                'message' => '正常: ユーザが存在しない（user_id=9999）',
            ],
        ];
    }

    /**
     * @dataProvider provider_test_getByUserIds
     * @param mixed $params
     * @param mixed $expected
     * @param mixed $message
     */
    public function test_getByUserIds($params, $expected, $message): void
    {
        if ($expected->call($this) instanceof \Throwable) {
            $this->expectException(get_class($expected->call($this)));
        }

        $this->bulkInsertIterable(QiitaSampleModel::class, $params['qiita_sample_factories']->call($this));

        $result = $this->repository->getByUserIds($params['user_ids']);

        $this->assertEquals($expected->call($this), $result, $message);
    }

    public function provider_test_getByUserIds(): array
    {
        return [
            [
                'params' => [
                    'user_ids' => [
                        1001,
                        2001
                    ],
                    'qiita_sample_factories' => fn () => array_map(
                        fn ($fake) => factory(QiitaSampleModel::class, 'qiita_sample_1')->make($fake),
                        [
                            [
                                'id' => 1,
                                'user_id' => 1001,
                                'name' => 'qiita1'
                            ],
                            [
                                'id' => 2,
                                'user_id' => 2001,
                                'name' => 'qiita2'
                            ],
                        ],
                    ),
                ],
                'expected' => fn () => collect(array_map(
                    fn ($fake) => QiitaSampleEntity::getInstance(factory(QiitaSampleModel::class, 'qiita_sample_1')->make($fake)),
                    [
                        [
                            'id' => 1,
                            'user_id' => 1001,
                            'name' => 'qiita1'
                        ],
                        [
                            'id' => 2,
                            'user_id' => 2001,
                            'name' => 'qiita2'
                        ],
                    ],
                )),
                'message' => '正常: 2件取得できる',
            ],
            [
                'params' => [
                    'user_ids' => [
                        9999,
                        2001
                    ],
                    'qiita_sample_factories' => fn () => array_map(
                        fn ($fake) => factory(QiitaSampleModel::class, 'qiita_sample_1')->make($fake),
                        [
                            [
                                'id' => 1,
                                'user_id' => 1001,
                                'name' => 'qiita1'
                            ],
                            [
                                'id' => 2,
                                'user_id' => 2001,
                                'name' => 'qiita2'
                            ],
                        ],
                    ),
                ],
                'expected' => fn () => collect(array_map(
                    fn ($fake) => QiitaSampleEntity::getInstance(factory(QiitaSampleModel::class, 'qiita_sample_1')->make($fake)),
                    [
                        [
                            'id' => 2,
                            'user_id' => 2001,
                            'name' => 'qiita2'
                        ],
                    ],
                )),
                'message' => '正常: 1件取得できる',
            ],
        ];
    }
}
```

上記のテストを流した結果です。

```
$ phpunit tests/Unit/Infrastructures/Repositories/User/QiitaSampleRepositoryTest.php 
PHPUnit 8.5.8 by Sebastian Bergmann and contributors.

....                                                                4 / 4 (100%)

Time: 8.53 seconds, Memory: 14.00 MB

OK (4 tests, 4 assertions)
```

## 補足

#### _Private_ メソッドのテスト

_QiitaSampleRepository.php_ に _privete_ メソッドを追記します。

```
    /**
     * @param int $id
     */
    private function privateSample(int $id): ?QiitaSampleEntity
    {
        $qiita_sample = QiitaSampleModel::find($id);

        return QiitaSampleEntity::getInstance($qiita_sample);
    }
```

_QiitaSampleRepositoryTest.php_ に _privete_ メソッドのテストを追記します。

```
    /**
     * @dataProvider provider_test_privateSample
     * @param mixed $params
     * @param mixed $expected
     * @param mixed $message
     */
    public function test_privateSample($params, $expected, $message): void
    {
        if ($expected->call($this) instanceof \Throwable) {
            $this->expectException(get_class($expected->call($this)));
        }

        $this->bulkInsertIterable(QiitaSampleModel::class, $params['qiita_sample_factories']->call($this));

        $repository = new \ReflectionClass('App\Infrastructures\Repositories\User\QiitaSampleRepository');
        $function = $repository->getMethod('privateSample');
        $function->setAccessible(true);

        $result = $function->invokeArgs($this->repository, [
            $params['id'],
        ]);

        $this->assertEquals($expected->call($this), $result, $message);
    }

    public function provider_test_privateSample(): array
    {
        return [
            [
                'params' => [
                    'id' => 10002,
                    'qiita_sample_factories' => fn () => [
                        factory(QiitaSampleModel::class, 'qiita_sample_1')->make(),
                    ],
                ],
                'expected' => fn () => QiitaSampleEntity::getInstance(factory(QiitaSampleModel::class, 'qiita_sample_1')->make()->toArray()),
                'message' => '正常: 取得できる',
            ],
            [
                'params' => [
                    'id' => 9999,
                    'qiita_sample_factories' => fn () => [],
                ],
                'expected' => fn () => null,
                'message' => '正常: idが存在しない（id=9999）',
            ],
        ];
    }
```

テストを実行します。（filterで追記した _private_ メソッドのテストだけ）

```
$ phpunit tests/Unit/Infrastructures/Repositories/User/QiitaSampleRepositoryTest.php --filter=test_privateSample
PHPUnit 8.5.8 by Sebastian Bergmann and contributors.

..                                                                  2 / 2 (100%)

Time: 5.07 seconds, Memory: 12.00 MB
```

#### モックの利用

_QiitaSampleRepository.php_ にメソッドを追記します。

```
    public function getQiitaSample(): string
    {
        return 'QiitaSample';
    }
```

_Domain/Service_ を作成します。

```
<?php

declare(strict_types = 1);

namespace App\Domain\Services\User;

use App\Domain\RepositoryInterfaces\User\QiitaSampleRepositoryInterface;

class QiitaSampleService
{
    protected QiitaSampleRepositoryInterface $qiita_sample;

    /**
     * @param QiitaSampleRepositoryInterface $qiita_sample
     */
    public function __construct(
        QiitaSampleRepositoryInterface $qiita_sample
    ) {
        $this->qiita_sample = $qiita_sample;
    }

    public function getQiitaSample(): string
    {
        return $this->qiita_sample->getQiitaSample();
    }
}
```

_Domain/Service_ のテストコード書きます。

```
<?php

declare(strict_types = 1);

namespace Tests\Unit\Domain\Services\User;

use App\Domain\RepositoryInterfaces\User\QiitaSampleRepositoryInterface;
use App\Domain\Services\User\QiitaSampleService;
use Mockery;
use Tests\TestCase;

class QiitaSampleServiceTest extends TestCase
{
    public function setUp(): void
    {
        parent::setUp();
    }

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

    /**
     * @dataProvider provider_test_getQiitaSample
     * @param mixed $params
     * @param mixed $expected
     * @param mixed $message
     */
    public function test_getQiitaSample($params, $expected, $message): void
    {
        if ($expected->call($this) instanceof \Throwable) {
            $this->expectException(get_class($expected->call($this)));
        }

        $this->mock(
            QiitaSampleRepositoryInterface::class,
            function ($mock) use ($params): void {
                $mock->shouldReceive('getQiitaSample')->andReturn($params['mock']['QiitaSampleRepositoryInterface']['getQiitaSample']);
            }
        );

        $service = app(QiitaSampleService::class);

        $result = $service->getQiitaSample();

        $this->assertEquals($expected->call($this), $result, $message);
    }

    public function provider_test_getQiitaSample(): array
    {
        return [
            [
                'params' => [
                    'mock' => [
                        'QiitaSampleRepositoryInterface' => [
                            'getQiitaSample' => 'QiiiiitaSample',
                        ],
                    ]
                ],
                'expected' => fn () => 'QiiiiitaSample',
                'message' => '正常: 取得できる',
            ],
        ];
    }
}
```

実行結果は以下です。

```
$ phpunit tests/Unit/Domain/Services/User/QiitaSampleServiceTest.php 
PHPUnit 8.5.8 by Sebastian Bergmann and contributors.

.                                                                   1 / 1 (100%)

Time: 4.28 seconds, Memory: 12.00 MB

OK (1 test, 2 assertions)
```

#### _Factory_ って素敵

* _Factory_ に定義したデータを _Eloquent Model_ で取得

```
$ php artisan tinker
Psy Shell v0.10.4 (PHP 7.4.11 — cli) by Justin Hileman
>>> use App\Domain\Eloquent\User\QiitaSample as QiitaSampleModel;
>>> factory(QiitaSampleModel::class, 'qiita_sample_1')->make();
=> App\Domain\Eloquent\User\QiitaSample {#3615
     id: 10002,
     user_id: 1001,
     name: "太郎",
     value: "QiitaSample太郎",
   }
>>>
```

* _Factory_ に定義したデータの”name”を変更して_Eloquent Model_ で取得

```
>>> factory(QiitaSampleModel::class, 'qiita_sample_1')->make(['name'=>'QiitaMan']);
=> App\Domain\Eloquent\User\QiitaSample {#3738
     id: 10002,
     user_id: 1001,
     name: "QiitaMan",
     value: "QiitaSample太郎",
   }
>>>
```

* _Factory_ を使って複数件の _Eloquent Model_ で取得

```
>>> array_map(
...     fn ($fake) => factory(QiitaSampleModel::class, 'qiita_sample_1')->make($fake),
...     [
...         [
...             'id' => 1,
...             'user_id' => 1,
...             'name' => 'qiita_1',
...             'value' => 'qiita--',
...         ],
...         [
...             'id' => 2,
...             'user_id' => 2,
...             'name' => 'qiita_2',
...             'value' => 'qiita-----n',
...         ],
...     ]
... );
=> [
     App\Domain\Eloquent\User\QiitaSample {#3771
       id: 1,
       user_id: 1,
       name: "qiita_1",
       value: "qiita--",
     },
     App\Domain\Eloquent\User\QiitaSample {#3768
       id: 2,
       user_id: 2,
       name: "qiita_2",
       value: "qiita-----n",
     },
   ]
>>> 
```

## おわりに

_laravel_ でテスト駆動開発をするにあたり、開発メンバーと話し合いを行い、
テスト方針とテストコードの規約を決めていきました。
まだ、開発途中ということ（かつ _Laravel_ 初心者の私）もあり、拙い部分も多いとは思いますが、
少しでも参考になれば幸いです。