2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

LaravelのEventSubscriberにDIするとMockeryのoverloadモックが効かない理由

Posted at

Laravelのテストでモックする時は、Mockeryを使うことが一般的かと思います。Mockeryには overload という特殊なモック方法があり、便利ではあるのですが副作用もあります。
今回、EventSubscriberの実装でハマってしまいました。

Mockeryの overload について

例えば、以下の様なクラスがあったとします。

PriceService.php
<?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 の出番です。

PriceServiceTest.php
<?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などのクラスと同様に、オブジェクトの自動注入を行うことができます。

ItemCreatedEventSubscriber.php
<?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 で設定することで有効になります。

AppEventServiceProvider.php
<?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を実行すると

  1. ItemCreatedEventSubscriber がインスタンス化
  2. DIしてた ItemFetcher がインスタンス化
  3. PriceServiceTest が実行
  4. テストメソッド内で ItemFetcher を overloadでモック

となり、モックが失敗していたのです。

解決方法

解決方法は2つあります。
1つ目は、EventSubscriberでlistenしていたクラスを自クラスではなく他のクラスにすることです。

ItemCreatedEventSubscriber.php
<?php

namespace App\Listners;

use App\Events\ItemCreated;

class ItemCreatedEventSubscriber
{
    public function subscribe($events)
    {
        $events->listen(
            ItemCreated::class,
            [ItemCreatedHandler::class, 'handleItemCreated']
        );
    }
}
ItemCreatedHandler.php
<?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つ目は、メソッド内でインスタンス化させないようにすることです。

PriceService.php
<?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();
    }
}

このようにすれば、純粋にモックを渡すだけでよくなります。

PriceServiceTest.php
<?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' を消していきましょう。そのほうが幸せです。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?