0

Testing mail — Mail::fake(), assertSent()

Intermediate5 min read·lv-22-004

Concept

Mail::fake() replaces the real mail transport with an in-memory array store for the duration of a test. No SMTP connections are made, no API calls go out — sent mailables are simply recorded in memory, and Mail provides a rich set of assertion methods to verify the application behaved correctly.

Calling Mail::fake() in a test swaps the Illuminate\Mail\Mailer binding in the container with Illuminate\Support\Testing\Fakes\MailFake. The fake's send() method stores the mailable in an array, and subsequent assertSent(), assertNotSent(), assertQueued(), and assertNothingSent() calls inspect that array.

assertSent(OrderShipped::class) confirms the mailable was sent at least once. The second argument is an optional closure that receives the mailable instance, allowing you to assert specific properties: the recipient, the attached data, the subject, or any other business logic embedded in the mailable. This is the right way to write granular mail assertions — don't just assert the class was sent, assert it was sent to the correct recipient with the correct data.

The distinction between assertSent() and assertQueued() matters: if your mailable implements ShouldQueue, Mail::to()->send() queues it rather than sending it synchronously. In tests, Mail::fake() intercepts both paths, but you must use assertQueued() for queueable mailables, not assertSent(). Alternatively, set Queue::fake() separately and let Mail::fake() still see the send — but this gets confusing. The simplest rule: if ShouldQueue is on the class, use assertQueued().

Mail::fake() also supports assertSentCount() to assert an exact number of sends, and assertNothingQueued() / assertNothingSent() to assert no mail was dispatched. These are valuable negative assertions — confirm that cancelling an order does not trigger an order confirmation email.

Code Example

php
<?php

namespace Tests\Feature;

use App\Mail\OrderShipped;
use App\Mail\OrderCancelled;
use App\Models\Order;
use App\Models\User;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;

class OrderMailTest extends TestCase
{
    public function test_order_shipped_email_is_sent_to_customer(): void
    {
        Mail::fake();

        $user  = User::factory()->create(['email' => 'customer@example.com']);
        $order = Order::factory()->for($user)->create();

        $this->post("/orders/{$order->id}/ship");

        // Assert the class was sent at all
        Mail::assertSent(OrderShipped::class);

        // Assert it was sent to the right recipient with the right data
        Mail::assertSent(OrderShipped::class, function (OrderShipped $mail) use ($order, $user) {
            return $mail->hasTo($user->email)
                && $mail->order->is($order);
        });
    }

    public function test_order_shipped_email_is_sent_exactly_once(): void
    {
        Mail::fake();

        $user  = User::factory()->create();
        $order = Order::factory()->for($user)->create();

        $this->post("/orders/{$order->id}/ship");

        Mail::assertSentCount(1);
    }

    public function test_cancellation_does_not_send_shipped_email(): void
    {
        Mail::fake();

        $user  = User::factory()->create();
        $order = Order::factory()->for($user)->create();

        $this->delete("/orders/{$order->id}");

        Mail::assertNotSent(OrderShipped::class);
        Mail::assertSent(OrderCancelled::class);
    }

    public function test_queued_welcome_mail_is_queued_on_registration(): void
    {
        Mail::fake();

        $this->post('/register', [
            'name'     => 'Jane Doe',
            'email'    => 'jane@example.com',
            'password' => 'password',
            'password_confirmation' => 'password',
        ]);

        // WelcomeMail implements ShouldQueue — use assertQueued, not assertSent
        Mail::assertQueued(\App\Mail\WelcomeMail::class, function ($mail) {
            return $mail->hasTo('jane@example.com');
        });
    }

    public function test_nothing_sent_when_user_is_inactive(): void
    {
        Mail::fake();

        $user  = User::factory()->inactive()->create();
        $order = Order::factory()->for($user)->create();

        $this->post("/orders/{$order->id}/ship");

        Mail::assertNothingSent();
    }
}

Interview Q&A

Q: What is the difference between Mail::assertSent() and Mail::assertQueued() in tests, and when do you use each?

assertSent() checks that the mailable was synchronously sent — the transport was actually invoked (in the fake's memory). assertQueued() checks that the mailable was pushed to the queue. The distinction only matters when your mailable implements ShouldQueue. In that case, Mail::to()->send() routes through the queue system rather than the mailer directly, so assertSent() will fail and assertQueued() is the correct assertion. If you want to test mailable content in a queued scenario, either use assertQueued() with a closure, or temporarily remove ShouldQueue in the test by using Mail::to()->send() after calling Mail::fake() with the mailable instantiated directly without the queue interface.


Q: How do you assert that a mail was sent with specific content, like a particular order number in the subject?

Pass a closure as the second argument to assertSent(). The closure receives the mailable instance and must return true for the assertion to pass. You can inspect any public property, call methods on the mailable, or even call $mail->envelope() to check the subject: Mail::assertSent(OrderShipped::class, fn($mail) => str_contains($mail->envelope()->subject, '#1234')). You can also assert recipients using $mail->hasTo(), $mail->hasCc(), $mail->hasBcc(). This makes mail assertions genuinely useful rather than just checking the class name.


Q: Does Mail::fake() affect mailables sent inside queued jobs, and how do you handle that testing scenario?

Mail::fake() replaces the container binding for the duration of the test request, but queue workers run in separate processes — so if you dispatch a job that sends mail and immediately process it via Queue::fake() + manual job execution, the fake is still in scope and works. However, if you run actual workers in integration tests, they spawn separate processes where Mail::fake() is not in effect. The standard approach is to test the mailable sending in isolation using Mail::fake() on the synchronous path, and separately test that the job dispatches the mailable. Avoid end-to-end tests that go through real workers for mail assertions — keep that concern in focused feature tests.