Efficiently Testing Events and Event Jobs in Laravel

Efficiently Testing Events and Event Jobs in Laravel

Writing tests for your application is an essential part of ensuring that your code works as expected. However, when it comes to testing events and event jobs in Laravel, things can get a little tricky. In this blog, we’ll explore how to test events and event jobs in Laravel.

First, let's start by understanding what events and event jobs are in Laravel.

Laravel's events provide a simple subscriber-observer implementation, allowing us to subscribe and listen to various events that occur in your application. By default, Laravel provides several events, such as model events and authentication events, but you can also create custom events to suit your application's needs.

Event jobs are a way to perform a task in the background without blocking the main thread. These jobs can be queued and processed asynchronously, allowing your application to continue processing requests while the job runs in the background. In Laravel, event jobs are typically used to handle complex tasks that may take a long time to complete, such as sending emails, generating reports, or performing data processing tasks.

Now that we understand what events and event jobs are, let's dive into testing them.

Testing Event

Let's create a scenario where we have a list of clients in our clients table and we need to schedule a meeting with our clients and save the information in our meetings table. We have the following model class Meeting in our system to handle all things related to meetings.

class Meeting extends Model {
    protected $fillable = [
        'meeting_date',
        'meeting_time',
        'email_to',
        'mail_content',
        'status',
        'created_by',
    ];

    protected $dates = [
        'meeting_date',
    ];

    public function creator(): BelongsTo
    {
        return $this->belongsTo(User::class, 'created_by', 'id');
    }

    protected static function boot()
    {
        parent::boot();

        static::creating(function ($model) {
            $model->created_by = auth()->id();
        });
    }
}

We have the MeetingController class to store the meeting data and route as follows.

use App\Controller;

class MeetingController extends Controller {

    public function store(MeetingRequest $request) {
        try { 
            $meeting  = Meeting::create($request->validated())

            MeetingCreated::dispatch($meeting);
        } catch(\Exception $exception){
            Log::error($exception->getMessage());

            return response()->json($meeting)
        }   

        return response()->json($meeting, 201)
    }
}
Route::post('/meetings', [MeetingController::class, 'store'])->name('meetings.create');

Let's say we have a MeetingCreated event that sends an email regarding a meeting set with the client.

<?php

namespace App\Providers;

use App\Events\MeetingCreated;
use App\Listeners\SendMeetingEmail;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;

class EventServiceProvider extends ServiceProvider
{
    protected $listen = [
        MeetingCreated::class => [
            SendMeetingEmail::class,
        ],
    ];
}

When testing events in Laravel, the first step is to create a test that triggers the event. For example, let's say we test the controller action for the route meetings.store which in turn will fire the MeetingCreated event.

namespace Tests\Feature;

use Tests\TestCase;
use App\Models\User;

class LeadMailControllerTest extends TestCase
    use RefreshDatabase;

    public function setUp(): void
    {
        parent::setUp();
        $this->actingAs(User::factory()->create());
    }
    /**
    * user can create a meeting
    *
    * @test
    */
    public function test_user_can_create_a_meeting()
    {
        Event::fake();

        $attributes = Meeting::factory()->make();
        $response   = $this->post(route('meetings.store', $attributes);

        $response->assertCreated();
        $this->assertDatabaseHas('meetings', $attributes);
        Event::assertDispatched(MeetingCreated::class);
    }
}

In this test, we're using the Event::fake() method to fake the event dispatcher, so the event won't be sent. We then create a new meeting by posting the meeting data to meetings.store route which will trigger the MeetingCreated event. Finally, we use the Event::assertDispatched() method to assert that the event was dispatched.

The above test will pass but when we add a new assertion to the above code to check if the user who created the meeting is set as the creator then it will fail because we are faking all events.

namespace Tests\Feature;

use Tests\TestCase;
use App\Models\User;

class LeadMailControllerTest extends TestCase
    use RefreshDatabase;

    public function setUp(): void
    {
        parent::setUp();
        $this->actingAs(User::factory()->create());
    }
    /**
    * user can create a meeting
    *
    * @test
    */
    public function test_user_can_create_a_meeting()
    {
        Event::fake();

        $attributes = Meeting::factory()->make();
        $response   = $this->post(route('meetings.store', $attributes);

        $response->assertCreated();
        $this->assertDatabaseHas('meetings', $attributes);
        Event::assertDispatched(MeetingCreated::class);

        // test whether created_by is inserted
        $latestMeeting = Meeting::latest()->first();
        $this->assertEquals(auth()->id(), $latestMeeting->created_by);
    }
}

To solve this issue what we will need to do is fake only the MeetingCreated event as below.

public function test_user_can_create_a_meeting()
{
    Event::fake([
        MeetingCreated::class,
    ]);

    $attributes = Meeting::factory()->make();
    $response   = $this->post(route('meetings.store', $attributes);

    $response->assertCreated();
    $this->assertDatabaseHas('meetings', $attributes);
    Event::assertDispatched(MeetingCreated::class);

    // test whether created_by is inserted
    $latestMeeting = Meeting::latest()->first();
    $this->assertEquals(auth()->id(), $latestMeeting->created_by);
}

Testing Event Job and Listener

When testing event jobs, we first need to create a test that queues the job. For example, let's say we have an event job called "SendMeetingEmail" that sends a meeting email to the client. Here's an example test for this event job:

/**
* @test
*/
public function test_send_meeting_email_job()
{
    Queue::fake();

    $meeting = Meeting::factory()->create();
    MeetingCreated::dispatch($meeting);

    Queue::assertPushed(CallQueuedListener::class, function ($job) {
        return $job->class == SenMeetingEmail::class;
    });
}

In this test, we're using the Queue::fake() method to fake the queue, so the job won't be processed. We then create a new meeting using a factory which should queue the SendMeetingEmail job. Finally, we use the Queue::assertPushed() method to assert that the job was queued.


Testing Event Job Execution

Once we've confirmed that the job was queued, we can test that the job was executed correctly. Here's an example for the "SendMeetingEmail" event job and test for the same.

namespace App\Listeners;

use App\Constants\Queue;
use App\Events\MeetingCreated;
use Illuminate\Contracts\Queue\ShouldQueue;

/**
 * Class SendMeetingEmail
 */
class SendMeetingEmail implements ShouldQueue
{
    /**
     * @var string $queue
     */
    public string $queue = Queue::EMAIL;

    /**
     * Handle the event.
     *
     * @param MeetingCreated $event
     *
     * @return void
     */
    public function handle(MeetingCreated $event)
    {
        $meeting    = $event->meeting;

        Mail::to($meeting->email_to)
            ->queue((new MeetingEmail($meeting))
            ->onQueue(Queue::EMAIL));
    }
}
<?php

namespace App\Notifications;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;

/**
 * Class MeetingEmail
 *
 * @package App\Notifications
 */
class MeetingEmail extends Mailable implements ShouldQueue
{
    use Queueable;
    use SerializesModels;

    /**
     * @var Meeting|null
     */
    public array $meeting;

    /**
     * Create a new message instance.
     *
     * @return void
     */
    public function __construct(Meeting $meeting)
    {
        $this->meeting = $meeting;
    }

    /**
     * Build the message.
     *
     * @return $this
     */
    public function build(): MeetingEmail
    {
        $mailable    = $this->from($this->meeting->creator->email, $this->meeting->creator->name);
        $mailable    = $mailable->subject("Meeting Confirmed");
        $mailable    = $mailable->text('email.meeting.index', ['meeting' => $this->meeting]);

        return $mailable;
    }
}
public function test_send_meeting_email_job_execution()
{
    Mail::fake();

    $meeting = Meeting::factory()->create();
    $event    = new MeetingCreated($leadMail);
    event($event);

    Mail::assertQueued(MeetingEmail::class, 
        function ($mail) use ($meeting) {
            return $mail->hasTo($meeting->email_to) &&
                   $mail->hasFrom($meeting->creator->email)
                   $mail->hasSubject('Meeting Confirmed'); 
        }
    );
}

In this test, we're using the Mail::fake() method to fake the mailer, so the email won't be sent. We then create a new user using a factory and create a new instance of the MeetingCreated event and dispatch it using event() method. We can then assert if the Mail was queued to be sent and the different properties of the mail such as hasTo, hasFrom, hasSubject etc.


Conclusion

Laravel's event system provides an efficient way of subscribing to events and listening for changes in our application. Event jobs allow your application to continue processing requests while handling complex tasks in the background. But it is usually a hassle to test when we do not know where to start.

When testing events, it's essential to use the Event::fake() method to fake the event dispatcher and ensure that the event is not sent. However, it's crucial to note that you should only fake the events you need to test to avoid interfering with other parts of your application.