Queues are where “it works on my machine” quietly turns into production incidents.
Emails are sent in the background.
Invoices are generated asynchronously.
External APIs are called outside the request lifecycle.
Data synchronization runs in workers you don’t actively watch.
Laravel makes queues extremely easy to use. Add ShouldQueue, dispatch the job, done.
Testing them properly is a different story.
Most developers stop at:
Bus::fake();
Bus::assertDispatched(SyncOrderJob::class);PHPThat verifies wiring. It does not verify behavior.
This article focuses on how to test Laravel jobs correctly — including idempotency, failure handling, and retry safety — with a realistic example.
Previous article in this category: https://codecraftdiary.com/2026/02/14/contract-testing-external-apis-in-php-with-pact-real-laravel-example/
The Scenario: Syncing an Order to an External API
Let’s say we have an Order model that needs to be synchronized to an external system after checkout.
The Job
namespace App\Jobs;use App\Models\Order;
use App\Services\OrderApiClient;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;class SyncOrderJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, SerializesModels; public int $tries = 3; public function __construct(
public Order $order
) {} public function handle(OrderApiClient $client): void
{
if ($this->order->synced_at !== null) {
return; // idempotency guard
} $client->send([
'id' => $this->order->id,
'total' => $this->order->total,
]); $this->order->update([
'synced_at' => now(),
]);
}
}PHPAlready we have several important concerns:
- The job must not sync twice.
- It depends on an external API client.
- It may be retried.
- It mutates persistent state.
Let’s test it properly.
1. Testing Dispatch (Integration Level)
This belongs in a feature test.
use Illuminate\Support\Facades\Bus;
use App\Jobs\SyncOrderJob;
public function test_order_dispatches_sync_job()
{
Bus::fake();
$order = Order::factory()->create();
$order->markAsPaid(); // imagine this dispatches the job
Bus::assertDispatched(SyncOrderJob::class, function ($job) use ($order) {
return $job->order->is($order);
});
}PHPGood. Necessary.
But insufficient.
This does not test handle() at all.
2. Testing Job Logic Directly (Unit Level)
Now we test the job itself.
We do not fake the bus.
We instantiate the job and call handle() directly.
Mocking the External API
use App\Services\OrderApiClient;
use Mockery;
public function test_it_calls_external_api_when_not_synced()
{
$order = Order::factory()->create([
'synced_at' => null,
]);
$mock = Mockery::mock(OrderApiClient::class);
$mock->shouldReceive('send')
->once()
->with(Mockery::on(fn ($payload) => $payload['id'] === $order->id));
$job = new SyncOrderJob($order);
$job->handle($mock);
}PHPThis verifies real behavior.
We are not testing Laravel.
We are testing our business logic.
3. Testing Idempotency
Queues retry automatically. If your job is not idempotent, you will create duplicate side effects.
public function test_it_does_not_call_api_if_already_synced()
{
$order = Order::factory()->create([
'synced_at' => now(),
]);
$mock = Mockery::mock(OrderApiClient::class);
$mock->shouldNotReceive('send');
$job = new SyncOrderJob($order);
$job->handle($mock);
}PHPIf this test fails, your production system will eventually duplicate work.
4. Testing Retry Safety
Consider a failure between the API call and the database update.
If the job crashes after calling the API but before marking the order as synced, retry will send it again.
Safer pattern:
public function handle(OrderApiClient $client): void
{
if ($this->order->synced_at !== null) {
return;
} $this->order->update([
'synced_at' => now(),
]); $client->send([
'id' => $this->order->id,
'total' => $this->order->total,
]);
}PHPNow state changes before side effects.
Let’s simulate a failure.
public function test_it_retries_safely()
{
$order = Order::factory()->create([
'synced_at' => null,
]);
$mock = Mockery::mock(OrderApiClient::class);
$mock->shouldReceive('send')
->once()
->andThrow(new RuntimeException());
$job = new SyncOrderJob($order);
try {
$job->handle($mock);
} catch (RuntimeException $e) {
// expected
}
$order->refresh();
$this->assertNotNull($order->synced_at);
}PHPNow even if retry happens, the idempotency guard prevents double execution.
This is not theoretical. This is how duplicate invoices happen.
5. Testing Jobs That Dispatch Other Jobs
Chained or nested jobs add complexity.
ProcessPaymentJob::dispatch($order);
SendInvoiceJob::dispatch($order);PHPTo test that correctly:
Bus::fake();
$job = new ProcessPaymentJob($order);
$job->handle($paymentService);
Bus::assertDispatched(SendInvoiceJob::class);PHPNotice the difference:
- We test
handle()directly. - We fake the bus only to assert nested dispatch.
That separation is critical.
6. Testing Backoff and Retry Configuration
Laravel allows configuration:
public int $tries = 5;
public function backoff(): array
{
return [10, 30, 60];
}PHPYou can test configuration explicitly:
public function test_job_has_correct_retry_settings()
{
$job = new SyncOrderJob(Order::factory()->make());
$this->assertEquals(3, $job->tries);
}PHPNot glamorous — but configuration errors cause real outages.
7. The Dangerous Anti-Pattern
This is common:
Queue::fake();
SyncOrderJob::dispatch($order);
Queue::assertPushed(SyncOrderJob::class);PHPThis test will pass even if:
- The job throws immediately.
- The API client is broken.
- The logic is inverted.
- Idempotency is missing.
- The job deletes the order by accident.
You tested dispatch, not behavior.
When to Use Bus::fake() vs Real Execution
Use Bus::fake() when:
- Testing controllers or services that dispatch jobs.
- Verifying orchestration.
- Ensuring a job is queued under certain conditions.
Do not use Bus::fake() when:
- Testing job business logic.
- Testing external integration behavior.
- Testing failure handling.
In job tests, instantiate and call handle() directly.
Advanced: Running Jobs Synchronously in Tests
Laravel allows:
config(['queue.default' => 'sync']);PHPThis executes jobs immediately.
Useful for feature tests where:
- You want full behavior executed.
- You don’t care about queue infrastructure.
But be careful:
If you rely on sync execution everywhere, you may miss race conditions or retry issues.
Design Principles for Testable Jobs
If your job is hard to test, it’s usually poorly designed.
Good Laravel jobs:
- Inject services via
handle(). - Keep constructors lightweight.
- Avoid heavy logic in
__construct. - Avoid static service calls.
- Are idempotent by design.
- Fail loudly.
Bad Laravel jobs:
- Call
Http::post()directly inside handle. - Query models statically without abstraction.
- Mutate global state.
- Swallow exceptions silently.
Testing reveals architecture quality.
Final Thoughts
Queues introduce three new axes of complexity:
- Time (execution is delayed)
- Failure (automatic retries)
- Concurrency (multiple workers)
Testing Laravel jobs properly means:
- Testing dispatch at feature level.
- Testing
handle()directly at unit level. - Mocking external services.
- Verifying idempotency.
- Simulating failure.
- Thinking explicitly about retry safety.
Queues scale your application.
Tests make your background processing reliable.

