/ Content
Queued jobs are one of those things that feel easy until you try to write tests for them. You fake the queue, dispatch a job, assert it was dispatched, great. Then you realize you have no idea if the job actually works. Or worse, you don't fake the queue, the job runs synchronously in your test, hits a third-party API, and now your test suite takes four minutes and occasionally fails because Stripe is having a bad day.
I've been through all of it. Here's what I've settled on.
The Two-Layer Testing Strategy
The mental model that finally made queue testing click for me: you need two kinds of tests.
- Dispatch tests: Did the right job get dispatched with the right data at the right time?
- Execution tests: Does the job do the right thing when it actually runs?
Trying to do both in a single test is where the pain starts. Separate them, and everything gets simpler.
Testing That Jobs Are Dispatched
Bus::fake() is your best friend for dispatch tests. It intercepts every job dispatch and records it instead of actually putting anything on the queue.
public function testOrderCompletionDispatchesInvoiceJob(): void
{
Bus::fake();
$order = Order::factory()->create();
$this->postJson("/api/orders/{$order->id}/complete")
->assertOk();
Bus::assertDispatched(GenerateInvoice::class, function (GenerateInvoice $job) use ($order) {
return $job->orderId === $order->id;
});
}
That closure in assertDispatched is critical. Without it, you're only checking that some GenerateInvoice job was dispatched, but not that it was for the right order. I've seen tests pass for months with this bug, only catching it when someone dispatched the job for a hardcoded ID during development and the test still went green.
You can also assert that certain jobs were not dispatched, which is just as important:
Bus::assertNotDispatched(SendRefundNotification::class);
The Classic Mistake: Faking and Wondering Why Nothing Happened
This one gets everybody at least once. You fake the queue, dispatch a job, and then assert something the job was supposed to do, like checking that an invoice record exists in the database. The test fails, and you spend twenty minutes debugging the job itself before realizing: the job never ran. That's the whole point of faking.
Bus::fake() prevents jobs from executing. It only records that they were dispatched. If you need to test side effects, you need a separate test where the job actually runs. Which brings us to...
Testing Job Logic Directly
For execution tests, just instantiate the job and call handle(). No queue involved. You're testing the class as a unit.
public function testGenerateInvoiceCreatesInvoiceRecord(): void
{
$order = Order::factory()->create([
'total' => 5000,
]);
$job = new GenerateInvoice($order->id);
$job->handle();
$this->assertDatabaseHas('invoices', [
'order_id' => $order->id,
'amount' => 5000,
]);
}
If your job has dependencies that get injected via the handle method (which is the Laravel convention), you can pass them in or let the container resolve them:
public function testJobSendsEmail(): void
{
Mail::fake();
$user = User::factory()->create();
$job = new SendWelcomeEmail($user->id);
app()->call([$job, 'handle']);
Mail::assertSent(WelcomeMail::class, function (WelcomeMail $mail) use ($user) {
return $mail->hasTo($user->email);
});
}
Using app()->call() lets the container resolve any type-hinted dependencies in your handle method. This matters because if your job injects a service class, you don't want to manually wire that up in every test.
Testing Job Chains
Job chains are where testing gets tricky. You've got multiple jobs that need to run in sequence, and you want to verify the chain is set up correctly.
public function testOrderProcessingDispatchesChain(): void
{
Bus::fake();
$order = Order::factory()->create();
ProcessOrder::dispatch($order->id);
Bus::assertChained([
new ProcessPayment($order->id),
new GenerateInvoice($order->id),
new SendConfirmationEmail($order->id),
]);
}
assertChained checks that those jobs were dispatched as a chain, in that order. If your chain is dispatched inside a controller action, you'd hit the endpoint first, then assert the chain. This is one of those assertions where getting the order wrong tells you something useful: your business process has a bug, not your test.
Testing Failed Job Handling
If your job implements a failed method, you should test that too. This is the method Laravel calls when a job exceeds its retry limit, and it's often where you send alert notifications or clean up partial state.
public function testFailedJobNotifiesAdmin(): void
{
Notification::fake();
$order = Order::factory()->create();
$job = new ProcessOrder($order->id);
$exception = new PaymentFailedException('Card declined');
$job->failed($exception);
Notification::assertSentTo(
User::factory()->admin()->create(),
OrderFailedNotification::class
);
}
Just call the failed method directly with an exception. No need to actually make the job fail through the queue system, that would be slow and fragile. You're testing the failure handling logic, not Laravel's retry mechanism.
Queue::fake() vs. Bus::fake()
This confused me for a while. Queue::fake() fakes the queue connection, so jobs get intercepted at the queue driver level. Bus::fake() fakes the command bus, so jobs get intercepted before they even reach the queue.
For most testing scenarios, Bus::fake() is what you want. It has better assertion methods and works with chains and batches. Queue::fake() is more useful when you need to test queue-specific behavior, like verifying a job was pushed to a specific queue:
Queue::fake();
ProcessOrder::dispatch($order->id)->onQueue('payments');
Queue::assertPushedOn('payments', ProcessOrder::class);
I default to Bus::fake() and only reach for Queue::fake() when I specifically care about queue names or connections.
Partial Faking
One last pattern that's been useful. Sometimes you want most jobs to be faked, but one specific job to actually run. Bus::fake() accepts an array of classes to fake, and everything else runs normally:
Bus::fake([SendNotification::class]);
// This runs for real
ProcessOrder::dispatch($order->id);
// This gets faked
Bus::assertDispatched(SendNotification::class);
I use this when I have a controller that dispatches multiple jobs and I only want to prevent the side-effecty ones (emails, notifications, API calls) from running, while letting the core business logic execute so I can assert on database state.
The Short Version
Separate dispatch tests from execution tests. Use Bus::fake() for dispatch assertions. Call handle() directly for execution tests. Don't forget to test your failed method. And if your tests are green but the job doesn't work in production, double-check that you're not only testing the dispatch and never the logic. I've seen that one more times than I'd like to admit.