Laravel email tests: Mail::fake, Dusk and a real inbox

Updated October 17, 2026 · Zyntra

Laravel's phpunit.xml sets MAIL_MAILER=array, and Mail::fake() records mailables instead of sending them. That covers who gets which mailable and what it contains. It doesn't reach Dusk, whose browser talks to a separately running app, or staging, where alwaysTo and a stopped queue worker can keep real email from ever arriving.

Feature tests: Mail::fake and mailable assertions

The default phpunit.xml has MAIL_MAILER=array and QUEUE_CONNECTION=sync. Mail::fake() goes further and records each mailable, so a test asserts what was sent; for mailables that implement ShouldQueue, use assertQueued instead of assertSent.

tests/Feature/SignupTest.php
<?php

use App\Mail\ConfirmEmail;
use Illuminate\Support\Facades\Mail;

test('sign-up sends the confirmation mailable', function () {
    Mail::fake();

    $this->post('/register', [
        'name' => 'Ana',
        'email' => '[email protected]',
        'password' => 'a-long-test-password',
        'password_confirmation' => 'a-long-test-password',
    ]);

    // ConfirmEmail implements ShouldQueue, so it is queued, not sent.
    Mail::assertQueued(ConfirmEmail::class, function (ConfirmEmail $mail) {
        return $mail->hasTo('[email protected]');
    });
});

For the content, build the mailable and use its own assertions: assertHasSubject, assertSeeInHtml, assertSeeInText, assertHasAttachedData.

Dusk: the fake doesn't reach the app

Dusk drives a real browser against your app served by a separate process, configured from a .env.dusk.{environment} file when one exists. Mail::fake() inside a Dusk test changes the test process only; the app that handles the browser's sign-up uses whatever mailer that environment file sets. So a Dusk test of the email step needs the app to really send: an SMTP mailer pointed at Mailpit locally, or your real provider on staging.

Staging: alwaysTo and the queue

  • alwaysTo. Laravel's docs suggest Mail::alwaysTo() in a service provider to send all mail to one address outside production. It also removes cc and bcc. With it on in staging, a test inbox never sees an email, and a test of a cc'd copy can't pass.
  • Queued mail. A mailable that implements ShouldQueue is queued even when you call send. On staging it goes out only if a queue worker is running and its job succeeds.
  • After commit. Queued mail dispatched inside a database transaction can be set to wait for the commit (afterCommit()); a rolled-back transaction then sends nothing, which is correct, and only an end-to-end test shows it.

A Dusk test that reads a real inbox

With the app sending for real, the test builds a fresh address, signs up in the browser and reads the email with Laravel's HTTP client.

tests/Browser/SignupEmailTest.php
<?php

namespace Tests\Browser;

use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Laravel\Dusk\Browser;
use Tests\DuskTestCase;

class SignupEmailTest extends DuskTestCase
{
    public function test_confirmation_email_arrives(): void
    {
        $address = env('ZYNTRA_TEAM_ID').'.laravel-'.Str::lower(Str::random(8)).'@zyntramail.com';

        $this->browse(function (Browser $browser) use ($address) {
            $browser->visit('/register')
                ->type('name', 'Dusk Test')
                ->type('email', $address)
                ->type('password', 'a-long-test-password')
                ->type('password_confirmation', 'a-long-test-password')
                ->press('Register');

            // The server waits up to 30 s for the first email to the address.
            $email = Http::withHeaders(['X-API-Key' => env('ZYNTRA_API_KEY')])
                ->timeout(45)
                ->get('https://api.zyntra.app/emails/last', ['email' => $address])
                ->throw()
                ->json();

            $links = array_values(array_filter(
                array_column($email['links'], 'url'),
                fn ($url) => str_contains($url, '/email/verify/'),
            ));
            $this->assertCount(1, $links);

            $browser->visit($links[0])->assertSee('verified');
        });
    }
}

/email/verify/ is the path of Laravel's built-in verification route; match your own route if you changed it. The verification link is signed, so it only works on the host it was generated for, which makes it a good check that APP_URL is right on staging.

Questions

Can I use Mailpit for Dusk instead?
Locally, yes: Laravel Sail runs Mailpit, and its interface is at http://localhost:8025. It catches mail sent over SMTP; mail sent through a provider's HTTP API (the ses, postmark or resend mailers) never reaches it.