Why Testing Queues is Different
Queue testing has two distinct concerns that most developers confuse:
Did the job get dispatched? — Testing that your controller/service dispatched the right job with the right data.
Does the job do the right thing? — Testing the actual logic inside handle().
These require different approaches. Mixing them leads to slow, brittle tests.
The golden rule: test dispatch separately from job logic.
By default, Laravel's sync driver runs jobs immediately during tests — which is sometimes what you want,
but often you just want to assert a job was dispatched without running it.
That's where Queue::fake() comes in.
Queue::fake() – Intercept Dispatches
Queue::fake() replaces the queue with an in-memory fake that records dispatches
but never actually runs any jobs. This lets you assert dispatch behavior without running real job logic.
use Illuminate\Support\Facades\Queue;
use App\Jobs\SendWelcomeEmail;
use Tests\TestCase;
class RegistrationTest extends TestCase
{
public function test_welcome_email_is_queued_on_registration(): void
{
Queue::fake(); // intercept all dispatches
$response = $this->postJson('/api/register', [
'name' => 'John Doe',
'email' => 'john@example.com',
'password' => 'secret1234',
'password_confirmation' => 'secret1234',
]);
$response->assertCreated();
// Assert the job was pushed to the queue
Queue::assertPushed(SendWelcomeEmail::class);
}
}
Fake Only Specific Jobs
By default, Queue::fake() intercepts ALL job dispatches.
Pass specific classes to fake only those — others run normally:
// Only fake the email job — let other jobs run normally
Queue::fake([SendWelcomeEmail::class]);
// Fake everything EXCEPT specific classes
Queue::except([CriticalPaymentJob::class]);
assertDispatched & assertNotDispatched
After a Queue::fake(), you have a full set of assertion methods:
// Assert a job was pushed at least once
Queue::assertPushed(SendWelcomeEmail::class);
// Assert pushed exactly N times
Queue::assertPushed(SendNewsletterToUser::class, 3);
// Assert NOT pushed
Queue::assertNotPushed(SendWelcomeEmail::class);
// Assert nothing was pushed at all
Queue::assertNothingPushed();
Inspect the Job's Data with a Callback
Pass a callback to verify the job was dispatched with the correct data:
Queue::assertPushed(SendWelcomeEmail::class, function (SendWelcomeEmail $job) use ($user) {
// Assert the job has the right user
return $job->user->id === $user->id;
});
// Count-matching with a callback
Queue::assertPushed(SendNewsletterToUser::class, function ($job) {
return $job->onQueue('newsletters');
});
Assert Pushed to a Specific Queue
Queue::assertPushedOn('emails', SendWelcomeEmail::class);
Queue::assertPushedOn('emails', SendWelcomeEmail::class, function ($job) use ($user) {
return $job->user->id === $user->id;
});
Assert Delayed Dispatch
// After dispatching with ->delay(now()->addMinutes(10))
Queue::assertPushed(SendReminderEmail::class, function ($job) {
// Assert the job has a delay
return $job->delay !== null;
});
Testing Job Logic Directly
To test what a job actually does, don't dispatch it through the queue system.
Just instantiate the job class and call handle() directly.
This is fast, direct, and isolates the job's business logic.
use App\Jobs\GenerateInvoicePdf;
use App\Models\Invoice;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class GenerateInvoicePdfTest extends TestCase
{
public function test_pdf_is_generated_and_stored(): void
{
Storage::fake('s3');
$invoice = Invoice::factory()->create(['status' => 'pending']);
// Call the job directly — no queue involved
(new GenerateInvoicePdf($invoice))->handle();
// Assert the PDF was stored on S3
Storage::disk('s3')->assertExists("invoices/{$invoice->id}/invoice-{$invoice->number}.pdf");
// Assert the invoice was updated
$this->assertDatabaseHas('invoices', [
'id' => $invoice->id,
'status' => 'ready',
]);
$this->assertNotNull($invoice->fresh()->pdf_generated_at);
}
public function test_invoice_status_set_to_failed_on_error(): void
{
Storage::fake('s3');
Storage::disk('s3')->shouldReceive('put')->andThrow(new \RuntimeException('S3 unavailable'));
$invoice = Invoice::factory()->create(['status' => 'pending']);
$job = new GenerateInvoicePdf($invoice);
// Call the failed() method directly to test failure handling
$job->failed(new \RuntimeException('S3 unavailable'));
$this->assertDatabaseHas('invoices', [
'id' => $invoice->id,
'status' => 'pdf_failed',
]);
}
}
Dependency Injection in handle()
If your handle() method accepts services via dependency injection,
the container resolves them — so you can mock them in tests:
use App\Jobs\SyncCustomerToHubspot;
use App\Services\HubspotService;
public function test_customer_is_created_in_hubspot(): void
{
$customer = Customer::factory()->create(['hubspot_id' => null]);
// Mock the HubspotService
$mockHubspot = $this->mock(HubspotService::class);
$mockHubspot->shouldReceive('createContact')
->once()
->with(\Mockery::on(fn ($payload) => $payload['email'] === $customer->email))
->andReturn('hubspot-contact-123');
// Run the job — DI will inject the mock
(new SyncCustomerToHubspot($customer))->handle($mockHubspot);
$this->assertDatabaseHas('customers', [
'id' => $customer->id,
'hubspot_id' => 'hubspot-contact-123',
]);
}
Testing Job Chains
Testing that a chain was dispatched correctly requires Queue::fake()
and the assertPushedWithChain() assertion:
use App\Jobs\ProcessVideoUpload;
use App\Jobs\GenerateThumbnail;
use App\Jobs\NotifySubscribers;
public function test_video_processing_chain_is_dispatched(): void
{
Queue::fake();
$video = Video::factory()->create();
// Trigger whatever dispatches the chain
$this->postJson("/api/videos/{$video->id}/publish");
Queue::assertPushedWithChain(ProcessVideoUpload::class, [
GenerateThumbnail::class,
NotifySubscribers::class,
]);
}
// With callback to check chain job data
Queue::assertPushedWithChain(ProcessVideoUpload::class, [
new GenerateThumbnail($video),
new NotifySubscribers($video),
]);
⚠️ assertPushedWithChain() checks that the first job in the chain was dispatched AND that the subsequent jobs are attached as the chain. It does not test the chain execution order — that requires an integration test.
Testing Job Batches
Use Bus::fake() (not Queue::fake()) to test batch dispatches:
use Illuminate\Support\Facades\Bus;
use App\Jobs\ImportCsvFile;
use App\Jobs\ProcessCsvChunk;
public function test_csv_import_dispatches_batch(): void
{
Bus::fake();
Storage::fake('local');
$file = UploadedFile::fake()->create('contacts.csv', 100);
$this->postJson('/api/imports', ['file' => $file])
->assertAccepted();
// Assert a batch was dispatched
Bus::assertBatched(function (PendingBatch $batch) {
return $batch->name === 'CSV Import'
&& $batch->jobs->count() > 0;
});
}
// Assert nothing was batched
Bus::assertNothingBatched();
Test a Batchable Job Directly
use Illuminate\Bus\PendingBatch;
use Illuminate\Support\Facades\Bus;
public function test_csv_chunk_job_skips_when_batch_cancelled(): void
{
// Create a fake cancelled batch
$batch = Bus::createFakeBatch();
$batch->cancel();
$job = new ProcessCsvChunk([['email' => 'test@test.com']], 1);
$job->withFakeBatch($batch); // inject the fake batch
// Run the job
$job->handle();
// Assert nothing was inserted (job should have returned early)
$this->assertDatabaseCount('contacts', 0);
}
Testing Failed Jobs & the failed() Method
Test your failed() cleanup method by calling it directly with a fake exception:
public function test_failed_job_updates_order_status(): void
{
$order = Order::factory()->create(['status' => 'processing']);
$job = new ProcessPayment($order);
$job->failed(new \RuntimeException('Payment gateway timeout'));
$this->assertDatabaseHas('orders', [
'id' => $order->id,
'status' => 'payment_failed',
]);
}
public function test_failed_job_sends_admin_notification(): void
{
Notification::fake();
$order = Order::factory()->create();
$job = new ProcessPayment($order);
$job->failed(new \RuntimeException('Card declined'));
Notification::assertSentTo(
User::admin()->first(),
PaymentFailedNotification::class
);
}
Testing Job Middleware
Test custom job middleware in isolation by creating a test job and running it through the middleware:
use App\Jobs\Middleware\EnsureFeatureIsEnabled;
class EnsureFeatureIsEnabledTest extends TestCase
{
public function test_job_runs_when_feature_is_enabled(): void
{
// Enable the feature
config(['features.newsletter' => true]);
$executed = false;
$fakeJob = new class {
public function delete() {}
};
$middleware = new EnsureFeatureIsEnabled('newsletter');
$middleware->handle($fakeJob, function () use (&$executed) {
$executed = true;
});
$this->assertTrue($executed);
}
public function test_job_is_deleted_when_feature_is_disabled(): void
{
config(['features.newsletter' => false]);
$deleted = false;
$fakeJob = new class (&$deleted) {
public function __construct(private bool &$deleted) {}
public function delete(): void { $this->deleted = true; }
};
$middleware = new EnsureFeatureIsEnabled('newsletter');
$middleware->handle($fakeJob, function () {
$this->fail('Next should not be called when feature is disabled');
});
$this->assertTrue($deleted);
}
}
Pro Tips & Common Pitfalls
Tip 1: Use RefreshDatabase with Queue Tests
// Always use RefreshDatabase or DatabaseTransactions in job logic tests
// to avoid stale data between tests
use Illuminate\Foundation\Testing\RefreshDatabase;
class SendWelcomeEmailTest extends TestCase
{
use RefreshDatabase;
// ...
}
Tip 2: Don't Test Queue Infrastructure
Don't write tests that verify Laravel's queue system works. It does — it has its own tests.
Focus on: (1) your code dispatches the right job, (2) the job's business logic is correct.
Tip 3: Use withoutExceptionHandling() for Job Logic Tests
public function test_job_throws_on_invalid_data(): void
{
$this->withoutExceptionHandling();
$this->expectException(\InvalidArgumentException::class);
(new ProcessPayment(null))->handle();
}
Pitfall: Queue::fake() After Dispatch
// ❌ Wrong — fake() must be called BEFORE the dispatch
$this->postJson('/api/register', $data);
Queue::fake();
Queue::assertPushed(SendWelcomeEmail::class); // will always fail
// ✅ Correct
Queue::fake();
$this->postJson('/api/register', $data);
Queue::assertPushed(SendWelcomeEmail::class);
Pitfall: Testing Serialization
// ❌ Don't pass a model that's not in the database
$user = new User(['email' => 'test@test.com']); // not persisted
SendWelcomeEmail::dispatch($user); // SerializesModels will fail to re-fetch
// ✅ Always use factories to create persisted models for job tests
$user = User::factory()->create();
SendWelcomeEmail::dispatch($user);
Conclusion
Proper queue testing separates dispatch assertions from job logic tests.
This gives you fast, focused, reliable test suites:
Use Queue::fake() to test that the right job was dispatched with the right data — without running the job
Use Bus::fake() for chain and batch dispatch assertions
Instantiate jobs directly and call handle() to test business logic in isolation
Call failed() directly to test your failure handling code
Test job middleware independently with a fake job object
Always call Queue::fake() before the action that triggers the dispatch