Laravelでfactoryの際にイベントリスナーを走らせない方法

こんにちはみなさん

Webでデータを更新したら、そのログを取っておくという処理をコントローラに一緒に書くのは、まあ、よくあることなのです。しかし、システムが肥大化していき、「ユーザにメール出さなきゃ」とか、「並び順の変更もしなきゃ」とか、いろんな事が出てくると、コントローラが煩雑になっていきます。しかも、其のデータ更新が別の箇所で発生していたら、また同じような処理を書かなければならない。。。

LaravelのEloquentを使っている場合、データ更新という中心的な処理に対し、ログを取ったりメールを出したりといった「脇道の処理」はイベントを使うのが楽ちんです。
楽ちんなんですが、テストでfactoryを使ってデータを作ると、いちいちメールが飛んだり、ログが作られたりしますので、これはこれで面倒なのです。

というわけで、factoryでデータ作るときはイベントリスナーを実行しないようにするやり方をご紹介しましょう。
ちなみに、Laravelは7を使っています。

# ３行

- factoryのときもイベントは発生する
- fakeForを使えば、一時的にイベントの発生を抑制できる
- ModelのeventDispatcherを変える技もあるよ

# 問題設定

メモを更新するwebの処理についてのテストを書いてみて、イベントリスナーの挙動を見てみます。

## 処理の作成
「メモは更新すると、その履歴を保存する」という、簡単な所作をイベントを使って定義します。

```Models/Memo.php
<?php

namespace App\Models;

use App\Events\MemoUpdating;
use Illuminate\Database\Eloquent\Model;

class Memo extends Model
{
    protected $dispatchesEvents = [
        'updating' => MemoUpdating::class,
    ];

    public function logs()
    {
        return $this->hasMany(MemoLog::class);
    }
}
```
ここで`$dispatchesEvents`によって、発生するイベントを定義します。今回は更新時のことしか考えていないので、`updating`のみ定義しています。
諸々省略して、リスナーを見てみます。

```Listeners/WriteMemoLog.php
<?php

namespace App\Listeners;

use App\Events\MemoUpdating;

class WriteMemoLog
{
    /**
     * Create the event listener.
     *
     * @return void
     */
    public function __construct()
    {
        //
    }

    /**
     * Handle the event.
     *
     * @param  MemoUpdating  $event
     * @return void
     */
    public function handle(MemoUpdating $event)
    {
        $memo = $event->memo;
        $original_content =  $memo->getOriginal('content');
        $memo->logs()->create([
            'content' => $original_content
        ]);
    }
}
```
コントローラはデータの更新だけします。

```Http/Controllers/MemoController.php
    public function update(Request $request, Memo $memo)
    {
        $memo->content = $request->input('content');
        $memo->save();
        return $memo;
    }
```
他色々やってたりしますが、まあ、何した以下はわかるのではないでしょうか。

## テストを走らせる
とりあえず適当なテストを作りましょう。

```tests/Feature/MemoTest.php
    /**
     * @test
     */
    public function メモのアップデート()
    {
        $memo = factory(Memo::class)->create();
        $this->putJson(route('memo.update', $memo->id), [
            'content' => 'あいうえお'
        ]);

        // 変更している
        $new_memo = Memo::find($memo->id);
        $this->assertEquals('あいうえお', $new_memo->content);

        // ログがひとつできている
        $this->assertEquals(1, MemoLog::count());
        $log = MemoLog::first();
        $this->assertEquals($memo->content, $log->content);
    }
```
目論見通りですね。

## factoryデータを更新
ところが、factoryで作ったデータを更新しようとすると、こんなことになります。

```tests/Feature/MemoTest.php
    /**
     * @test
     */
    public function factoryのデータを事前更新()
    {
        $memo = factory(Memo::class)->create();
        $memo->content = 'アイウエオ';
        $memo->save();

        $this->putJson(route('memo.update', $memo->id), [
            'content' => 'あいうえお'
        ]);

        $new_memo = Memo::find($memo->id);
        $this->assertEquals('あいうえお', $new_memo->content);

        $this->assertEquals(1, MemoLog::count());
    }
```
これは実行すると以下のようにエラーが出ます。

```sh
> phpunit
PHPUnit 8.5.5 by Sebastian Bergmann and contributors.

...F                                                                4 / 4 (100%)

Time: 896 ms, Memory: 26.00 MB

There was 1 failure:

1) Tests\Feature\MemoTest::factoryのデータを事前更新
Failed asserting that 2 matches expected 1.

/var/www/tests/Feature/MemoTest.php:49

FAILURES!
Tests: 4, Assertions: 7, Failures: 1.
Script phpunit handling the test event returned with error code 1
```
何が起こったかというと、factoryで作ったデータをちょっと変えてsaveしたところ、updatingイベントが走ってログが作成され、想定している個数とズレが発生したということです。

# factoryのイベントを止める

まあ、factoryでイベントが発生しても、この程度であれば困らないっちゃ困らないのですが、とはいえ、意図しないデータが勝手に作成されてしまったりすると、テスト結果に変な不確実性が出来ちゃうかもしれないので、イベントの発生を止めることを考えてみましょう。

## fakeForを使う
一番簡単なのは`Event::fakeFor`を使って、対象部分だけイベントリスナーの挙動を止める方法です。

```tests/Feature/MemoTest.php
    /**
     * @test
     */
    public function イベントを止めたい()
    {
        // fakeForのなかのみイベントリスナーが作動しない
        $memo = Event::fakeFor(function () {
            $memo = factory(Memo::class)->create();
            $memo->content = 'アイウエオ';
            $memo->save();
            return $memo;
        });

        $this->putJson(route('memo.update', $memo->id), [
            'content' => 'あいうえお'
        ]);

        $new_memo = Memo::find($memo->id);
        $this->assertEquals('あいうえお', $new_memo->content);

        $this->assertEquals(1, MemoLog::count());
    }
```
これでテストは通ります。

## 一時的にModelのeventDispatcherを変える
復数のデータを扱っているときはfakeForが面倒なときもあります。
かなり裏技っぽいですが、factoryでデータを作っているときだけ、ModelのeventDispatcherを変更するという方法もあります。

```tests/Feature/MemoTest.php
    /**
     * @test
     */
    public function イベントを止めたい()
    {
        // eventDispatcherを一時的に入れ替える
        $original_event = Event::getFacadeRoot();
        Model::setEventDispatcher(new EventFake($original_event, []));
        $memo = factory(Memo::class)->create();
        $memo->content = 'アイウエオ';
        $memo->save();
        Model::setEventDispatcher($original_event);


        $this->putJson(route('memo.update', $memo->id), [
            'content' => 'あいうえお'
        ]);

        $new_memo = Memo::find($memo->id);
        $this->assertEquals('あいうえお', $new_memo->content);

        $this->assertEquals(1, MemoLog::count());
    }
```
factoryでいちいちイベントが発生したかどうかを見る必要もなければ、このような方法でもありかなと思います。

# まとめ
そんなわけで、テスト時のfactoryによるデータ作成時に、イベントリスナーを動作させない方法を紹介しました。
普通は`fakeFor`を使うのでしょうが、複雑な系で復数のデータのfactoryを走らせたい場合は、eventDispacherの入替えを検討するのもありって感じです。

今回はこんなところです。

# 参考
[EventのfakeFor](https://laravel.com/docs/7.x/mocking#scoped-event-fakes)