4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Posted at

こんにちはみなさん

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

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

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

3行

  • 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());
    }

これは実行すると以下のようにエラーが出ます。

> 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

4
1
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
4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?