Mockeryのモックを利用してリトライ処理の検証を行う〜なんと素晴らしきかなテストコード〜
こんにちは。
最近新居に引っ越してウハウハな反面、暑さと湿気とベタベタになるのがイヤイヤなかっちゃんです。
今回もテストコードについて書いていきたいと思います。
以下の環境でテストを行う事が前提条件です
- PHP 7.4.20
Laravel Framework 6.18.10
今回テストコードを書いていきたい状況は以下の様な状況だと仮定します。
新規で商品情報を登録する
前提条件としては以下とします。
商品コードはランダムで生成される
商品コードのDB定義はuniqueとする
商品登録に失敗した際は3回までやり直す
そしてテストコードを書きたい部分は
商品登録に失敗した際は3回までやり直す
この部分についてテストコードを書いていきたいと思います。
マイグレーション作成
まずはマイグレーションから書いていきましょう。
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateTestProductsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create(
'test_products',
function (Blueprint $table) {
$table->bigIncrements('id');
$table->string('code', 20)->unique();
$table->timestamps();
}
);
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('test_products');
}
}
今回は簡単にcodeの情報を持つ test_products
のテーブルを作成します。
もちろん code は unique です。
モデル作成
次にモデルを作成します。
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
/**
* @property int $id ID
* @property string $code 商品コード
* @property Carbon|null $created_at 作成日時
* @property Carbon|null $updated_at 更新日時
*/
class TestProduct extends Model
{
/**
* @var array
*/
protected $fillable = [
'code'
];
}
サービス作成
今回テストコードを書きたい商品作成のロジックをサービスクラスに書いていきたいと思います。
<?php
namespace App\Services;
use App\Models\TestProduct;
use Illuminate\Support\Str;
class TestProductStoreService
{
/**
* @return void
* @throws \Exception
*/
public function store(): int
{
$count = 0;
retry(
3,
function () use (&$count) {
$count++;
$product = new TestProduct();
$product->code = Str::random(20);
$product->save();
}
);
return $count;
}
}
今回は分かりやすくする為に、tryの中の実装が何回繰り返し行われているか $count を仕込んで返すようにしてみました。 何事もなく正常に終われば $count は1で返ってくるはずですね。
テストコード作成
今回肝となるテストコードの作成です。
上記で作成した TestProductStoreServiceクラス の storeメソッドが思惑通りに動作しているか確認して見ましょう。
<?php
namespace Tests\Feature;
use App\Models\TestProduct;
use App\Services\TestProductStoreService;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Mockery;
use Tests\TestCase;
class TestProductStoreTest extends TestCase
{
use DatabaseTransactions;
/**
* @var TestProductStoreService
*/
private TestProductStoreService $storeService;
/**
* @return void
* @throws BindingResolutionException
*/
public function setUp(): void
{
parent::setUp();
$this->storeService = $this->app->make(TestProductStoreService::class);
}
/**
* @testdox 正常に商品が登録されている
*/
public function testStoreTestProductSuccess(): void
{
$this->assertSame(0, TestProduct::count());
$count = $this->storeService->store();
$this->assertSame(1, TestProduct::count());
$this->assertSame(1, $count);
}
/**
* @testdox コードが重複したとしても正常に商品が登録されている
*/
public function testStoreTestProductSuccessForPdoException(): void
{
// コネクションを指定してPDOをモック
$connection = DB::connection();
$pdo = $connection->getPdo();
$pdoMock = Mockery::mock($pdo)
$connection->setPdo($pdoMock);
$pdoMock->shouldReceive('prepare')
->once()
->with(Mockery::pattern('/insert .*test_products/'))
->andThrow(new \PDOException('test_products 作成失敗'));
$pdoMock->shouldReceive('prepare')
->once()
->with(Mockery::pattern('/insert .*test_products/'))
->andThrow(new \PDOException('test_products 作成失敗'));
$statement = $pdo->prepare("insert into test_products (code, created_at, updated_at) VALUES (?, ?, ?)");
$pdoMock->shouldReceive('prepare')
->with(Mockery::pattern('/insert .*test_products/'))
->andReturn($statement);
$this->assertSame(0, TestProduct::count());
$count = $this->storeService->store();
$this->assertSame(1, TestProduct::count());
$this->assertSame(3, $count);
}
}
1つ目の testStoreTestProductSuccess のテストでは TestProduct のレコードの件数が1件正常に作成され、1回も繰り返されずに正常に終了していることが分かります。
では2つ目の testStoreTestProductSuccessForPdoException を見ていきましょう。
$connection = DB::connection();
$pdo = $connection->getPdo();
$pdoMock = Mockery::mock($pdo)
->makePartial();
$connection->setPdo($pdoMock);
Mockeryでパーシャルモックを作成し、Connectionのクラス内のPDOをモックのPDOと入れ替える事によって
PDOをモックする事ができます。
*パーシャルモックについてはこちら
$pdoMock->shouldReceive('prepare')
->once()
->with(Mockery::pattern('/insert .*test_products/'))
->andThrow(new \PDOException('test_products 作成失敗'));
$pdoMock->shouldReceive('prepare')
->once()
->with(Mockery::pattern('/insert .*test_products/'))
->andThrow(new \PDOException('test_products 作成失敗'));
shouldReceiveで呼び出すメソッドを指定し、withで引数を指定しています。 onceを指定する事によって一度だけ実行される様に設定し、andThrowで例外が返るようになります。
上記のコードで2回 PDOException が商品作成時に返るようになるという事です。
$statement = $pdo->prepare("insert into test_products (code, created_at, updated_at) VALUES (?, ?, ?)");
$pdoMock->shouldReceive('prepare')
->once()
->with(Mockery::pattern('/insert .*test_products/'))
->andReturn($statement);
そして最後の部分です。
今までと違う部分は andThrow が andReturn に変わっている事です。
andReturn は 呼び出されたメソッドの返り値を指定する事ができるので、今回は商品情報を作成する為のSQL文を返しています。
そうする事によって商品を作成する事ができています。
$this->assertSame(1, TestProduct::count());
$this->assertSame(3, $count);
このアサーションが商品を作成する事ができている証明になります。
TestProductのレコードが1つだけ正常に作成できていて、商品作成の為に3回繰り返されている事が分かります。
まとめ
テストコードって機能開発とは全く別の知識が必要なので敬遠されがちなのかなと思いますが、ちゃんと書いた方がバグが減ります。 例えば大量のコンフリクト発生して正しく修正できているか不安になった時、テストコードがちゃんと書かれていれば正しくコンフリクト修正が行えた証明になります。
また一部の部分開発を行った時に誤って他の機能で必要なロジックを変更してしまってバグになった時、テストコードが書かれていればそのバグに気づく事ができます。
ああなんて素晴らしいテストコードなのでしょうか。