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?
answer
- fake first, act, then assert
- Queue::fake() or Bus::fake()
- assertPushed vs assertDispatched
- Mail::fake() with assertSent
- ShouldQueue mailable needs assertQueued
basics
~10 sCall 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 sSwap 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
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
Remember the order fake, act, assert, and the pairs: Queue::fake with assertPushed, Mail::fake with assertSent.
Explain why Queue::fake misses jobs without ShouldQueue while Bus::fake records every dispatch, and why a ShouldQueue mailable needs assertQueued.
Show that closure assertions prove the right refund and recipient, and that serializeAndRestore and a separate handle() test close the gaps the fake leaves.
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