Search code examples
laraveltestingphp-pest

Laravel testing - check if mail was sent via Event/Listener


How can I test next logic in ONE Laravel test

  • When I make a POST request with valid data
  • Email was sent

In a code base it looks like this

// controller 
public function store(StoreOrderRequest $request)
{
    OrderCreated::dispatch($request->validated());
}

SendEmailToUser listener (which implements ShouldQueue interface, queue connection - database) listens OrderCreated event

// SendEmailToUser listener
public function handle(OrderCreated $event)
{
    Mail::send(new OrderCreatedMail($event->data));
} 

For now next two tests works fine - first I'll check if event was dispatched, then check if corresponding listener was attached and finally check the logic for listener itself

test('event dispatched', function () {
    Event::fake();

    $this->post(...);

    Event::assertDispatched(OrderCreated::class);
    Event::assertListening(OrderCreated::class, SendEmailToUser::class);
});

test('mail sent', function () {
    Mail::fake();

    (new SendEmailToUser())->handle(new OrderCreated($data));

    Mail::assertSent(OrderCreatedMail::class);
});

However my question is can I refactor those into ONE test something like

Something::fake();

// When I make a POST request
$this->post(...);

// Email was sent
Mail::assertSent(OrderCreatedMail::class);

I want my test to not depend on logic inside of the "black box" but to take care only of its result. Cause maybe I'll change implementation for sending mails and the only thing for now I care is was the mail sent or not (let's say I'll change my controller method to send mails directly or something else - tests will fail while real app logic "when send POST request with correct data - then email was sent" still be valid)


Solution

  • You definitely can, and should not be an issue (no code-issues), but remember that you should be only testing ONE thing per test. So, my personal recommendation, after some years of experience with Laravel and testing is:

    1. I would split them even more. For example, the first test is fine, except that you are testing two things into one (never do that). So, instead of testing if the listener is attached to the correct event inside the test that checks if the correct event is dispatched for that route, create a new test that will only test that:
      test('listener attached to event', function () {
          /**
           * You should not need to Event::fake(); but I cannot remember,
           * test it this way first
           */
          Event::assertListening(OrderCreated::class, SendEmailToUser::class);
      });
      
    2. You may have the test (or not, you did not share that) related to the form request, remember to test that the form request has the right validation rules, do a post to the route with invalid data per field, and assert that the error messages coming back are the expected ones (this is an example of that but using PHPUnit, not Pest)
    3. As you stated at the last part of your question, you are correct, your test should be as a black box (not caring about the implementation), it should be as implementation-agnostic as possible, but you still have to fake some stuff, for example, the event and then also literally call and execute the listener with the data (that would be a Unit test, instead of a Feature test, but you can have it grouped together here). The less stuff you can fake and relate on your test, the better, so if the implementation changes in the future, you should still have the same data on the test, but in your black box (the real code), it should still perform some actions and end with the same result (in your case an event dispatched, in the future, maybe the listener changes, but not the event being dispatched)

    Check out my StackOverflow profile, maybe there is another solution in there related to testing that is of help for you!