skip to content

Fakes, Mocks & Time Travel

Swapping Laravel services for fakes, mocks and a frozen clock: Queue, Mail, Event and Http fakes, facade shouldReceive and travelTo. Interviewers probe what each fake stops and asserts.

on this pageshow

explore

questions

6

In a Laravel feature test, how do you assert that issuing a refund queues a job and sends a mailable without running or sending either?

level: middleimportance: must knowfreq 64%

answer

  1. fake first, act, then assert
  2. Queue::fake() or Bus::fake()
  3. assertPushed vs assertDispatched
  4. Mail::fake() with assertSent
  5. ShouldQueue mailable needs assertQueued

basics

~10 s

Call Queue::fake() (or Bus::fake()) and Mail::fake() before the request, then assert with Queue::assertPushed(ProcessRefund::class) and Mail::assertSent(RefundIssued::class). The fakes record instead of pushing or delivering, so neither the job nor the mail runs.

solid answer

~30 s

Swap the services for fakes **before** acting: `Queue::fake()` and `Mail::fake()`. Then hit the endpoint and assert on what was recorded: `Queue::assertPushed(ProcessRefund::class, fn ($job) => $job->refund->is($refund))` and `Mail::assertSent(RefundIssued::class, fn ($mail) => $mail->hasTo($user->email))`. `Queue::fake()` only sees jobs that implement `ShouldQueue`; `Bus::fake()` with `Bus::assertDispatched()` records every dispatch, queued or not, and adds chain and batch assertions. If the mailable implements `ShouldQueue`, `Mail::fake()` files it as queued, so the right assertion is `Mail::assertQueued()`; `assertSent()` fails and its message suggests `assertQueued()`. The job's own `handle()` is tested separately by instantiating the job and calling it.

code

php · 25 lines
php
<?php

use App\Jobs\ProcessRefund;
use App\Mail\RefundIssued;
use App\Models\Order;
use App\Models\Refund;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;

test('issuing a refund queues processing and mails the customer', function () {
    Queue::fake();
    Mail::fake();

    $order = Order::factory()->paid()->create();

    $this->actingAs($order->customer)
        ->post("/orders/{$order->id}/refunds", ['amount' => 1500])
        ->assertRedirect();

    $refund = Refund::sole();

    Queue::assertPushedOnce(ProcessRefund::class);
    Queue::assertPushed(ProcessRefund::class, fn ($job) => $job->refund->is($refund));
    Mail::assertSent(RefundIssued::class, fn ($mail) => $mail->hasTo($order->customer->email));
});

go deeper

for a junior

Remember the order fake, act, assert, and the pairs: Queue::fake with assertPushed, Mail::fake with assertSent.

for a middle

Explain why Queue::fake misses jobs without ShouldQueue while Bus::fake records every dispatch, and why a ShouldQueue mailable needs assertQueued.

for a senior

Show that closure assertions prove the right refund and recipient, and that serializeAndRestore and a separate handle() test close the gaps the fake leaves.

for a principal

Frame fakes as a contract test of the controller's intent: decide which side effects are asserted as recorded and which get their own direct tests.

## What a fake is in Laravel Laravel ships **fakes** for the services that cause side effects: `Queue`, `Bus`, `Mail`, `Notification`, `Event`, `Http`, `Storage`, `Process` and `Exceptions`. Calling `Queue::fake()` does not stub one method: it **swaps the object behind the facade** for a recording object (`QueueFake`, `MailFake`, `BusFake` and so on) that keeps every job or mailable it receives in memory and exposes assertion methods. Because the swap happens in the container, your controller keeps calling `ProcessRefund::dispatch()` and `Mail::to()->send()` unchanged; it simply talks to the recorder. The order matters: **fake, act, assert**. A fake installed after the request has run records nothing. ## The refund test Suppose `POST /orders/{order}/refunds` creates a `Refund`, dispatches `ProcessRefund` (which calls the payment provider) and mails the customer a `RefundIssued` mailable. A feature test wants to prove the controller did both, without calling the provider or delivering mail. ```php Queue::fake(); Mail::fake(); $this->actingAs($user)->post("/orders/{$order->id}/refunds", ['amount' => 1500]); $refund = Refund::sole(); Queue::assertPushed(ProcessRefund::class, fn (ProcessRefund $job) => $job->refund->is($refund)); Queue::assertPushedOnce(ProcessRefund::class); Mail::assertSent(RefundIssued::class, fn (RefundIssued $mail) => $mail->hasTo($user->email)); ``` The closure form is the valuable part: asserting only the class proves *a* refund job was pushed, while the closure proves it carries **this** refund and goes to **this** customer. ## `Queue::fake()` versus `Bus::fake()` | | `Queue::fake()` | `Bus::fake()` | |---|---|---| | What it replaces | the queue manager | the job dispatcher | | Records | jobs pushed to a queue, i.e. `ShouldQueue` jobs, plus queued mail, notifications and listeners | every `dispatch()`, `dispatchSync()` and after-response dispatch | | Main assertions | `assertPushed`, `assertPushedOn`, `assertPushedTimes`, `assertNothingPushed` | `assertDispatched`, `assertDispatchedSync`, `assertChained`, `assertBatched` | | A job **without** `ShouldQueue` | runs immediately, is not recorded | recorded, not run | The last row is the usual surprise. The bus dispatcher only sends a job to the queue when it implements `ShouldQueue`; otherwise it calls `handle()` on the spot. Under `Queue::fake()` such a job therefore **executes** and `assertPushed` fails. `Bus::fake()` intercepts earlier, at the dispatcher, so it records both kinds. Both fakes accept a list to fake only some classes (`Queue::fake([ProcessRefund::class])`) and an `except()` to leave some real. ## Sent versus queued mail `MailFake` keeps two lists. A mailable that implements `ShouldQueue`, or one passed to `Mail::queue()`, lands in the **queued** list even when the code called `send()`. So: - `Mail::assertSent()`, `assertSentOnce()`, `assertNotSent()` and `assertNothingSent()` look at mail sent synchronously. - `Mail::assertQueued()`, `assertQueuedOnce()` and `assertNothingQueued()` look at queued mail. - `Mail::assertNothingOutgoing()` covers both. When `assertSent()` fails while something was queued, its failure message ends with a hint to use `assertQueued()` instead. `Notification::fake()` follows the same idea with `Notification::assertSentTo($user, RefundIssuedNotification::class)`. ## What the fakes do not cover 1. **The job's own logic.** The fake proves the controller *asked* for the work. Test `ProcessRefund::handle()` in its own test by constructing the job and calling it, with the payment client faked there. 2. **Serialization.** A fake stores the live object, so a job whose payload cannot be serialized still passes. `Queue::fake()->serializeAndRestore()` round-trips each job through serialization to catch that. 3. **Things other code does for real.** Without fakes, the skeleton's `phpunit.xml` already sets `QUEUE_CONNECTION=sync` and `MAIL_MAILER=array`, so jobs run inline and mail goes to an in-memory transport. That keeps tests off the network but gives you nothing to assert on and runs the payment job for real, which is exactly what the fakes prevent. ## Narrower fakes and the unhappy path A test does not have to fake everything. `Queue::fake([ProcessRefund::class])` records only that job and lets every other queued job behave as configured, which keeps unrelated jobs from silently disappearing. `Queue::fake()->except([AuditLog::class])` inverts the list. `Bus::fake()` takes the same kind of list. The negative case deserves its own test. A refund above the order total should be rejected, and the proof is the absence of side effects: ```php Queue::assertNothingPushed(); Mail::assertNothingOutgoing(); ``` When an assertion helper does not fit, the fakes expose the raw records: `Queue::pushed(ProcessRefund::class)` and `Mail::sent(RefundIssued::class)` return collections you can inspect directly, and `Mail::assertSentCount()` or `Queue::assertCount()` check totals. ## Common mistakes - Asserting only the class name, so a refund for the wrong order still passes. - Using `Queue::assertPushed` for a job that lacks `ShouldQueue`. - Using `assertSent` for a mailable that implements `ShouldQueue`. - Faking after the request, then wondering why nothing was recorded.

  • The team switches RefundIssued to implement ShouldQueue and the test starts failing. Why, and what is the fix?
    `MailFake` files a `ShouldQueue` mailable in its queued list even when the code calls `send()`, so `Mail::assertSent()` finds nothing. Change the assertion to `Mail::assertQueued(RefundIssued::class, ...)`. The failure message from `assertSent()` already hints at `assertQueued()` when queued mail exists.
  • The refund flow dispatches a chain: ProcessRefund then NotifyAccounting. How do you assert the chain?
    Use `Bus::fake()` and `Bus::assertChained([ProcessRefund::class, NotifyAccounting::class])`, which checks the jobs and their order. With `Queue::fake()` you would use `Queue::assertPushedWithChain(ProcessRefund::class, [NotifyAccounting::class])` on the first job instead.
  • How do you make sure a queued job's payload would survive serialization in the fake?
    Call `Queue::fake()->serializeAndRestore()` (Bus fakes have the same method). The fake then serializes and unserializes each job before recording it, so a closure, a resource handle or another unserializable property fails the test instead of failing on a real worker.

saying these in an interview costs you the question

  • Queue::fake() records every dispatched job, even ones without ShouldQueue
  • Mail::assertSent() also passes for a mailable that implements ShouldQueue
  • The fake can be installed after the request and still records it
  • Asserting the job class alone proves the right refund was processed
  • Mail::fake() delivers mail to the log so you can inspect it
  • Faking the queue also tests the job's handle() logic
open as a page

In a Laravel test, how do you move the clock forward to check that a refund request window closes after 14 days?

level: juniorimportance: should knowfreq 44%

basics

~10 s

Use the test case's time helpers: $this->travel(15)->days() or $this->travelTo($date) set Carbon's test time, so now() and Eloquent timestamps see the new date. travelBack() or a closure restores real time, and teardown resets it anyway.

open as a page

In a Laravel test, what does Event::fake() stop from running, and how do you fake only some events?

level: middleimportance: should knowfreq 42%

basics

~10 s

Event::fake() swaps the dispatcher for a recorder, so no listener runs, including Eloquent model-event listeners and observers. Pass a list, Event::fake([RefundIssued::class]), to fake only those; everything else dispatches normally.

open as a page

In a Laravel test, how do Cache::shouldReceive(), a facade spy and $this->mock() differ when you replace a collaborator?

level: middleimportance: should knowfreq 48%

basics

~10 s

Cache::shouldReceive() swaps the facade's instance for a Mockery mock with expectations set up front; a spy records every call for shouldHaveReceived() afterwards; $this->mock(Service::class) binds a Mockery mock in the container for injected classes.

open as a page

In a Laravel test suite, how do Http::fake() and Http::preventStrayRequests() keep tests from calling a real payment provider's refund API?

level: seniorimportance: should knowfreq 38%

basics

~10 s

Http::fake() answers requests made through Laravel's HTTP client with stubs and records them for Http::assertSent(). Http::preventStrayRequests() makes any request without a matching stub throw a StrayRequestException instead of reaching the network.

open as a page

In Laravel 13, how do Sleep::fake() and Str::freezeUuids() make a refund retry's back-off and generated reference testable?

level: middleimportance: nice to knowfreq 22%

basics

~10 s

Sleep::fake() makes Laravel's Sleep class record durations instead of pausing, so back-off can be asserted with Sleep::assertSequence(). Str::freezeUuids() makes Str::uuid() return one known value; Laravel 13 resets these Str factories after each test.

open as a page