Laravelのテストでモックする時は、Mockeryを使うことが一般的かと思います。Mockeryには overload
という特殊なモック方法があり、便利ではあるのですが副作用もあります。
今回、EventSubscriberの実装でハマってしまいました。
Mockeryの overload について
例えば、以下の様なクラスがあったとします。
<?php
namespace App\Services;
use App\Services\ItemFetcher;
class PriceService
{
public function updatePrice(int $id, int $price): void
{
$fetcher = new ItemFetcher();
$item = $fetcher->get($id);
$item->price = $price;
$item->save();
}
}
このクラスはメソッド内で ItemFetcher
インスタンスを生成しています。このクラスのテストを行う場合、 ItemFetcher
はメソッド内でインスタンス化されているので、本来であれば、 ItemFetcher
をモックして、仮の Item
オブジェクトを渡すことができません。
そこで、 overload
の出番です。
<?php
namespace Tests\Unit;
use App\Models\Item;
use App\Services\ItemFetcher;
use App\Services\PriceService;
use Tests\TestCase;
class PriceServiceTest extends TestCase
{
public function test(): void
{
$item = \Mockery::mock(Item::class);
$item->shouldReceive('setAttribute')->with('price', $price = 100)->once();
$item->shouldReceive('save')->once();
$fetcher = \Mockery::mock('overload:' . ItemFetcher::class);
$fetcher->shouldReceive('get')->andReturn($item);
$service = new PriceService();
$service->update(1, $price);
}
}
Mockeryでモックオブジェクトを作る際に、 overload:
を前につけることで、インスタンス化される時にこのモックオブジェクトへ置き換えることができます。
ただし、このモックオブジェクトを作る前に一度でもインスタンス化されているとエラーとなります。
ここがとても重要です。
こうすることで、PriceService内のItemFetcherがモックオブジェクトに置き換わり、無事にテストを通すことができます。
. 1 / 1 (100%)
Time: 00:00.042, Memory: 26.00 MB
OK (1 test, 2 assertions)
EventSubscriberでのDI
さて、次はEventSubscriberに話が移ります。イベントの発行を購読し、別の処理を実行するためにEventSubscriberを作ることがありますが、このEventSubscriberもControllerなどのクラスと同様に、オブジェクトの自動注入を行うことができます。
<?php
namespace App\Listners;
use App\Services\ItemFetcher;
use App\Events\ItemCreated;
class ItemCreatedEventSubscriber
{
public function __construct(
private readonly ItemFetcher $fetcher
)
{
}
public function handleItemCreated(ItemCreated $event)
{
$item = $this->fetcher($event->getItemId());
...
}
public function subscribe($events)
{
$events->listen(
ItemCreated::class,
[self::class, 'handleItemCreated']
);
}
}
EventSubscriberは EventServiceProvider
で設定することで有効になります。
<?php
namespace App\Providers;
use App\Listeners\ItemCreatedEventSubscriber;
use Illuminate\Foundation\Support\Providers\EventServiceProvider;
class AppEventServiceProvider extends EventServiceProvider
{
protected $subscribe = [
ItemCreatedEventSubscriber::class,
];
}
boostrap/providers.php
にこのプロバイダーを登録する必要がありますが、割愛します。
これにより、 ItemCreated
イベントが発行されると、 ItemCreatedEventSubscriber::handleItemCreated()
メソッドが自動的に実行されます。
この組み合わせによるエラーと原因
ここまでやって、先ほど作ったテストを再実行します。
E 1 / 1 (100%)
Time: 00:00.040, Memory: 26.00 MB
There was 1 error:
1) Tests\Unit\PriceServiceTest::test
Mockery\Exception\RuntimeException: Could not load mock App\Services\ItemFetcher, class already exists
/var/www/vendor/mockery/mockery/library/Mockery/Container.php:401
/var/www/vendor/mockery/mockery/library/Mockery.php:477
/var/www/tests/Unit/PriceServiceTest.php:18
--
ERRORS!
Tests: 1, Assertions: 2, Errors: 1.
テストが通らなくなりました。なぜでしょう?
EventSubscriberのインスタンス化のタイミング
Laravelでは、発行するイベントを受け取るEventSubscriberを事前に登録します。
これは EventServiceProvider::subscribe
配列に登録していました。
この配列に登録されているクラスは事前にインスタンス化されます。このクラスがインスタンス化されるということは、コンストラクタを使ってDIしていたクラスも自動的にインスタンス化されます。
そして、その事前登録が完了した後にテストが実行されます。
今回のケースだと、PHPUnitを実行すると
-
ItemCreatedEventSubscriber
がインスタンス化 - DIしてた
ItemFetcher
がインスタンス化 -
PriceServiceTest
が実行 - テストメソッド内で
ItemFetcher
を overloadでモック
となり、モックが失敗していたのです。
解決方法
解決方法は2つあります。
1つ目は、EventSubscriberでlistenしていたクラスを自クラスではなく他のクラスにすることです。
<?php
namespace App\Listners;
use App\Events\ItemCreated;
class ItemCreatedEventSubscriber
{
public function subscribe($events)
{
$events->listen(
ItemCreated::class,
[ItemCreatedHandler::class, 'handleItemCreated']
);
}
}
<?php
namespace App\Listners;
use App\Services\ItemFetcher;
use App\Events\ItemCreated;
class ItemCreatedHandler
{
public function __construct(
private readonly ItemFetcher $fetcher
)
{
}
public function handleItemCreated(ItemCreated $event)
{
$item = $this->fetcher($event->getItemId());
...
}
このように、コンストラクタでのDIとイベントを受け取ったあとの処理を ItemCreatedHandler
に移します。イベントが発行されるまで、このクラスはインスタンス化されないので、テストは通る様になります。
2つ目は、メソッド内でインスタンス化させないようにすることです。
<?php
namespace App\Services;
use App\Services\ItemFetcher;
class PriceService
{
public function __construct(
private readnoly ItemFetcher $fetcher,
)
{
}
public function updatePrice(int $id, int $price): void
{
$item = $this->fetcher->get($id);
$item->price = $price;
$item->save();
}
}
このようにすれば、純粋にモックを渡すだけでよくなります。
<?php
namespace Tests\Unit;
use App\Models\Item;
use App\Services\ItemFetcher;
use App\Services\PriceService;
use Tests\TestCase;
class PriceServiceTest extends TestCase
{
public function test(): void
{
$item = \Mockery::mock(Item::class);
$item->shouldReceive('setAttribute')->with('price', $price = 100)->once();
$item->shouldReceive('save')->once();
$fetcher = \Mockery::mock(ItemFetcher::class);
$fetcher->shouldReceive('get')->andReturn($item);
$service = new PriceService($fetcher);
$service->update(1, $price);
}
}
まとめ
このように、Laravelが事前にインスタンス化するクラスを 'overload' を使ってモックすると、予想外のエラーが発生します。
この現象の困ったことは、いずれも書き方的には特に間違っておらず、発生した原因が把握しずらい点です。
もしテストで急に 'overload' なモックができなくてエラーになった場合は、EventSubscriberなどの事前にインスタンス化しそうなところを確認してみてください。
また、メソッド内でサービスクラスをインスタンス化してるような箇所を見つけたら、早めにDIへ差し替えて、テストから 'overload' を消していきましょう。そのほうが幸せです。