オンライン家庭教師マナリンク CTOの名人です。
今日はマナリンクのバックエンドを支えているLaravelでテストコードを書く際に利用している、PHPUnitのちょっとしたTipsをまとめました。
JSON型のカラムに対してassertDatabaseHasしたい
そもそもJSON型のカラムはほとんどのケースで使わないのですが、稀に使った際にテストコードの書き方に迷います。
書き方としてはカラム名を'->'で繋げることがポイントです。
雑な例ですが、支払い履歴テーブルがあるとして、そこにpayment_resultといった名前で決済代行サービスへのリクエスト結果をJSONで格納しているとします。その場合正しくJSON内のcharge_idが保存されていることのテストは以下のように書きます。
$this->assertDatabaseHas((new ChargeHistory())->getTable(), [
'payment_result->charge_id' => 'charge_xxxx',
]);
もちろんこれの前には、決済代行サービスへのリクエストをモックして、andReturn()で'charge_xxxx'が返るように設定しておく必要があります。
外部サービスがエラーを返したときのハンドリングをテストしたい
先程の例にもつながる話ですが、決済代行サービスなどの外部サービスを使う際には失敗時のハンドリングもテストできたほうが安心です。
以下のように、サービスコンテナのinstanceメソッドを使ってモック化します。もちろん、決済関連の処理はPaymentAdaptorInterfaceといった形式で切り出されていることが前提です。
$this->app->instance(
PaymentAdaptorInterface::class,
($mock = \Mockery::mock(PaymentAdaptorInterface::class))
);
$mock->shouldReceive('create')->andThrow(new \Exception('Mockで発生させたエラー'));
この状態でテストしたい処理を実行すれば、途中でExceptionを投げてくれるため、エラー時にどのような挙動になるかをテストできます。
個人的な話ですが、外部サービスへのアクセスに対するエラーを100%のカバレッジでテストできているわけではない(計測しようと思ったこともないのですが...多分30%も無いかもしれない)ので、よりによってそこが落ちるのか、という障害に繋がりがちです。地道にテストケースを増やしていこうと思います。
ちなみに例外を投げるモック化の仕方としては、以下のように例外しか投げない実装クラスをbindする手法も有り、どちらも一長一短があるでしょう。
$this->app
->when(SubscriptionRenewedUseCase::class)
->needs(PaymentAdaptorInterface::class)
->give(ExceptionPaymentAdaptor::class);
SubscriptionRenewedUseCaseでのみ例外が発生するようになった状態でテストが実行できます。
テスト用のデータベースを毎回"速く"Refreshしたい
テストコードをローカルで実行する際、データベースを普段使っているコンテナのDBに向けていては以前実行したデータが邪魔をしてしまいます。
こちらのページ を参考に、以下のように実装します。
<?php
declare(strict_types=1);
namespace Tests\Lib;
use App\Console\Kernel;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\RefreshDatabaseState;
/**
* Trait RefreshDatabaseLite
* @see https://wand-ta.hatenablog.com/entry/2019/12/15/185625
* @package Tests\Lib
*/
trait RefreshDatabaseLite
{
use RefreshDatabase;
/**
* Refresh a conventional test database.
*
* @return void
*/
protected function refreshTestDatabase()
{
if (! RefreshDatabaseState::$migrated) {
$this->artisan('migrate');
$this->app[Kernel::class]->setArtisan(null);
RefreshDatabaseState::$migrated = true;
}
$this->beginDatabaseTransaction();
}
}
<?php
namespace Tests\Lib;
use Faker\Factory;
use Faker\Generator;
use Tests\TestCase;
/**
* Class BaseTestCase
* @package Tests\Lib
*/
abstract class BaseTestCase extends TestCase
{
use RefreshDatabaseLite;
/**
* フェイクデータを生成してくれる
*
* @var Generator
*/
protected $faker;
protected function setUp(): void
{
parent::setUp();
$this->faker = Factory::create();
}
}
RefreshDatabaseLiteトレイトを利用します。
標準のRefreshDatabaseはmigrate:freshで全テーブルを丸ごとDropするためかなり遅くなっています。Lite版はmigrateのみを実行するため高速というからくりになっています。
テスト用のデータベースとローカル開発用のデータベースを分けたい
前述の毎回テストごとにRefreshする工夫をしている場合、ローカル開発用のデータベースとテスト用のデータベースは切り分けたいところです。
ローカル開発をDockerで行っている前提で解説します。
phpunit.xml
<env name="DB_DATABASE" value="online_teacher_testing" />
docker-compose.yml
mysqlコンテナの設定に、volumeを追記します。
# テスト用のデータベースの作成も実行している
- ./docker/mysql/init:/docker-entrypoint-initdb.d
docker/mysql/init/1_ddl.sql
CREATE DATABASE IF NOT EXISTS `online_teacher_testing`;
GRANT ALL ON `online_teacher_testing`.* TO 'user'@'%';
DockerのMySQLコンテナは、docker-entrypoint-initdb.dディレクトリ以下に配置されたDDLを実行してくれる仕様なので、そこに別の名前のDBを立てます。弊社の場合はオンライン家庭教師なので上記のような命名になっています。
PHPUnit実行時にそちらのDBを向くように設定したら完了です。
落とし穴として、GitHub Actionsでテストを実行している場合はそちらの設定も変更しなければならないです。
多くのデータで同じテストを実行したい
データプロバイダーを使います。
マナリンクでは学年表記を短縮して表記することが有り、そこの表示パターンのテストに利用しています。
/**
* @dataProvider dp__grade_patterns
* @param array $gradeNames
* @param string $expectText
*/
public function testFormatGradesToTextTest(array $gradeNames, string $expectText): void
{
// 略
}
/**
* @return array[]
*/
public function dp__grade_patterns()
{
return [
[
['小学2年生'],
'小学2年生',
],
[
['中学1年生'],
'中学1年生',
],
[
['高校3年生'],
'高校3年生',
],
[
['中学浪人'],
'中学浪人',
],
[
['高校浪人'],
'高校浪人',
],
[
['その他'],
'その他',
],
[
['小学1年生', '小学2年生', '小学3年生', '小学4年生', '小学5年生', '小学6年生'],
'小学1〜6年生',
],
[
['中学1年生', '中学2年生', '中学3年生'],
'中学1〜3年生',
],
[
['高校1年生', '高校2年生', '高校3年生'],
'高校1〜3年生',
],
[
['小学1年生', '小学3年生', '小学4年生'],
'小学1・3・4年生',
],
[
['中学1年生', '中学2年生'],
'中学1・2年生',
],
[
['高校1年生', '高校3年生'],
'高校1・3年生',
],
[
['中学浪人', '高校浪人', 'その他'],
'中学浪人、高校浪人、その他',
],
[
['小学1年生', '小学2年生', '小学5年生', '小学6年生', '中学1年生', '中学2年生', '中学3年生', '高校1年生', '高校2年生', '高校3年生'],
'小学1・2・5・6年生、中学1〜3年生、高校1〜3年生',
],
[
['小学1年生', '小学2年生', '小学4年生', '小学5年生', '小学6年生', '中学1年生', '中学2年生', '中学3年生', '高校1年生', '高校2年生', '高校浪人'],
'小学1・2、4〜6年生、中学1〜3年生、高校1・2年生、高校浪人',
],
[
['小学1年生', '小学2年生', '小学3年生', '小学5年生', '小学6年生', '中学1年生', '中学3年生', '中学浪人', '高校1年生', '高校2年生', '高校3年生', 'その他'],
'小学1〜3、5・6年生、中学1・3年生、中学浪人、高校1〜3年生、その他',
],
];
}
複数の学年が紐付いているデータが、出力結果のように短縮されていることが分かると思います。こういった、多くのパターンでテストしなければならない場合にデータプロバイダーは有効です。
データプロバイダーの注意点として、プロバイダ側の関数内ではサービスコンテナを使った処理などが書けなかったはずですので、テスト対象のクラスのインスタンスをサービスコンテナから引っ張ってきたいときなどはsetup関数内で実行してください。
以上です!引き続きエンジニアの募集をしておりますので、ぜひご応募ください。