CakePHP4 で Fabricate を使ったテストを書く

[資産価値の高いテストを書くためにFabricateを使い始めました](https://devblog.thebase.in/entry/2019/07/25/114556)

この記事はとても共感出来る記事です。
開発作業の最初や途中で UnitTest を入れるぞっと盛り上がったときは、テストを書くのですが、後ほど負担になって UnitTest を実行しなくなるっというケースが多々あります。
（特にフェーズ1リリースが終わって、次の開発まで期間があく案件のケースで多発します。。。）

今後、良いテストを書くために CakePHP4 で Fabricate を導入する手順を残します。


# この記事でわかること

- CakePHP4 へ Fabricate を導入する手順。
- この記事内のソースは以下で公開しています。
    - https://github.com/katsuhiko/cakephp-vue-study


# 事前準備

- CakePHP4 プロジェクトは以下の記事で作成して、Docker で動かしつつ、最低限の動作をできるようにしています。
    - [CakePHP4 を docker-compose で動くようにする](https://qiita.com/katsuhiko/items/f18c488457af33abdf66)
    - [CakePHP4 で phpstan を使えるようにする](https://qiita.com/katsuhiko/items/d58a9d8dbfbe3b12f108)
    - [CakePHP4 で Session 情報を DB へ可能するようにする](https://qiita.com/katsuhiko/items/b61383a21c2ebc2027b4)
    - [CakePHP4 で簡単な API を作る](https://qiita.com/katsuhiko/items/5fd845351b011fb70eaf)
- docker-compose を実行し、コンテナを立ち上げます。

```sh
docker-compose up -d
```

|  | バージョン |
|:-----------------|:------------------|
| docker           | Docker version 19.03.8, build afacb8b |
| docker-compose   | docker-compose version 1.25.4, build 8d51620a |
| CakePHP4         | 4.0.4 |


# Fabricate の導入

CakePHP4 用の Fabricate Adaptor が用意されているのでそれを利用します。

- https://github.com/sizuhiko/cakephp-fabricate-adaptor/tree/cakephp4

開発時のみ利用するため `--dev` オプションを付けています。

```
docker exec -it app php composer.phar require --dev sizuhiko/cake_fabricate:dev-cakephp4
```


# Fabricate を読み込む

`./tests/bootstrap.php` で Fabricate を読み込みます。
`./tests/bootstrap.php` の一番最後に付き足せばOKです。

```php:tests/bootstrap.php
// ...
use Fabricate\Fabricate;
// ...

// Add Fabricate
Fabricate::config(function ($config) {
    $config->adaptor = new CakeFabricateAdaptor([
        CakeFabricateAdaptor::OPTION_FILTER_KEY => true,
        CakeFabricateAdaptor::OPTION_VALIDATE => false,
    ]);
});
```

主キーは UUID にして自動インクリメントは使わないので、 `OPTION_FILTER_KEY` へ `true` をセットしています。


# Fixture の作成

`./tests/Fixture/TasksFixture.php` を以下のように変えます。

```php:tests/Fixture/TasksFixture.php
<?php
declare(strict_types=1);

namespace App\Test\Fixture;

use Cake\Chronos\Chronos;
use Cake\Datasource\ConnectionInterface;
use Cake\TestSuite\Fixture\TestFixture;
use Fabricate\Fabricate;

/**
 * TasksFixture
 */
class TasksFixture extends TestFixture
{
    /**
     * @var array
     */
    public $import = ['model' => 'Tasks'];

    /**
     * Init method
     *
     * @return void
     */
    public function init(): void
    {
        parent::init();
    }

    public function create(ConnectionInterface $db): bool
    {
        $result = parent::create($db);

        Fabricate::define('Tasks', function ($data, $world) {
            $now = Chronos::now();

            return [
                'id' => $world->faker()->uuid,
                'description' => $world->faker()->text,
                'created' => $now->toIso8601String(),
                'modified' => $now->toIso8601String(),
            ];
        });

        return $result;
    }
}
```

`$import` を使ってテーブル構成を読み込むようにします。
`init`メソッド内で `Fabricate::define` を行った場合、以下のエラーが発生しました。 `init`メソッドはテーブルを作成していないですが、`Fabricate::define` したタイミングでテーブルのスキーマ情報を取ろうとして起きているエラーのようです。
少々、効率は悪くなる気がしますが、テーブルが作成される `create`メソッドで`Fabricate::define` を行うようにしました。

```
Exception: SQLSTATE[42S02]: Base table or view not found: 1146 Table 'app_test.tasks' doesn't exist
```


# Fabricate を使ったテスト

`./tests/TestCase/Model/Table/TasksTableTest.php` で Fabricate を使った場合と使わない場合のテストケースでテストデータを作るケースです。

今回はカラムが少ないのでそこまで大きな違いはないですが、カラムが増えれば増えるほど効果が出てくると思います。

```php:tests/TestCase/Model/Table/TasksTableTest.php
<?php
declare(strict_types=1);

namespace App\Test\TestCase\Model\Table;

use App\Model\Table\TasksTable;
use Cake\ORM\TableRegistry;
use Cake\TestSuite\TestCase;
use Fabricate\Fabricate;

/**
 * App\Model\Table\TasksTable Test Case
 */
class TasksTableTest extends TestCase
{
    /**
     * Test subject
     *
     * @var \App\Model\Table\TasksTable
     */
    protected $Tasks;

    /**
     * Fixtures
     *
     * @var array
     */
    protected $fixtures = [
        'app.Tasks',
    ];

    /**
     * setUp method
     *
     * @return void
     */
    public function setUp(): void
    {
        parent::setUp();
        $config = TableRegistry::getTableLocator()->exists('Tasks') ? [] : ['className' => TasksTable::class];
        $this->Tasks = TableRegistry::getTableLocator()->get('Tasks', $config);
    }

    /**
     * tearDown method
     *
     * @return void
     */
    public function tearDown(): void
    {
        unset($this->Tasks);
        parent::tearDown();
    }

    /**
     * @return void
     */
    public function test_Fabricateを使わずにレコードを作成する(): void
    {
        // Arrange
        for ($i = 0; $i < 3; $i++) {
            $task = $this->Tasks->newEntity(['description' => 'unittest']);
            $this->Tasks->save($task);
        }

        // Act
        $tasks = $this->Tasks->find()->all();

        // Assert
        $this->assertSame(3, count($tasks));
    }

    /**
     * @return void
     */
    public function test_Fabricateでレコードを作成する(): void
    {
        // Arrange
        Fabricate::create('Tasks', 3);

        // Act
        $tasks = $this->Tasks->find()->all();

        // Assert
        $this->assertSame(3, count($tasks));
    }
}
```

Controller のテストケースも載せます。雑なテストです。（テストファーストで作ってないので、余計に雑になっています）
でも、動かすだけでわかる不具合もあるので、雑でも良い点はあると思います。

```php:tests/TestCase/Controller/Api/TaskControllerTest.php
<?php
declare(strict_types=1);

namespace App\Test\TestCase\Controller\Api;

use Cake\TestSuite\IntegrationTestTrait;
use Cake\TestSuite\TestCase;
use Fabricate\Fabricate;

/**
 * App\Controller\Api\TaskController Test Case
 *
 * @uses \App\Controller\Api\TaskController
 */
class TaskControllerTest extends TestCase
{
    use IntegrationTestTrait;

    /**
     * Fixtures
     *
     * @var array
     */
    protected $fixtures = [
        'app.Tasks',
    ];

    /**
     * setUp method
     *
     * @return void
     */
    public function setUp(): void
    {
        $this->configRequest([
            'headers' => [
                'Accept' => 'application/json',
            ],
        ]);
    }

    /**
     * Test search method
     *
     * @return void
     */
    public function test_全件取得できること(): void
    {
        // Arrange
        Fabricate::create('Tasks', 3);

        // Act
        $this->get('/api/task/search.json');

        // Assert
        $this->assertResponseOk();
        $actual = json_decode((string)$this->_response->getBody(), true);
        $this->assertSame(3, count($actual['tasks']));
    }

    /**
     * Test search method
     *
     * @return void
     */
    public function test_タスク内容で絞って取得できること(): void
    {
        // Arrange
        Fabricate::create('Tasks', 2, ['description' => '検索キーワードあり']);
        Fabricate::create('Tasks', 1, ['description' => '何もなし']);

        // Act
        $this->get('/api/task/search.json?description_like=キーワード');

        // Assert
        $this->assertResponseOk();
        $actual = json_decode((string)$this->_response->getBody(), true);
        $this->assertSame(2, count($actual['tasks']));
    }

    /**
     * Test detail method
     *
     * @return void
     */
    public function test_該当1件を取得できること(): void
    {
        // Arrange
        $tasks = Fabricate::create('Tasks');

        // Act
        $this->get("/api/task/detail/{$tasks[0]->id}.json");

        // Assert
        $this->assertResponseOk();
        $actual = json_decode((string)$this->_response->getBody(), true);
        $this->assertSame($tasks[0]->id, $actual['task']['id']);
    }

    /**
     * Test create method
     *
     * @return void
     */
    public function test_登録できること(): void
    {
        // Arrange
        $data = ['description' => 'created'];

        // Act
        $this->post('/api/task/create.json', $data);

        // Assert
        $this->assertResponseOk();
        $actual = json_decode((string)$this->_response->getBody(), true);
        $this->assertNotNull($actual['task']['id']);
    }

    /**
     * Test update method
     *
     * @return void
     */
    public function test_更新できること(): void
    {
        // Arrange
        $tasks = Fabricate::create('Tasks', ['description' => 'created']);
        $data = ['description' => 'updated'];

        // Act
        $this->put("/api/task/update/{$tasks[0]->id}.json", $data);

        // Assert
        $this->assertResponseOk();
        $actual = json_decode((string)$this->_response->getBody(), true);
        $this->assertSame($tasks[0]->id, $actual['task']['id']);
    }

    /**
     * Test delete method
     *
     * @return void
     */
    public function test_削除できること(): void
    {
        // Arrange
        $tasks = Fabricate::create('Tasks');

        // Act
        $this->delete("/api/task/delete/{$tasks[0]->id}.json");

        // Assert
        $this->assertResponseOk();
    }
}
```


# Fabricate があっても DB を使ったテストは難しい

Fabricate があると DB をつかったテストが書きやすくなり、管理できる状態に持っていけます。
それでも、やっぱり DB を使ったテストを継続的に実施することは、テストの実行が長くなる（遅くなる）ため、難しいです。

DB を使ったテストケースには `@group slow` アノテーションをつけて、ローカルで実施するときは `--exclude-group=slow` で除外し、速さを重視すべきだと思います。
フルテストの実行は CI に任せる方針です。

- https://phpunit.readthedocs.io/ja/latest/annotations.html#appendixes-annotations-group

Mock(テストダブル) も使いすぎの課題はあるかもしれませんが、継続的にテストを実施するためには速さはすごく大切だと思うので、効果的に使っていきたいです。