Laravel Queue Testing: What Most Developers Get Wrong

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);
PHP

That 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(),
        ]);
    }
}
PHP

Already 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);
    });
}
PHP

Good. 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);
}
PHP

This 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);
}
PHP

If 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,
    ]);
}
PHP

Now 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);
}
PHP

Now 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);
PHP

To test that correctly:

Bus::fake();

$job = new ProcessPaymentJob($order);

$job->handle($paymentService);

Bus::assertDispatched(SendInvoiceJob::class);
PHP

Notice 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];
}
PHP

You can test configuration explicitly:

public function test_job_has_correct_retry_settings()
{
    $job = new SyncOrderJob(Order::factory()->make());

    $this->assertEquals(3, $job->tries);
}
PHP

Not glamorous — but configuration errors cause real outages.


7. The Dangerous Anti-Pattern

This is common:

Queue::fake();

SyncOrderJob::dispatch($order);

Queue::assertPushed(SyncOrderJob::class);
PHP

This 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']);
PHP

This 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:

  1. Time (execution is delayed)
  2. Failure (automatic retries)
  3. 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.

Leave a Reply

Your email address will not be published. Required fields are marked *