【Laravel】 DB::transaction と クロージャの中身をモックするユニットテスト
TL;DR
-
クロージャを引数として受け取るメソッドをモックし,かつ渡されたクロージャ内で呼ばれるメソッドもアサートしたい場合は
with(\Hamcrest\Core\IsInstanceOf::anInstanceOf(Closure::class))
andReturnUsing(fn (Closure $closure) => $closure());
を使うことで実現できる
-
メソッドチェーンでそれぞれのメソッドをモックする場合は
andReturn($user = Mockery::mock(UserContract::class));
のように,メソッドの戻り値を新たにモックし,変数に格納することで,次のメソッドのモックは,前のメソッドの返り値(のモック)を使うことができる
はじめに
PHP でテストを書く上ではほぼ必須と言って良いおなじみのモックライブラリ Mockery
.
以下のように書くことで,簡単にクラス・メソッドのモックを作ることができます.
$mock = \Mockery::mock(Sample::class) // Sample クラスのテストダブルを作成する
->shouldReceive('sampleMethod') // sampleMethod というメソッドの呼び出しを期待する
->once() // 呼び出し回数は1回
->with('sample argument text') // 「sample argument text」 を引数に受け取ることを期待する
->andReturn('processed text'); // 「processed text」 を返すことを明示的に指定する
とても分かりやすいですね.
あとはこのモックしたオブジェクトをテストの対象クラスに DI してあげることで,実行時には本物の実装ではなくモックが呼び出されることになります.
ちょっと複雑なケース
では以下のようなクラスを実装したとします.
class UpdateAction
{
public function __constract(
private ConnectionInterface $db,
private UserRepositoryContract $userRepository,
) {
}
public function __invoke(array $validatedRequests): UserContract
{
// バグ対策の簡易アサーション
assert(
isset(
$validatedRequest['user_id'],
$validatedRequest['name'],
$validatedRequest['birthday']
)
);
return $this->db->transaction(function () use ($validatedRequests) { // トランザクションを貼る
return $this->userRepository
->retrieveByUserIdOrFail($validatedRequest['user_id']) // user_id で検索し,なければ例外を投げる
->setName($validatedRequest['name'])
->setBirthday($validatedRequest['birthday'])
->save();
});
}
}
少々癖はありますが,ありがちなユーザーデータの更新処理ですね.
「なんちゃってクリーンアーキテクチャ」を参考に,Controller のロジックを UseCase に逃がしてきました.
今回は,モジュラモノリスなど,パッケージそれぞれをガッツリ疎結合にしてインターフェースに依存させるようなケースを想定しているので,なんちゃってクリーンアーキテクチャを参考にしていますが Eloquent Model と Entity の橋渡し役として Repository 層を作っています.
さて,大したロジックはありませんが,それでも一応ユニットテストとして,純粋なロジック部分をテストしたいケースは少なからずあると思います.
ユニットテストは純粋なロジック以外はすべてモックされている必要があるので,今回は
-
ConnectionInterface::class
のtransaction()
-
UserRepositoryContract::class
のretrieveByUserIdOrFail()
setName()
setBirthday()
save()
のすべてをモックする必要があります.
とりあえずモックを書いてみる
最初に示したように, Mockery
を使ってそれぞれのメソッドをモックしてみます.
/**
* @test
*/
public function ユーザーを更新する(): void
{
$db = \Mockery::mock(ConnectionInterface::class);
$userRepository = \Mockery::mock(UserRepositoryContract::class);
$db->shouldReceive('transaction')
->once()
->with(???)
->??
}
えっと...あれ?
このようなケースが初めてだと,ここで手が止まってしまうんですよね(経験談).
-
shouldReceive()
を使えばメソッドの呼び出しを期待できる -
with()
を使えば引数に渡る値を期待できる -
andReturn()
を使えば任意の値を返すように指定できる
ここまでは分かる.
でも,クロージャを引数に渡す場合は?クロージャの中で呼ばれることはどうやって期待させる?
そう,シンプルなモックの方法だと,イマイチ今回のケースのようなモックをどうやって書けばいいのか分かりにくいのです.
あきらめて,DB::transaction()
だけをモックしますか?
それとも,DB::class
のモックを諦めて, UserRepository::class
のモックだけしますか?
それだと,正確なユニットテストはできませんよね.安心してください.ちゃんと全部簡単にモックできます.
クロージャを受け取るメソッドのモック
DB::transaction
のように,引数にクロージャを受け取り,クロージャの返り値をそのまま返すようなメソッドのモックは以下のように書けます.
$db = \Mockery::mock(ConnectionInterface::class)
->shouldReceive('transaction')
->once()
->with(\Hamcrest\Core\IsInstanceOf::anInstanceOf(Closure::class)) // クロージャを受け取ることを期待する
->andReturnUsing(function (Closure $closure) { // 受け取ったクロージャの結果を返す
return $closure();
});
anInstanceOf()
は,値が特定の型のインスタンスであるかどうかを判定します.
なお,この部分は typeOf()
でも代用可能です.詳しくは Laravel(Mockery) のマニュアルをご覧ください.
andReturn()
では戻り値を決め打ちでしか指定できなかったのに対し, andReturnUsing()
は戻り値を動的に設定することができます.
andReturnUsing()
の引数に渡しているクロージャの引数 $closure
では,モックしているメソッドが実際にコールされるときに渡された値を受け取ります.
したがって,今回は transaction()
が受け取ったクロージャがそのまま andReturnUsing()
に渡されて,そのクロージャの返り値をそのまま返します.
この部分は, PHP 7.4 で追加されたアロー関数を使って
->andReturnUsing(fn (Closure $closure) => $closure())
と書き直せますね.
このように,andReturnUsing()
にクロージャを渡して中で実行してあげることで,transaction()
で実際に呼ばれるはずのクロージャをテスト時に呼ぶことができます.
クロージャ内のメソッドのモック
あとは,retrieveByUserIdOrFail()
,setName()
,setBirthday()
,save()
のモックを作ってあげれば完成です.
ここで,チェーンされているメソッド群のモック方法も紹介します.
このとき,
-
retrieveByUserIdOrFail()
はUserContract::class
-
setName()
はstatic
-
setBirthday()
はstatic
-
save()
はstatic
を返すように実装されてるとします.
$userRepository = \Mockery::mock(UserRepositoryContract::class);
->shouldReceive('retrieveByUserIdOrFail')
->once()
->with('fuwasegu')
->andReturn($user = Mockery::mock(UserContract::class));
$user->shouldReceive('setName')
->once()
->with('ふわせぐ')
->andReturnSelf();
$user->shouldReceive('setBirthday')
->once()
->with('1998-08-04')
->andReturnSelf();
$user->shouldReceive('save')
->once()
->andReturnSelf();
ポイントは
->andReturn($user = Mockery::mock(UserContract::class));
ですね.もちろん,retrieveByUserIdOrFail()
が返す UserContract::class
のオブジェクトもモックされている必要があるので,andReturn()
の中で新しくモックを作り,それを $user
として返します.
そうすることで,その後に続く UserContract::class
のメソッドは, retrieveByUserIdOrFail()
のモックのが返したオブジェクトを用いてモックすることができます.
これ以外の setter や save()
は static
を返すので, andReturnSelf()
を使ってモック自身を返してあげるようにすれば良いです.
モック完成
完成した最終的なモックを使って書いた正常系のテストはこちら.
正常系のテスト
/**
* @var ConnectionInterface&MockInterface&mixed
*/
private ConnectionInterface $db;
/**
* @var UserRepositoryContract&MockInterface&mixed
*/
private UserRepositoryContract $userRepository;
protected function setUp(): void
{
parent::setUp();
$this->db = Mockery::mock(ConnectionInterface::class);
$this->userRepository = Mockery::mock(UserRepositoryContract::class);
}
/**
* @test
*/
public function ユーザーを更新する(): void
{
$this->db->shouldReceive('transaction')
->once()
->with(\Hamcrest\Core\IsInstanceOf::anInstanceOf(Closure::class))
->andReturnUsing(fn (Closure $closure) => $closure());
$this->userRepository->shouldReceive('retrieveByUserIdOrFail')
->once()
->with('fuwasegu')
->andReturn($user = Mockery::mock(UserContract::class));
$user->shouldReceive('setName')
->once()
->with('ふわせぐ')
->andReturnSelf();
$user->shouldReceive('setBirthday')
->once()
->with('1998-08-04')
->andReturnSelf();
$user->shouldReceive('save')
->once()
->andReturnSelf();
$validatedRequests = [
'user_id' => 'fuwasegu',
'name' => 'ふわせぐ',
'birthday' => '1998-08-04'
];
$action = new UpdateAction(
db: $this->db,
userRepository: $this->userRepository
);
$this->assertInstanceOf(UserContract::class, $action($validatedRequests));
}
まとめ
クロージャを引数として受け取るメソッドをモックし,かつ渡されたクロージャ内で呼ばれるメソッドもアサートしたい場合は
with(\Hamcrest\Core\IsInstanceOf::anInstanceOf(Closure::class))
andReturnUsing(fn (Closure $closure) => $closure());
を使うことで実現できる
メソッドチェーンでそれぞれのメソッドをモックする場合は
andReturn($user = Mockery::mock(UserContract::class));
のように,メソッドの戻り値を新たにモックし,変数に格納することで,次のメソッドのモックは,前のメソッドの返り値(のモック)を使うことができる
Discussion