[PHP/Laravel] 外部ライブラリのfinalクラスをモックする
外部のライブラリを使用して実装し、テストを書いていたらfinalクラスがモックできずに詰まったので、その解決方法を解説していきます。
また、解決にあたりサービスコンテナ周りで得た知見の共有をしていきます。
問題
以下のようにLaravelのAuth0 SDKを使用して実装し、テストを書いていたら、
Auth0 SDKで実装されているクラスがfinalなため、直でモック化することができませんでした...
//Auth0 SDKの初期化
$auth0 = new \Auth0\SDK\Auth0([
'domain' => $_ENV['AUTH0_DOMAIN'],
'clientId' => $_ENV['AUTH0_CLIENT_ID'],
'clientSecret' => $_ENV['AUTH0_CLIENT_SECRET'],
'cookieSecret' => $_ENV['AUTH0_COOKIE_SECRET']
]);
$auth0->getIdToken()
//Auth0のテスト
app()->bind(Auth0::class, function () {
$mockedAuth0 = Mockery::mock(Auth0::class);
$mockedAuth0->shouldReceive('getIdToken')->once()
->andReturn('test');
return $mockedAuth0;
});
エラー文
The class Auth0\SDK\Auth0 is marked final and its methods cannot be replaced. Classes marked final can be passed in to \Mockery::mock() as instantiated objects to create a partial mock, but only if the mock is not subject to type hinting checks.
解決方法
Auth0 SDKは外部のライブラリなので、finalを消すことができないです...
ですが、以下のようにコードを見てみると
Auth0クラスはAuth0Interfaceクラスをimplementsしているので、
Auth0Interfaceクラスを使用して実装してあげればできそう!
// SDKのコード
final class Auth0 implements Auth0Interface
{
・
・
}
サービスプロバイダーを使用する
サービスプロバイダーを作成し、Auth0Interface::classにAuth0::classを注入します。
そうすると、app()->make(Auth0Interface::class)で作成したインスタンスで、
Auth0Interface::classで実装しているAuth0::classのすべてのメソッドが使用できるようになります。
class Auth0ServiceProvider extends ServiceProvider
{
public array $bindings = [
// ここでもいい
Auth0Interface::class => Auth0::class,
];
public function register()
{
// こっちでもいい
app()->bind(Auth0Interface::class, Auth0::class);
}
}
サービスプロバイダについてはこの記事がわかりやすかったです
サービスプロバイダー使用後の実装側のコード
app()->make()を使用し、Auth0Interfaceクラスを呼び出すこと、
サービスプロバイダーに登録した、Auth0クラスが注入されたAuth0Interfaceクラスが作成されます。
なので、Auth0Interfaceクラスのメソッドが呼び出されると、Auth0で実装されたメソッドが実行されるので、
Auth0クラスのメソッドを使用した時と変わらないです。
// サービスプロバイダに登録したので、Auth0Interfaceを呼ぶと、Auth0が作られる
$auth0 = app()->make(Auth0Interface::class,[
'domain' => $_ENV['AUTH0_DOMAIN'],
'clientId' => $_ENV['AUTH0_CLIENT_ID'],
'clientSecret' => $_ENV['AUTH0_CLIENT_SECRET'],
'cookieSecret' => $_ENV['AUTH0_COOKIE_SECRET']
]);
// Auth0::classのgetIdToken()が呼ばれる
$auth0->getIdToken()
サービスプロバイダー使用後のテストのコード
先ほどはAuth0クラスをモックしていましたが、実装側ではAuth0Interfaceクラスが使用されているので、
Auth0Interfaceクラスをモックします。
//Auth0のテスト
app()->bind(Auth0Interface::class, function () {
// 実装したコードで呼ばれるのはAuth0Interfaceなので、そっちをモックする
$mockedAuth0 = Mockery::mock(Auth0Interface::class);
$mockedAuth0->shouldReceive('getIdToken')->once()
->andReturn('test');
return $mockedAuth0;
});
番外編
app()->instance()を使用するケース
既存のインスタンスを注入したい場合に使用します。
例えば、以下のコードのように$hogeService->test()が外部APIを叩くメソッドで、APIを叩くたびに課金が発生するならば、本番では呼ばれていいが、テストでは呼ばれるべきではないと思います。
SampleServiceのhello()のテストを書くときに、$hogeService->test()が実際に呼ばれないようにする時に使用します。
実装側のコード
class SampleService
{
public function __construct(
private readonly HogeService $hogeService
) {}
public function hello()
{
$hogeService->test();
・
・
}
}
テストコード
テストで、app()->instance()を使用し、$hogeServiceをMockに置き換え、実際に呼ばれないようにします。
今回は例が単調すぎますが、実際にはLaravelのコントローラーのテストでよく使います。
public function testHello(): void
{
// HogeServiceのモックを作成
$mockedHogeService = Mockery::mock(HogeService::class);
// HogeServiceのtest()が1回呼ばれ、helloを返すように設定
$mockedHogeService->shouldReceive('test')->once()
->andReturn('hello')
// HogeServiceが呼ばれるときは、mockedHogeServiceが呼ばれる
app()->instance(HogeService::class, $mockedHogeService);
// こっちでもいい
$sampleService = new SampleService(mockedHogeService);
}
bindとsingletonの違い
singletonは呼び出された時の値が変わらないです。
例:
ランダムな値を返す関数を持つクラスを注入したら、
bindは呼び出されるごとに値が変わるが、singletonは変わらないです。
最後に
今までサービスコンテナについてはなんとなくしか理解していなかったのですが、この問題を通じてかなり理解を深めることができました。
サービスコンテナとかサービスプロバイダーは、あるクラスを別のクラスなどに置き換えて、クラスが登録されている箱の中から、呼び出すだけなのに、
インスタンスやらコンテナなど横文字を使用し、Dockerと似た表現をするから頭がゴチャゴチャしてわかりにくかったんだなと思いました。(かと言って他のいい表現は思いつかない、、、)
他にもっと簡単な方法などありましたら気軽にコメントいただけると幸いです。
参考
Discussion