- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You open a test that's been red on CI for a week. It dispatches SendInvoiceEmail, then sleeps for two seconds, then checks the database for a sent-mail record. Locally it passes. On CI it fails one run in five. Someone added sleep(2) to make it flake-proof. It got flakier.
The test is hitting a real queue. A worker has to pick the job up, run it, and write the row before the assertion fires. That timing is not yours to control. The queue driver, the worker poll interval, the CI machine's load all decide when the job runs. Your test is asserting against a race.
You don't need a real queue to test a job. You need to know one of two things: did the code dispatch the right job, or does the job's handle() do the right thing. Those are different questions, and Laravel gives you a clean tool for each.
Two questions, never one test
Split the concern before you write a line. A job has two sides:
- The caller side: some controller or service ran
SendInvoiceEmail::dispatch($invoice). You want to prove the dispatch happened, with the right payload, on the right queue. - The handler side: given the job,
handle()sends the email and marks the invoice. You want to prove that logic in isolation.
Mixing them is where the flake comes from. A test that dispatches and waits for the worker is testing the framework's queue plumbing, not your code. Laravel already tests its own queue. You don't have to.
Queue::fake() proves the dispatch, not the work
Queue::fake() swaps the queue driver for an in-memory spy. Nothing gets pushed to Redis. Nothing runs. Every dispatch() is recorded so you can assert against it after.
<?php
namespace Tests\Feature;
use App\Jobs\SendInvoiceEmail;
use App\Models\Invoice;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class InvoiceCheckoutTest extends TestCase
{
public function test_checkout_queues_the_invoice_email(): void
{
Queue::fake();
$invoice = Invoice::factory()->create();
$this->post("/invoices/{$invoice->id}/pay")
->assertOk();
Queue::assertPushed(
SendInvoiceEmail::class,
fn (SendInvoiceEmail $job) =>
$job->invoice->is($invoice)
);
}
}
The closure is the part that earns its keep. assertPushed without it proves a job of that class was dispatched. With it, you prove the right one went out — the correct invoice, not some other row from a factory. Assert on the payload, not just the class.
The matching negative assertions keep you honest:
Queue::assertPushedOn('emails', SendInvoiceEmail::class);
Queue::assertNotPushed(ChargeCard::class);
Queue::assertPushed(SendInvoiceEmail::class, 1); // exactly once
Queue::assertNothingPushed();
assertNotPushed is the one people skip. A paid invoice should send an email and not re-charge the card. Proving the thing that must not happen is as much a test as proving the thing that must.
One catch. Queue::fake() also stops chained and delayed jobs from running, so anything downstream of the dispatch won't execute in that test. That's correct for a caller test. If you find yourself wanting the job to actually run, you're in a handler test, and you're reaching for the wrong tool.
Bus::fake() is for batches and chains
Queue::fake() covers plain dispatches. The moment you use Bus::batch() or Bus::chain(), switch to Bus::fake(). It records batched and chained work that Queue::fake() doesn't model well.
<?php
namespace Tests\Feature;
use App\Jobs\GenerateStatementChunk;
use Illuminate\Bus\PendingBatch;
use Illuminate\Support\Facades\Bus;
use Tests\TestCase;
class StatementBatchTest extends TestCase
{
public function test_month_end_dispatches_a_statement_batch(): void
{
Bus::fake();
$this->artisan('statements:run --period=2026-06');
Bus::assertBatched(
fn (PendingBatch $batch) =>
$batch->name === 'statements:2026-06'
&& $batch->jobs->count() === 4
&& $batch->jobs->first()
instanceof GenerateStatementChunk
);
}
}
PendingBatch gives you the batch before it dispatches: its name, its queue, and the collection of jobs inside it. Assert the shape of the fan-out without running a single child. For chains, Bus::assertChained([First::class, Second::class]) checks order.
You can scope the fake so only some jobs are faked and the rest run for real:
Bus::fake([GenerateStatementChunk::class]);
Handy when a command dispatches a batch you want to inspect but also fires a small bookkeeping job you'd rather let execute.
Test the handler by calling handle() directly
The caller tests above never run your job's logic. That's the handler's own test, and the cleanest way to write it is to skip the queue entirely and call the method.
A job is a plain PHP object. Construct it, call handle(), assert on the effects. Laravel's container resolves the arguments, so type-hint the dependencies on handle() and let app()->call() inject them:
<?php
namespace Tests\Feature;
use App\Jobs\SendInvoiceEmail;
use App\Models\Invoice;
use App\Mail\InvoiceMail;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
class SendInvoiceEmailTest extends TestCase
{
public function test_handle_emails_the_customer(): void
{
Mail::fake();
$invoice = Invoice::factory()->create([
'status' => 'paid',
]);
$job = new SendInvoiceEmail($invoice);
app()->call([$job, 'handle']);
Mail::assertSent(
InvoiceMail::class,
fn (InvoiceMail $mail) =>
$mail->hasTo($invoice->customer_email)
);
$this->assertNotNull($invoice->refresh()->emailed_at);
}
}
No queue, no worker, no sleep. The job runs inline, in-process, deterministically. Mail::fake() catches the outbound mail the same way Queue::fake() caught the dispatch. You're asserting two effects: the mail went out to the right address, and the invoice got stamped.
If handle() has no dependencies to inject, $job->handle() is enough. Reach for app()->call() only when the method type-hints services.
dispatchSync when you want the pipeline but not the wait
There's a middle ground. Sometimes you want the job to run through the real dispatch path (middleware, ShouldQueue, the works) but synchronously, so the test doesn't wait on a worker. That's dispatchSync():
public function test_paying_sends_the_email_inline(): void
{
Mail::fake();
$invoice = Invoice::factory()->create();
SendInvoiceEmail::dispatchSync($invoice);
Mail::assertSent(InvoiceMail::class);
}
dispatchSync runs the job in the current process, right now, and returns after it finishes. Job middleware still applies, which is the difference from calling handle() raw. Use it when the middleware is part of what you're testing — rate limiting, WithoutOverlapping, encryption. Use the direct handle() call when you want the logic alone with nothing in the way.
One thing dispatchSync does not do: it won't retry on failure or respect $backoff. Those are worker behaviors. If your test needs retry semantics, that's a different, narrower test against the job's retryUntil() or $tries config, not an end-to-end run.
Why a real queue in a unit test is a smell
Step back and name the anti-pattern. A test that pushes to Redis and waits for a worker couples three things that should be independent: your code, the queue driver, and wall-clock timing. When it fails, you can't tell which of the three broke. That's the definition of a flaky test — a failure that doesn't map to a cause.
There's a narrow, honest exception. An integration test that deliberately boots a worker to prove your Horizon config, serializer, and connection all line up is worth having. One or two of those, run on a schedule, guarding the wiring. Not three hundred of them standing in for unit tests, each paying the Redis round-trip tax and each carrying its own small chance of a race.
The QUEUE_CONNECTION=sync shortcut in phpunit.xml is a related trap. It makes every dispatch run inline, which sounds convenient, but it means your caller tests silently execute the handler too. A controller test that should only prove "the job was queued" ends up running the email send, hitting the mailer, touching whatever the handler touches. Keep sync out of the test env and reach for Queue::fake() per test where you actually want it. Explicit beats ambient.
The shape that scales
Three habits keep a growing job suite fast and honest:
-
Caller tests use
Queue::fake()/Bus::fake()and assert on the dispatch — class, payload, queue, count. They never run the handler. -
Handler tests call
handle()directly (ordispatchSync()when middleware matters) withMail::fake(),Http::fake(),Event::fake()for the outbound effects. They never touch a real broker. - A handful of integration tests boot a real worker on purpose, to guard the wiring. Named as such, run apart from the unit suite.
Do this and your job tests stop timing out on CI, stop needing sleep(), and start telling you exactly what broke when they go red.
If this was useful
The reason these tests get clean is that the job's real work (sending the invoice, generating the statement) is a small operation that doesn't know or care it's inside a queue. When that operation lives in your domain and the job class is a thin adapter that calls it, testing splits naturally: the handler test hits the domain method, the caller test fakes the dispatch. That edge-versus-core separation is exactly what Decoupled PHP is about: keeping the framework's queue plumbing at the boundary so the thing worth testing stays reachable without it.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)