はじめに
こんにちは、株式会社レコチョクの早瀬です。 普段はmurketというECソリューションの開発をしています。 CakePHP 3.xのサポートが終わりCakePHP 4.xが主流になりましたが、CakePHP 4.3を機にテストコード周りに一部変更が入りました。 中でも公式が推奨しているFixture Factoriesに興味を持ったため、テストコードの変更点とともに調査してみました。
Fixtureの役割
CakePHP 4.3からDBのスキーマ管理とテストデータの管理の責務が分割されました。
以前はFixtureには “どういうカラムを持っているのか”を管理する
$fieldと、”どういう値をテストデータとして登録しておくか”という$recordの2種類の情報を管理していました。
そのため、Fixtureはテーブル作成とデータ追加をテスト実行時に行っていましたが、最新版のCakePHPではテーブル作成はMigrationsに、データ追加はFixtureに役割が分割されました。
// CakePHP 4.3以前
class BoardsFixture extends TestFixture
{
public $fields = [
'board_id' => ['type' => 'integer', 'length' => null, 'unsigned' => true, 'null' => false, 'default' => null, 'comment' => 'ボードID', 'autoIncrement' => true, 'precision' => null],
'name' => ['type' => 'string', 'length' => 300, 'null' => false, 'default' => null, 'collate' => 'utf8mb4_bin', 'comment' => 'エリア名', 'precision' => null],
'created_datetime' => ['type' => 'datetime', 'length' => null, 'precision' => null, 'null' => true, 'default' => null, 'comment' => '作成日時'],
'updated_datetime' => ['type' => 'datetime', 'length' => null, 'precision' => null, 'null' => true, 'default' => null, 'comment' => '更新日時'],
'delete_datetime' => ['type' => 'datetime', 'length' => null, 'precision' => null, 'null' => false, 'default' => '1000-01-01 00:00:00', 'comment' => '削除日時'],
'_constraints' => [
'primary' => ['type' => 'primary', 'columns' => ['board_id'], 'length' => []],
],
'_options' => [
'engine' => 'InnoDB',
'collation' => 'utf8_general_ci'
],
];
public function init(): void
{
$this->records = [
[
'board_id' => 1,
'name' => 'Lorem ipsum dolor sit amet',
'created_datetime' => '2023-05-16 02:37:35',
'updated_datetime' => '2023-05-16 02:37:35',
'delete_datetime' => '2023-05-16 02:37:35',
],
];
parent::init();
}
}
// CakePHP 4.3以降
class BoardsFixture extends TestFixture
{
public $records = [
[
'board_id' => 1,
'name' => 'Lorem ipsum dolor sit amet',
'created_datetime' => '2023-05-16 02:37:35',
'updated_datetime' => '2023-05-16 02:37:35',
'delete_datetime' => '2023-05-16 02:37:35',
],
];
}
現状のFixtureの問題点
サービスが大規模になってくるとテストしなければいけない項目が多くなり、テストケースが増えていきます。 それに伴い、各テストケースで必要なテストデータを追加する必要があるため、Fixtureに登録されるテストデータは肥大化していきます。 CakePHPでは、テストケース実行前にテストデータをテスト用DBにINSERTし、テスト実行完了後にデータをTRUNCATEする仕組みになっています。 そのため、テストケースが増加する、もしくはテストデータが増加するとDBへのI/Oが増加し、テストの実行時間が長くなります。
CakePHPでは読み込むFixture情報をテストファイルのメンバ変数で管理しており、これらはテーブル単位で指定されます。
<?php
/* @see https://book.cakephp.org/4/ja/development/testing.html#id29 */
namespace App\Test\TestCase\Model\Table;
use App\Model\Table\ArticlesTable;
use Cake\TestSuite\TestCase;
class ArticlesTableTest extends TestCase
{
protected $fixtures = ['app.Articles']; // ファイル内で使用するテーブル名を記載
...
100件のテストケースが書かれているテストファイルがあり、うち1件だけは特定のテーブル(
TableA)を確認しなければならない場合を想定します。
このような場合、
TableAは1箇所しか使用していないにも関わらず、INSERT/TRUNCATEがテストケース分(100回)発生してしまうという問題があります。
loadFixtures()を使ってこの問題を回避する方法もありますが、テストが頻繁に修正されるようなプロジェクトではどこまでのFixtureを一括で読み込み、どこからのFixtureをテストケース単位で読み込むのかという明確な基準を作るのも難しいと思われます。
特定の箇所でしか使用されていないFixtureを
loadFixtures()で読み込むと、そのFixtureが頻繁に呼び出されるようになった際に$fixturesに移動させる手間が発生します。
また、いままでは頻繁に使用されていたが今はたまにしか使用されていないFixtureを見分けるのは困難であるため、
loadFixtures()に切り替えるのは大変です。
そこで登場するのがFixture Factoriesです。
Fixture Factories とは
Fixture Factoriesは従来のFixtureのように事前にテストデータを登録しておくのではなく、テストケース内で必要なデータを作成することで、DBのI/Oを減らす手法です。 従来手法の問題点はテストケースごとに大量のデータをINSERT/TRUNCATEすることですが、その本質的な問題は特定のケースでしか使用しないテストデータが大量に生まれてしまうというところです。 Fixture Factoriesを用いることで、各テストケースで必要なテストデータをテストケース内で作成し、Fixtureに書かれるテストデータの量を減らすことが出来ます。
今回のFixture Factoriesの概要を説明するにあたって、TODOアプリの登録・削除APIを作成しました。 CakePHPのバージョンは4.4.13、PHPのバージョンは8.1.4です。 必要に応じて以下コードを参考にしてください。
https://github.com/d-hayase/todo
DB構成は以下のようになっています。

Fixture Factoriesを使用するためにはテーブル単位でFactoryをbakeします。
bin/cake bake fixture_factory [テーブル名]
bakeされたFactoryクラスには
getRootTableRegistryName()とsetDefaultTemplate()が存在しますが、各テストケースで使用したいデフォルト値が決まっているものについてはsetDefaultTemplate()に記載しておきます。
<?php
namespace App\Test\Factory;
use CakephpFixtureFactories\Factory\BaseFactory;
use Faker\Generator;
class BoardFactory extends BaseFactory
{
/**
* Defines the Table Registry used to generate entities with
* @return string
*/
protected function getRootTableRegistryName(): string
{
return "Boards"; // PascalCase of the factory's table.
}
/**
* Defines the default values of you factory. Useful for
* not nullable fields.
* Use the patchData method to set the field values.
* You may use methods of the factory here
* @return void
*/
protected function setDefaultTemplate(): void
{
$this->setDefaultData(function(Generator $faker) {
return [
// set the model's default values
// For example:
// 'name' => $faker->lastName
];
});
}
}
Fixture Factoriesを用いたEntity作成/保存
Factoryクラスを生成した後に、テストケースで以下のように記載することでEntityを生成できます。
$board = BoardFactory::make()->getEntity();
また、Entity生成時に値の変更や個数の変更も可能です。
$board = BoardFactory::make(['name' => 'Foo'])->getEntity(); // BoardEntityのnameをFooに設定
$boards = BoardFactory::make(2)->getEntities(); // BoardEntityを2つ生成
Entityの保存は以下のように記述できます。
$board = BoardFactory::make()->persist();
関連付けられたEntityを作成する
生成したEntityに紐付いたEntityを追加で作成する場合には
withを使用します。
withはテーブルのアソシエーションに基づいて関連したEntityを生成します。
例えばBoardEntityと紐付いたTodoEntityを作成したい場合、BoardFactoryに以下のように記述すると生成できます。
// in BoardFactory.php
public function withTodo($params = null, int $n = 1): self
{
return $this->with('Todo', TodoFactory::make($params, $n));
}
// fe. $board = BoardFactory::make()->withTodo($params, $n)->getEntity();
Entityの関連付けをメソッド化しておくことでおくことで、データ構造がわかりやすくなるというメリットもあります。 例えば、以下のように事前にTodoに対して優先度カテゴリを付与するメソッドを作っておき、withTodo()内で処理を出し分けることも可能です。
/**
* 例)
* $params = [
* 'Boards' => [
* 'params' => [
* 'name' => 'ボード1',
* ],
* ],
* 'Todo' => [
* 'association' => 'withHighPriorityTodo',
* 'params' => [
* 'content' => 'Todo1',
* ],
* ],
* ];
*/
/**
* 関連付けされたTodoを作成
*/
public function withTodo($params = null, $n = 1): self
{
// アソシエーションが指定されていれば、そちらを採用
if (isset($params['Todo']['association'])) {
$association = $params['Todo']['association'];
return $this->$association($params);
}
return $this->with('Todo', TodoFactory::make($params['Todo']['params'], $n)->withCategories($params));
}
/**
* 関連付けされた優先度高のTodoを作成
*/
private function withHighPriorityTodo($params = null): self
{
$param = $params['Todo']['params'];
$param['category_id'] = 1; // 優先度高
return $this->with('Todo', TodoFactory::make($param));
}
/**
* 関連付けされた優先度中のTodoを作成
*/
private function withMiddlePriorityTodo($params = null): self
{
$param = $params['Todo']['params'];
$param['category_id'] = 2; // 優先度中
return $this->with('Todo', TodoFactory::make($param));
}
/**
* 関連付けされた優先度低のTodoを作成
*/
private function withLowPriorityTodo($params = null): self
{
$param = $params['Todo']['params'];
$param['category_id'] = 3; // 優先度低
return $this->with('Todo', TodoFactory::make($param));
}
Fixture Factoriesを用いたテスト実装
以下のメソッドについてテストコードを記載します。
/**
* カテゴリの有無
*
* @param int $categoryId カテゴリID
* @return bool カテゴリの有無
*/
private function existCategory(int $categoryId): bool
{
$categoriesTable = TableRegistry::getTableLocator()->get('Categories');
return $categoriesTable->exists(['category_id' => $categoryId]);
}
Fixture Factoriesを用いることでテストケース内で簡単にEntityの生成・保存ができるようになります。
/**
* Test existCategory method
*
* @return void
*/
public function testExistCategoryExisted(): void
{
$category = CategoryFactory::make(['text' => 'テストカテゴリ'])->persist();
$this->assertTrue($this->Todo->existCategory($category->category_id));
}
Fixture FactoriesとFixtureの使い分け
Fixture Factoriesを使ってテストケース内でテストデータを作成できました。 しかし、複数のテストケースをまたいで使用するテストデータを毎回生成するのは非効率です。 そのため、マスタデータのようなデータ更新が少ないデータや複数箇所にまたがって使用されるテストデータはFixtureに記載、各テストケースで使い捨てになる場合はFixture Factoriesで生成するといった方法を取るとDBのI/Oを減らせて良いと思います。
最後に
Fixtureファイルを小さくできるFixture FactoriesはFabricateのようなテスト高速化ツールとも相性が良いです。 テストを作成する前にFactoryを作成しなければならないという手間はありますが、DB構成が複雑な場合は事前にFixtureを作るロジックを組んでおけばテストデータ作成の手間が軽減されるので有用かと思われます。 できれば1行で欲しいデータがDBに登録されるとありがたいので、Fixture作成をどうやってラッピングしていくかが今後の課題です。
参考
早瀬 大智