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.
<?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
ShouldQueueis queued even when you callsend. 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.
<?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.