skip to content

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