###本記事について
最近、DBの値によって挙動が変わるメゾットの単体テストで、パターン網羅を自動でやりたかったのでユニットテストを実装してみました。
その時手こずったのでメモがてらに学んだ点をこちらにまとめています。また、日英どちらで検索してもValidation等やデータ保存など、簡単なテストばかりであまりCakePHP4のテストケースに深入り記事が見つからなかったため、誰かの役に立てればと思い記事にしました。ただ、自分もまだわからない部分が多いのであくまで参考程度で。
本職のコードを見せるわけにはいかないので、こちらで紹介するコードは場合に応じて名前を変えたりしているので実際に動かしたわけではありませんのでご注意ください。
###UnitTestの概要
CakePHP4公式サイトにあるように、CakePHPではPHPUnitを導入している。でFixtureやTestクラスを実装して、それを実行したらテストが行えるらしい。と、初心者がみてもあまり想像ができないです。
そもそも、FixtureやTestクラスを理解するより最初にUnitTestとはどんなことをするのかをイメージするととっつきやすいです。以下、具体例を踏まえてテストをどうするか考えましょう。。
例:航空券の予約システムを例に取り上げます。この予約システムでは、ユーザーがユーザーテーブルにポイントを持ち、予約申請が完了すると100ポイント付与されるとしましょう。このシステムでは予約処理とポイント付与のロジックを予約テーブル(ReservationsTable)のEntityに持っているとします。以下、そのテーブル構造とコードを示します。
DB - ユーザーテーブル(myapp.users)
id | point |
---|---|
1 | 0 |
予約が完了するとpointが100加算されます。
DB - 予約テーブル(myapp.reservations)
id | user_id | status |
---|---|---|
1 | 1 | 0 |
予約完了になるとstatusが0から1に変わるとします。
CakePHP - 予約テーブル(app/src/Model/Table/ReservationsTable.php
)
<?php
declare(strict_types=1);
namespace App\Model\Table;
class ReservationsTable extends AppTable
{
public function initialize(array $config): void
{
parent::initialize($config);
$this->setTable('reservations');
$this->setPrimaryKey('id');
//Define Association
$this->belongsTo('Users', [
'foreignKey' => 'user_id',
'joinType' => 'INNER',
]);
}
//validation等は省略。
//コントローラーからSubmitされたフォーム情報を引数として渡され、予約処理する。
public function reservationProcess($data)
{
//Save data in one transaction
$result = $this->Users->getConnection()->transactional(function($conn) {
//Save reservation
$reservation = $this->Reservations->patchEntity($reservation, $data);
$this->Reservations->save($reservation)
//Save user
$usersTable = TableRegistry::getTableLocator()->get('Users');
$user = $articlesTable->get($data['user_id']);
$user->point += 100;
$usersTable->save($user);
}
if ($result) {
return [
'status' => true,
'errors' => [],
];
}
return [
'status' => false,
'errors' => [__('エラーにより保存できませんでした。')],
];
}
こんな感じなメゾットとテーブルがあると想像してください。メゾットは結構適当に書いているので実際に動かすと動かないかもです。
本題のテストについて話していきます。上のメゾットを普通に手動で単体テストするにはどうしますか?テスターの経験があれば、以下の工程が一般的だと思います。
ステップ | 説明 |
---|---|
1. 該当テーブルにデータを挿入 | まず、ユーザーがいないと始まらないので、ユーザーテーブルにレコードを入れます。この時、SQLを使えるテスターならローカルDBにレコードを直接挿入する場合もあるし、Signup機能などがアプリで提供されていれば、画面からユーザーレコードを作成する場合もあるかと思います。 |
2. 予約フォーム入力 | 1で挿入されたユーザーで予約フォームを提出できるページにいき、予約情報を入力してフォームを入力します。 |
3. 予約フォーム提出 | 入力が終わったら提出ボタンを押してフォームをCakePHPの該当コントローラーへ投げます。 |
4. メゾット作動 | コントローラーから今回作成したReservationsTable::reservationProcessを呼び出します。 |
5. テーブル更新確認 | メゾットで更新されたテーブル内容を確認します。 |
6. テーブル初期化 | 後続の単体テストで影響が出ないよう、汚したテーブルを元通りにします。 |
...というようになると思います。
この工程を自動化するのが、UnitTestなのです。
上記ステップを一つずつ振り返り、どうしたらそれがFixture、Testクラスで補えるのかを説明していきます。
1. 該当テーブルにデータを挿入
ここで登場するのが、Fixtureクラスです。Fixtureクラスでは、テストに使用するデータを定義します。Testクラスで起動時にこのFixtureを呼び出すと、定義内容に従い、テストDBにテーブル作成、またそのデータが投入されます。
また、このFixtureクラスはBakeすることができるので、Bakeされたものに追加したいレコード情報を書き込むと作業が早いです。
今回はテスト用にユーザーデータが必要なので、以下のコマンドを実行し、Fixtureを定義します。
$ cake bake fixture Users
<?php
declare(strict_types=1);
namespace App\Test\Fixture;
use Cake\TestSuite\Fixture\TestFixture;
/**
* UsersFixture
*/
class UsersFixture extends TestFixture
{
/**
* Import
*
* @var array
*/
public $import = ['table' => 'users'];
/**
* Init method
*
* @return void
*/
public function init(): void
{
$this->records = [
[
'id' => 1,
'point' => 0,
],
parent::init();
}
}
同様に、ReservationsFixtureも作成してください。今回のテストではReservationsにレコードがないところから始めるので、ReservationsFixtureではrecordsは空のままで大丈夫です。
次にこのFixtureをTestクラスで呼び込む実装をします。TestクラスもBakeすることができます。今回、ReservationsTableで定義したメゾットをテストしたいため、以下のようなコマンドを実行し、Testクラスを定義します。
$ cake bake test Table ReservationsTableTest
<?php
declare(strict_types=1);
namespace App\Test\TestCase\Model\Table;
use App\Model\Table\ReservationsTable;
use Cake\ORM\TableRegistry;
use Cake\TestSuite\TestCase;
/**
* App\Model\Table\ReservationsTable Test Case
*/
class ReservationsTableTest extends TestCase
{
/**
* Test subject
*
* @var \App\Model\Table\ReservationsTable
*/
protected $Reservations;
/**
* Fixtures
*
* @var array
*/
protected $fixtures = [
'app.Reservations',
];
/**
* setUp method
*
* @return void
*/
public function setUp(): void
{
parent::setUp();
$config = TableRegistry::getTableLocator()->exists('Reservations') ? [] : ['className' => ReservationsTable::class];
$this->Tokens = TableRegistry::getTableLocator()->get('Tokens', $config);
}
/**
* tearDown method
*
* @return void
*/
public function tearDown(): void
{
unset($this->Reservations);
parent::tearDown();
}
public function testReservationProcess(): void
{
$this->markTestIncomplete('Not implemented yet.');
}
}
このテストケースを実行するには、app
配下で以下のコマンドを実行します。
$ vendor/bin/phpunit tests/TestCase/Model/Table/ReservationsTable
実際に実行しても、このままではユーザーデータは挿入されませんし、ユーザーテーブルがTest DBにできることもありません。テーブル作成、Fixtureのデータ挿入を行うには、以下のように$fictures
でUsersFixtureを指定します。
protected $fixtures = [
'app.Reservations',
'app.Users',
];
テストを実行すると、Testクラスで定義したtest...
で始まるメゾットが実行されるのですが、各メゾットの前で、setUp()
が呼び込まれ、指定されたFixtureに元づいて必要なテーブル作成、データ挿入が行われます。このデータの用意はsetUp()
内のparent::setUp()
で行われるため、この部分は必ず消さないでください。
public function setUp(): void
{
parent::setUp();
$config = TableRegistry::getTableLocator()->exists('Reservations') ? [] : ['className' => TokensTable::class];
$this->Tokens = TableRegistry::getTableLocator()->get('Reservations', $config);
}
実際にテストDBにデータが入っているのを確認するため、parent::setUp()
の後にdd()
を書いて、処理を途中で止めてみるとよりわかりやすいです。
public function setUp(): void
{
parent::setUp();
dd('処理を中断');
$config = TableRegistry::getTableLocator()->exists('Reservations') ? [] : ['className' => TokensTable::class];
$this->Tokens = TableRegistry::getTableLocator()->get('Reservations', $config);
}
テスト実行後、DBをみると以下のようにテストDBにテーブルが残っているのが確認できます。dd()
を入れないと後に説明しますが、tearDown()
でテーブル削除が行われます。
というわけで、Fixtureクラスの定義とTableクラスのsetUp()
と$fixtures
の定義がちゃんとできていれば、$ vendor/bin/phpunit tests/TestCase/Model/Table/ReservationsTable
を実行することでステップ1ができていることになります。
2. 予約フォーム入力 & 3. 予約フォーム提出
つづいて、手動でやるテストではブラウザを開いて予約ページでフォームを入力して提出、という作業があると思います。Testクラスではこのフォーム提出でどんな値がくるかを、Testメゾット内で指定します。
public function testReservationProcess(): void
{
//Set form data
$data = ['id' => 1, 'user_id' => 1, 'status' => 1,]
}
4. メゾット作動
コントローラーでは提出されたDataをTableメゾットに投げます。なので、Testクラスでは前ステップで定義したDataをTableメゾットの引数に設定します。
public function testReservationProcess(): void
{
//Set form data
$data = ['id' => 1, 'user_id' => 1, 'status' => 1,]
//Set data to reservation process method
$result = $this->ReservationsTable->reservationProcess($data)
}
5. テーブル更新確認
手動の場合、更新されたテーブル内容はDBで確認するかと思います。Testクラスでは、これが(基本的には)$this->assertSame($exptected, $actual)
の比較で行われます。今回のチェックポイントは、ユーザーテーブルのポイントが100になっているか、予約テーブルのステータスが1になっているか、またメゾットから帰ってくる結果がTrueか、の3点です。以下、書いていきます。
public function testReservationProcess(): void
{
//Set form data
$data = ['id' => 1, 'user_id' => 1, 'status' => 1,]
//Set data to reservation process method
$result = $this->ReservationsTable->reservationProcess($data)
//Fetch data after the table update
$entity = $this->Reservations->find()
->contain([
'Users',
])
->first();
//Was the point given?
$this->assertSame(100, $entity->user->point);
//Was the reservation status updated?
$this->assertSame(1, $entity->status);
//Was the method successful?
$this->assertSame(true, $result['status']);
}
実装が終わったらテスト結果を実行して、ちゃんとエラーがでないか確認してください。
6. テーブル初期化
最後に、後続のテストに影響を及ばさないよう汚したデータを元に戻します。上記でもありましたが、TestクラスではtearDown()
がその役割を果たします。これにより、テストDBにあるテーブルが消されます。だからテストを実行してもテストDBは一見何も変わっていないように見えるというカラクリです。
public function tearDown(): void
{
unset($this->Reservations);
parent::tearDown();
}
###最後に
以上が、CakePHP4で独自メゾットのユニットテストを作成する方法でした。FixtureとTestの考えはPHPだけじゃなくPythonや他言語のフレームワークでも使われると思うので、知っていて損はないと思います。あまり、このユニットテストとは何か?という初歩的なところを説明してくれる記事がないと思いますので、こちら参考になれば幸いです。