Profile    Mohammed Shiroz Status   Loading  
Logo
Share This
Back to blog
Filter by:
Tags
//Article title

Laravel Testing With Fakes: Mail, Queue, Http, Storage and Notifications

About Post

Somewhere out there is a staging database full of users called "Test Test", and an inbox belonging to a real customer who received forty password reset emails because someone ran the test suite against the wrong .env.

Tests that touch the outside world are slow, flaky and occasionally embarrassing. They send real emails, call real payment APIs, write real files to S3 and push real jobs onto real queues. And when the third-party sandbox is down, your build is red for reasons that have nothing to do with your code.

Laravel's fakes fix this with one line each. But they also make it very easy to write a test that passes for the wrong reason. Let's do both halves: how they work, and where they lie to you.

What a fake actually does

When you call Mail::fake(), Laravel swaps the real mailer behind the Mail facade for a fake object that records everything instead of sending it. Your application code doesn't change at all. It still calls Mail::to($user)->send(...); the fake just writes it down.

Then you ask the fake questions: was this mailable sent? To whom? How many times? That's the whole idea, and every fake below follows the same pattern: fake first, act, then assert.

A test that uses three of them

Here's a typical feature test for placing an order. The controller charges the card through an HTTP API and emails a receipt:

public function test_placing_an_order_charges_and_emails_a_receipt(): void
{
    Mail::fake();
    Http::preventStrayRequests();
    Http::fake([
        'api.payments.test/*' => Http::response(['id' => 'ch_123', 'status' => 'paid']),
    ]);

    $user = User::factory()->create();
    $data = ['product_id' => Product::factory()->create()->id, 'quantity' => 2];

    $this->actingAs($user)
        ->post('/orders', $data)
        ->assertCreated();

    Mail::assertSent(Receipt::class);
    Http::assertSent(fn (Request $request) =>
        $request->url() === 'https://api.payments.test/charges'
    );
}

(Request here is Illuminate\Http\Client\Request, not the incoming HTTP request.)

Notice Http::preventStrayRequests(). Without it, any URL you didn't fake is sent for real. With it, an unexpected outgoing call throws an exception, and you find out that your code also calls a geocoding API you forgot about. I put it in every test that touches HTTP, or once in the base TestCase.

The fakes, one by one

Mail::fake()

Assert on the mailable class, and usually on the recipient too:

Mail::assertSent(Receipt::class, fn (Receipt $mail) => $mail->hasTo($user->email));
Mail::assertSent(Receipt::class, 1); // exactly once
Mail::assertNothingSent();

Queue::fake()

Records jobs instead of running them. Great for checking that slow work is pushed to the background:

Queue::fake();
// ... act ...
Queue::assertPushed(GenerateInvoicePdf::class, fn ($job) => $job->order->is($order));
Queue::assertNotPushed(SendSmsReminder::class);

If you dispatch chains or batches, Bus::fake() gives you assertions like Bus::assertChained() and Bus::assertBatched().

Http::fake()

Beyond the happy path, fake the failures. That's where the interesting bugs live. A sequence returns different responses on each call, perfect for testing retries:

Http::fake([
    'api.payments.test/*' => Http::sequence()
        ->push('Server Error', 500)
        ->push(['id' => 'ch_123', 'status' => 'paid'], 200),
]);

Storage::fake()

Replaces a disk with a temporary local one, so uploads never reach S3:

Storage::fake('s3');
$photo = UploadedFile::fake()->image('leak.jpg');

$this->actingAs($tenant)->post('/maintenance-requests', [
    'description' => 'Kitchen tap is leaking',
    'photo' => $photo,
]);

Storage::disk('s3')->assertExists('maintenance/'.$photo->hashName());

That path assumes the controller calls $request->file('photo')->store('maintenance', 's3'), which names the file by its hash.

Notification::fake()

Works per notifiable, and the callback can check which channels were used:

Notification::assertSentTo($tenant, RentReminder::class,
    fn ($notification, array $channels) => in_array('mail', $channels)
);

The gotchas that make tests lie

Fakes are so convenient that it's easy to forget what they switch off. These are the ones I see most.

Queue::fake() means your jobs never run

If the receipt email is sent from inside a queued job, then Queue::fake() plus Mail::assertSent() fails, because the job that sends the mail never executes. That's correct behaviour, not a bug. Split it into two tests: one asserts the job was pushed, the other runs the job directly and asserts what it does.

Queued mailables need assertQueued

If Receipt implements ShouldQueue, the fake records it as queued, and Mail::assertSent() fails. Use Mail::assertQueued() instead. The error message is clear once you know; the first time, it's confusing.

Event::fake() switches off model events too

Call Event::fake() with no arguments and every event is faked, including Eloquent's. If a model sets a UUID or slug in a creating hook, your factories suddenly produce models without one. Fake only what you need: Event::fake([OrderShipped::class]).

A fake only knows what you told it

Http::fake() returns whatever JSON you wrote. If the real API's response is shaped differently, your tests pass and production breaks. Base your fake responses on real responses from the provider's sandbox or docs, and keep them in fixture files so they're easy to update.

A good habit: for every assertSent, ask what the negative test looks like. Invalid order? Mail::assertNothingSent(). Payment declined? Queue::assertNotPushed(GenerateInvoicePdf::class). The side effects that must not happen are often the ones that matter most.

Where fakes stop being enough

Fakes test that your code asked for the right side effect. They don't test that the email template renders, that the S3 bucket policy allows the upload, or that the payment provider accepts your payload. For those, render mailables in their own tests ($mailable->assertSeeInHtml(...)), and keep a small number of real integration checks against sandboxes, run separately from the fast suite.

The fast suite stays fast, offline and deterministic. That's the deal.

Quick checklist

  • Fake before you act, assert after.
  • Http::preventStrayRequests() everywhere.
  • Test failure responses, not only success.
  • Remember what Queue::fake() and Event::fake() switch off.
  • Write the negative assertion too.

Which fake has caught you out the most? My bet is the queued mailable: almost everyone stares at a failing assertSent at least once before spotting ShouldQueue.

Comments (0)
Leave your review

Thanks for your valuable comments. Your comments has been updated and appreciate your getting in touch...

01. About Shiroz

Mohammed Shiroz

Hi, I'm Mohammed Shiroz, a software engineer and AI enthusiast from Sri Lanka who turns ideas into intelligent, real-world solutions. With over 9 years of hands-on experience, I currently lead real estate ERP development at Kate Group, a...

03.My Projects

04. Categories

Ready To order Your Project ?

Get in Touch
Close