Tech Verse Logo
Enable dark mode
Testing Mail in Laravel: Mailables and Assertions

Testing Mail in Laravel: Mailables and Assertions

Md. Mostafijur RahmanMMd. Mostafijur Rahman

Md. Mostafijur Rahman

4 min read

Previewing Mailables in the Browser

Sending test emails to an external inbox like Mailtrap or a local SMTP container like Mailpit every time you adjust a CSS padding rule in a Blade template is a massive time sink. Each round trip takes 300ms to two seconds. In Laravel 12, mailables implement the Illuminate\Contracts\Renderable interface. This means you can return a mailable directly from a route or controller action to render the final HTML right in your browser.

Here is how you set up a quick preview route in your routes/web.php file during local development:

use App\Mail\OrderShipped;
use App\Models\Order;
use Illuminate\Support\Facades\Route;

if (app()->environment('local')) {
    Route::get('/mailable/order-shipped', function () {
        $order = Order::with('items')->first() ?? Order::factory()->create();

        return new OrderShipped($order);
    });
}

When you hit /mailable/order-shipped, Laravel compiles the Markdown template, applies the default responsive email inline styles, and outputs the raw HTML. You get sub-50ms feedback loops while tweaking layout details.

If you want to view the plain text version of your mailable instead of the rendered HTML, call the render() method directly or inspect the alternative text output by returning the rendered view string. That helps verify that users on plain-text mail clients still get a readable message.

Fake First, Assert Later

When writing automated tests, hitting an actual SMTP server slows down your test suite and introduces external dependencies. Call Mail::fake() at the start of your test to prevent Laravel from attempting to deliver anything over the network.

The call swaps the production mailer inside the service container with an instance of Illuminate\Support\Testing\Fakes\MailFake. From that point on, every outgoing email is trapped in memory for inspection.

Here is a complete Pest test showing how to verify both queued and synchronously sent emails in Laravel 12 using PHP 8.3:

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

test('order checkout triggers the shipped email', function () {
    Mail::fake();

    $user = User::factory()->create();
    $order = Order::factory()->for($user)->create([
        'total_amount' => 14999,
    ]);

    $response = $this->actingAs($user)
        ->postJson("/api/orders/{$order->id}/ship");

    $response->assertStatus(200);

    // Assert that a mailable was sent to a specific address
    Mail::assertSent(OrderShipped::class, function (OrderShipped $mail) use ($user, $order) {
        return $mail->hasTo($user->email) &&
               $mail->order->id === $order->id;
    });

    // Verify no other mails were sent accidentally
    Mail::assertSentCount(1);
});

Notice the order of execution. You must invoke Mail::fake() before triggering the application logic. If you swap those lines, the controller fires the real mailer before the fake replaces it in the container, and your assertions will fail with an unhelpful error message stating that no mail was sent.

Testing Queued vs Synchronous Mailables

Mailables that implement ShouldQueue behave differently under the hood. If your mailable is queued, calling Mail::assertSent() will fail because the email hasn't actually been dispatched to the transport yet; it was pushed to your queue connection.

For queued mailables, use Mail::assertQueued() instead:

// Verifies the mailable was pushed to the queue worker
Mail::assertQueued(OrderShipped::class, function (OrderShipped $mail) use ($user) {
    return $mail->hasTo($user->email);
});

// Verifies that a specific mailable was never queued
Mail::assertNotQueued(WelcomeMessage::class);

If your test suite sets QUEUE_CONNECTION=sync in phpunit.xml or .env.testing, queued mailables execute immediately during the test process. However, Mail::fake() still records them as queued items rather than sent items. Keep this distinction clear in your test assertions to avoid false negatives.

Inspecting Rendered Content and Recipients

Testing that a mailable class was dispatched gets you halfway there, but it doesn't guarantee the content rendered without errors. A missing variable reference inside a Markdown component will throw a Blade exception when rendered, even if the mailable object constructs perfectly fine in your controller.

To guard against broken templates, render the mailable inside your test and assert against its final string output:

test('order shipped mailable renders correct HTML content', function () {
    $order = Order::factory()->create([
        'reference_number' => 'ORD-2026-8812',
        'total_amount' => 8900,
    ]);

    $mailable = new OrderShipped($order);

    // Renders the blade template to a string
    $html = $mailable->render();

    expect($html)->toContain('ORD-2026-8812')
        ->toContain('$89.00')
        ->toContain('Track Your Package');
});

This approach catches missing array keys, null object references, and broken Markdown syntax before your code reaches production. It runs entirely in memory without touching any network layers, executing in under 10ms.

Envelope and Attachment Assertions

Laravel 12 mailables define metadata using the envelope() and attachments() methods. You can test these methods directly on the mailable instance without calling the fake mailer facade.

Here is how you write unit assertions for subject lines, from addresses, and attached files:

test('mailable contains correct envelope and attachments', function () {
    $order = Order::factory()->create();
    $mailable = new OrderShipped($order);

    $envelope = $mailable->envelope();
    expect($envelope->subject)->toBe('Your order ORD-' . $order->id . ' has shipped!');

    $attachments = $mailable->attachments();
    expect($attachments)->toHaveCount(1);

    $attachment = $attachments[0];
    expect($attachment->as)->toBe('invoice.pdf');
});

Common Pitfalls in Mail Testing

Working with Laravel mail fakes in complex applications comes with a few traps that catch developers off guard.

  • Order of Faking: Always call Mail::fake() before action triggers or event listeners fire. Calling it mid-way leaves previous emails untracked and lets subsequent real mail attempts escape into your development mail server.
  • Event Listeners and Queues: If your email is dispatched inside an Event Listener that implements ShouldQueue, running Mail::fake() won't catch the email if the queue isn't processing synchronously during the test. Make sure you either test the listener directly or set your queue driver to sync inside the test environment.
  • Mail::assertNothingSent vs Mail::assertNothingQueued: If your application queues emails, Mail::assertNothingSent() will pass even if 20 emails were pushed to the queue. Always use Mail::assertNothingQueued() when checking background mail operations.
  • Shared State Across Tests: In Pest or PHPUnit, Mail::fake() replaces the singleton bound in the application container. If you manually construct app instances across tests without refreshing the application state, stale trapped emails from a previous test can corrupt count assertions. Stick to standard database reset traits and framework-provided test cases.

Using Mail::fake() along with direct mailable string rendering gives you fast, reliable test coverage for your entire transactional email pipeline without burning API credits or sending test messages to dead ends.

Md. Mostafijur RahmanMMd. Mostafijur Rahman

WRITTEN BY

Md. Mostafijur Rahman

    Latest Posts

    View All

    Laravel Signed URLs and One-Time Download Links

    Laravel Signed URLs and One-Time Download Links

    Laravel Timezone Handling: UTC, Users, and DST Bugs

    Laravel Timezone Handling: UTC, Users, and DST Bugs

    Laravel Login Throttle: Rate Limiting and Credential Defense

    Laravel Login Throttle: Rate Limiting and Credential Defense

    Building Honest Health Check Endpoints in Laravel

    Building Honest Health Check Endpoints in Laravel

    Writing Production-Ready Laravel Artisan Commands

    Writing Production-Ready Laravel Artisan Commands

    Testing Mail in Laravel: Mailables and Assertions

    Testing Mail in Laravel: Mailables and Assertions

    Solving Low-Priority Queue Starvation in Laravel

    Solving Low-Priority Queue Starvation in Laravel

    Realistic Laravel Seeders with States and Relations

    Realistic Laravel Seeders with States and Relations

    Fixing Laravel Broadcasting Auth and 403 Errors

    Fixing Laravel Broadcasting Auth and 403 Errors

    Laravel Multi Tenancy: Single vs Multi Database

    Laravel Multi Tenancy: Single vs Multi Database