Be part of JetBrains PHPverse 2026 on June 9 – a free online event bringing PHP devs worldwide together.

jrdavidson's avatar

Testing Linkage Between Event and Listener

My question here is how to test the case a side effect of an event efficiently. Currently I have the Feature test written that tests the event is dispatched which is fine but I want to make sure that an email is fired off as well. The email is fired off as a listener to the event.

So I'm wondering if it would be better to test the linkage of the listener to the event as in that its attached and not necessarily that it was fired off or just keep it the way it is with testing the email was also fired off during the feature run.

/** @test */
public function store_dispatches_teacher_created_event()
{
    Event::fake();

    $this->actingAs(Administrator::factory()->create())->post(route('teachers.store'), $this->attributes);

    $teacher = Teacher::first();

    Event::assertDispatched(function (TeacherCreated $job) use ($teacher) {
        return $job->teacher->id === $teacher->id;
    });
}

/** @test */
public function store_queues_welcome_teacher_email()
{
    Mail::fake();

    $this->actingAs(Administrator::factory()->create())->post(route('teachers.store'), $this->attributes);

    $teacher = Teacher::first();

    Mail::assertQueued(WelcomeTeacher::class, 1);
    Mail::assertQueued(function (WelcomeTeacher $mail) use ($teacher) {
        return $mail->teacher->id === $teacher->id &&
                $mail->hasTo($teacher->email) &&
                $mail->hasTo($teacher->school_email);
    });
}
0 likes
27 replies
martinbean's avatar
Level 80

@jrdavidson Just fake the mailer and test the expected mail was queued:

Mail::fake();

$admin = Administrator::factory()->create();

$this
    ->actingAs($admin)
    ->post(route('teachers.store'), $this->attributes)
    ->assertSessionHasNoErrors()
    ->assertRedirect();

$teacher = Teacher::first();

Mail::assertQueued(WelcomeTeacher::class, function (WelcomeTeacher $mail) use ($teacher) {
    return (
        $mail->teacher->is($teacher) &&
        $mail->hasTo($teacher->email) &&
        $mail->hasTo($teacher->school_email)
    );
});

Don’t test that the listener is hooked up to the event, otherwise you’re then testing how your code is written and not what your code is doing. If you refactor your code so that it does the same thing but in a different way, then your test would start failing, even though nothing’s “broken”.

1 like
bugsysha's avatar

It can be used for unit and feature test.

martinbean's avatar

If you’re testing how events, listeners, and mails all work together than that’s very much out of the scope of a unit test, as you’re not testing a single unit of code there; that would be a feature or integration test.

1 like
jrdavidson's avatar

@martinbean Yes I’m thinking integration level is the right move. Just need to figure out how to write this test so that it knows to run the handlers and not just check that they are attached

martinbean's avatar

You don’t explicitly test the handlers are attached because like I say, that’s testing how the code’s written and not testing what it does.

If you’re performing an action and a side effect is an email should be sent, then test that. The email being sent by a listener is just an implementation detail.

jrdavidson's avatar

@martinbean So for a integration level test you are saying something like this that the event gets fired and its just seeing if the mail was sent.

<?php

namespace Tests\Integration\Events;

use App\Events\StudentCreated;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;

class StudentCreatedEventTest extends TestCase
{
    use RefreshDatabase;

    /** @test */
    public function event_fires_of_send_student_welcome_email()
    {
        Event::fake([StudentCreated::class]);
        Mail::fake();

        Mail::assertDispatched(SendStudentWelcomeEmail::class);
    }
}
martinbean's avatar

@jrdavidson No. Because if you fake the event the listeners won’t get called, so the mail won’t get dispatched.

Like I say, stop getting so hung up on how your code is doing something and focus on what the code is doing. Think about the given–when–then set up:

When I create a student, then an email should be sent.

So that’s all you should test: create a student, assert an email was sent.

It doesn’t matter how the email is sent (inline the controller, in a listener, in an observer); you just want to know that an email is sent. Your test case is if an email is sent; not how that email was queued.

Your tests should test your application’s behaviour; not how you’re piecing your code together. Because if you decide to say, dispatch the email directly from the controller instead of a listener, then your test is going to break even though your application is still doing what you want it to do (send an email when a student is created).

Creating one-to-one mappings between application code and test code just leads to brittle tests that break any time you refactor something.

jrdavidson's avatar

Okay I think I understand now. So the test would be more like this.

/** @test */
    public function testing_email_is_fired_when_student_is_created()
    {
        Mail::fake();

        $student = Student::factory()->create();

        Event::dispatch(StudentCreated::class);

        Mail::assertQueued(WelcomeStudent::class, 1);
        Mail::assertQueued(function (WelcomeStudent $mail) use ($student) {
            return $mail->student->id === $student->id &&
                   $mail->hasTo($student->email) &&
                   $mail->hasTo($student->school_email);
        });
    }
bugsysha's avatar

@jrdavidson

If you’re testing how events, listeners, and mails all work together than that’s very much out of the scope of a unit test, as you’re not testing a single unit of code there; that would be a feature or integration test.

Depends on how you test it. If you test a single class then you can use Event::fake() to make assertions in unit test.

bugsysha's avatar

Also I would avoid using Event::dispatch(StudentCreated::class); to trigger the event in the system. That might trigger bunch of other listeners and your tests might fail.

jrdavidson's avatar

If that's the case which I understand why. How would I trigger that email then when a student is created without having it be apart of the feature tests.

bugsysha's avatar

@jrdavidson it's super simple.

Mail::fake();

$student = Student::factory()->create();

(new NameOfTheListenerThatIsListeningForStudentCreatedEvent)->handle(new StudentCreated($student));

// assertions
martinbean's avatar

That’s an integration test. You’re inserting a row into a database, a listener’s being invoked, the mailer is being faked… so what exactly is the single unit being tested in that example?

A unit test should test one thing and one thing only, and not need any resources (such as a database or the framework). You’ll notice that when you create a unit test, the stub extends PHPUnit’s TestCase class. If you’re unable to write a test against that base test case (i.e. you need a database or the framework booting) then that’s a strong indication that what you’re writing isn’t a unit test.

jrdavidson's avatar

Just as a final question. This integration test name could possibly be named email_is_fired_when_student_is_created, right? If so, I'm displeased with me naming the file StudentCreatedEventTest under the Integration\Events namespace.

Would this be considered invalid?

bugsysha's avatar

@martinbean you are absolutely right. But I wanted to write $student = Student::factory()->make(). @jrdavidson it should be make() instead of create().

bugsysha's avatar

@jrdavidson I only test Listeners. But in Event classes you should only test that you can access the model. It is a simple Data Transfer Object/Value Object.

jrdavidson's avatar

@bugsysha @martinbean Here's my test class.

<?php

namespace Tests\Integration\Events;

use App\Events\StudentCreated;
use App\Listeners\SendStudentWelcomeEmail;
use App\Mail\WelcomeStudent;
use App\Models\Student;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;

class StudentCreatedEventTest extends TestCase
{
    use RefreshDatabase;

    /** @test */
    public function email_is_fired_when_student_is_created()
    {
        Mail::fake();

        $student = Student::factory()->make();

        (new SendStudentWelcomeEmail)->handle(new StudentCreated($student));

        Mail::assertQueued(WelcomeStudent::class, 1);
        Mail::assertQueued(function (WelcomeStudent $mail) use ($student) {
            return $mail->student->id === $student->id &&
                   $mail->hasTo($student->email) &&
                   $mail->hasTo($student->school_email);
        });
    }
}
bugsysha's avatar

Now I'm confused. Are you trying to write unit or integration test? For unit tests you try to isolate just that object/method and don't touch the database. For integration/feature test you should use your database. So if you are writing integration test, it should be $student = Student::factory()->create();. But for unit test it should be $student = Student::factory()->make();.

jrdavidson's avatar

@bugsysha I've been trying to write integration since the feature passes but just wanted to work on a lower level.

Outside of using the create vs. makes do you see anything wrong with the naming of the class or namespace?

bugsysha's avatar

I've been trying to write integration since the feature passes but just wanted to work on a lower level.

@jrdavidson depends on your definition, but most zoomed in/low level type of test is unit test. So your statement is bit conflicting cause it can't be zoomed in/low level type of test and to call it integration.

Outside of using the create vs. makes do you see anything wrong with the naming of the class or namespace?

Yes, you are testing SendStudentWelcomeEmail logic and not StudentCreatedEvent. Also email_is_fired_when_student_is_created is bit strange to me. I would name it test_that_welcome_student_email_is_sent_when_student_is_registered. Now this points that the StudentCreatedEvent should be StudentRegisteredEvent and so on.

Depending on the level of detail you want to go, if you have a register route, I would have default Laravel UserRegistered event and UserCreated event fired. That way I can be specific as much as I want.

Also I would never create Student model. That is a user of my website and for that reason it would be User with a column type with value student.

jrdavidson's avatar

@bugsysha Why do you say registered vs create? Also below is my test involved.

<?php

namespace Tests\Integration\Listeners;

use App\Events\StudentCreated;
use App\Listeners\SendStudentWelcomeEmail;
use App\Mail\WelcomeStudent;
use App\Models\Student;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;

class SendStudentWelcomeEmailTest extends TestCase
{
    use RefreshDatabase;

    /** @test */
    public function welcome_email_is_sent_when_student_is_created()
    {
        Mail::fake();

        $student = Student::factory()->create();

        (new SendStudentWelcomeEmail)->handle(new StudentCreated($student));

        Mail::assertQueued(WelcomeStudent::class, 1);
        Mail::assertQueued(function (WelcomeStudent $mail) use ($student) {
            return $mail->student->id === $student->id &&
                   $mail->hasTo($student->email) &&
                   $mail->hasTo($student->school_email);
        });
    }
}

bugsysha's avatar

Why do you say registered vs create?

@jrdavidson cause in order for a user/student to be able to use your website he/she has to go through registration process I assume. So when I talk about using your website I will never say I have to go to create an account in order to use your awesome website. I will have I have to go and register in order to use your awesome website.

1 like

Please or to participate in this conversation.