Unit Tests vs Integration Tests for Jobs
The single most common mistake in queue testing is using Queue::fake() for
everything. Queue::fake() prevents jobs from actually executing — it only
asserts that a dispatch happened. This means you're testing your controller or service,
not the job itself. The job's logic remains untested.
A clear mental model:
Unit test the job's handle() method — instantiate the job, call handle() directly, assert the outcome. No queue involved. Fast, isolated, covers the core logic.
Integration test the dispatch path — use Queue::fake() in the service/controller test to assert the job was dispatched with the right data. Tests the wiring, not the job.
End-to-end test with a real queue — for critical flows, run the actual queue driver (database or array) to verify the full round-trip. Slow but complete.
// The three test types side by side:
// TYPE 1: Unit test — tests handle() logic, no queue infrastructure
class SendWelcomeEmailJobTest extends TestCase
{
public function test_sends_welcome_email_to_correct_address(): void
{
Mail::fake();
$user = User::factory()->make(['email' => 'jane@example.com']);
$job = new SendWelcomeEmailJob($user);
$job->handle(); // call directly — no dispatch, no queue
Mail::assertSent(WelcomeEmail::class, fn ($mail) => $mail->to[0]['address'] === 'jane@example.com');
}
}
// TYPE 2: Integration test — tests dispatch, not handle()
class UserRegistrationTest extends TestCase
{
public function test_dispatches_welcome_email_job_after_registration(): void
{
Queue::fake();
$this->post('/register', ['email' => 'jane@example.com', 'password' => '...']);
Queue::assertPushed(SendWelcomeEmailJob::class, function ($job) {
return $job->user->email === 'jane@example.com';
});
}
}
// TYPE 3: E2E — real queue, tests the full flow
class UserRegistrationE2ETest extends TestCase
{
public function test_welcome_email_is_sent_after_registration(): void
{
config(['queue.default' => 'database']); // use real driver
Mail::fake();
$this->post('/register', ['email' => 'jane@example.com', 'password' => '...']);
$this->artisan('queue:work', ['--once' => true, '--queue' => 'default']);
Mail::assertSent(WelcomeEmail::class);
}
}
Testing Job Logic Directly
Direct handle() invocation is the most powerful testing approach for job logic. It keeps
tests fast, focused, and independent of queue infrastructure. The key is setting up the
job exactly as the queue worker would — with proper constructor arguments and any
dependency-injected services provided via the test container.
// App\Jobs\ProcessRefundJob.php
class ProcessRefundJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
private readonly Order $order,
private readonly float $amount,
private readonly string $reason,
) {}
public function handle(RefundService $refunds, AuditLogger $audit): void
{
if ($this->order->status !== OrderStatus::Completed) {
throw new UnrefundableOrderException("Order {$this->order->id} is not in completed state");
}
$refundId = $refunds->issue(
orderId: $this->order->id,
amount: $this->amount,
reason: $this->reason,
);
$this->order->update([
'status' => OrderStatus::Refunded,
'refund_id' => $refundId,
]);
$audit->log('refund_issued', [
'order_id' => $this->order->id,
'amount' => $this->amount,
'refund_id' => $refundId,
]);
}
}
// Tests\Unit\Jobs\ProcessRefundJobTest.php
class ProcessRefundJobTest extends TestCase
{
use RefreshDatabase;
public function test_issues_refund_and_updates_order_status(): void
{
$order = Order::factory()->create(['status' => OrderStatus::Completed]);
$refunds = Mockery::mock(RefundService::class);
$refunds->shouldReceive('issue')
->once()
->with($order->id, 50.00, 'duplicate_charge')
->andReturn('refund_abc123');
$audit = Mockery::mock(AuditLogger::class);
$audit->shouldReceive('log')->once()->with('refund_issued', Mockery::type('array'));
$job = new ProcessRefundJob($order, 50.00, 'duplicate_charge');
$job->handle($refunds, $audit);
$order->refresh();
$this->assertEquals(OrderStatus::Refunded, $order->status);
$this->assertEquals('refund_abc123', $order->refund_id);
}
public function test_throws_exception_for_non_completed_order(): void
{
$order = Order::factory()->create(['status' => OrderStatus::Pending]);
$job = new ProcessRefundJob($order, 50.00, 'customer_request');
$this->expectException(UnrefundableOrderException::class);
$job->handle(app(RefundService::class), app(AuditLogger::class));
}
}
Mocking External Services Inside Jobs
Jobs that call external APIs require careful mocking. The two approaches are:
constructor injection (explicit) and container binding (implicit). For jobs using
Laravel's container-injected handle() parameters, binding a mock in the test container
is the cleanest approach.
// Approach 1: Bind mocks to the container before calling handle()
class SyncToSalesforceJobTest extends TestCase
{
public function test_syncs_contact_to_salesforce(): void
{
$salesforce = Mockery::mock(SalesforceClient::class);
$salesforce->shouldReceive('upsertContact')
->once()
->with(Mockery::on(function ($data) {
return $data['email'] === 'test@example.com'
&& isset($data['external_id']);
}))
->andReturn(['id' => 'sf_001', 'success' => true]);
// Bind the mock — Laravel's container will inject it into handle()
$this->app->instance(SalesforceClient::class, $salesforce);
$user = User::factory()->create(['email' => 'test@example.com']);
$job = new SyncToSalesforceJob($user->id);
$job->handle(app(SalesforceClient::class));
}
}
// Approach 2: Http::fake() for HTTP-based external calls
class CallStripeWebhookJobTest extends TestCase
{
public function test_notifies_webhook_url(): void
{
Http::fake([
'https://hooks.stripe.com/*' => Http::response(['received' => true], 200),
]);
$job = new NotifyStripeWebhookJob(orderId: 99, event: 'charge.succeeded');
$job->handle();
Http::assertSent(function ($request) {
return $request->url() === 'https://hooks.stripe.com/events'
&& $request->data()['order_id'] === 99;
});
}
}
// Testing that exceptions from the external service propagate correctly
public function test_marks_job_for_retry_on_transient_failure(): void
{
Http::fake([
'https://api.external.com/*' => Http::sequence()
->push(['error' => 'timeout'], 503)
->push(['success' => true], 200),
]);
$job = new CallExternalApiJob(payload: ['key' => 'value']);
// First call should throw
$this->expectException(\RuntimeException::class);
$job->handle();
// After retry (second Http::fake call), it should succeed — test separately
}
Testing Job Properties & Backoff
Job class properties like $tries, $timeout, $maxExceptions,
and the backoff() method are not tested by most teams — and they represent real
production behavior. Incorrect values here cause silent issues: jobs retrying too many times,
workers timing out before finishing, or jobs never being retried at all.
// Tests\Unit\Jobs\JobConfigurationTest.php
class ProcessVideoJobConfigTest extends TestCase
{
private ProcessVideoJob $job;
protected function setUp(): void
{
parent::setUp();
$this->job = new ProcessVideoJob(videoId: 1);
}
public function test_has_correct_retry_limit(): void
{
$this->assertEquals(3, $this->job->tries);
}
public function test_has_appropriate_timeout_for_video_processing(): void
{
// Video processing can take 10 minutes — ensure timeout is adequate
$this->assertEquals(600, $this->job->timeout);
}
public function test_backoff_uses_exponential_jitter(): void
{
$backoff = $this->job->backoff();
$this->assertIsArray($backoff);
$this->assertCount(3, $backoff); // one value per retry attempt
// Values should be non-zero (jitter applied) and within expected ranges
foreach ($backoff as $index => $delay) {
$this->assertGreaterThanOrEqual(0, $delay);
$this->assertLessThanOrEqual(600, $delay); // max cap
}
// Statistically: run 100 times and verify average increases with attempt number
// (proves exponential, not constant)
$attempt1Delays = [];
$attempt3Delays = [];
for ($i = 0; $i < 100; $i++) {
$delays = (new ProcessVideoJob(videoId: 1))->backoff();
$attempt1Delays[] = $delays[0];
$attempt3Delays[] = $delays[2];
}
$this->assertGreaterThan(array_sum($attempt1Delays), array_sum($attempt3Delays));
}
public function test_max_exceptions_limits_retries_on_unrecoverable_errors(): void
{
$this->assertEquals(1, $this->job->maxExceptions);
// maxExceptions = 1 means a single distinct exception stops all retries
}
public function test_queue_assignment(): void
{
$this->assertEquals('heavy-processing', $this->job->queue);
}
}
Testing the failed() Method
The failed() method is called when a job exhausts all its retry attempts and
permanently fails. It's critical infrastructure — often responsible for sending alerts,
updating order statuses, notifying customers, or logging audit trails. Yet it's almost
never tested.
// App\Jobs\ProcessPaymentJob.php (relevant section)
class ProcessPaymentJob implements ShouldQueue
{
public int $tries = 3;
public function __construct(private readonly int $paymentId) {}
public function handle(PaymentGateway $gateway): void
{
$payment = Payment::findOrFail($this->paymentId);
$gateway->charge($payment);
$payment->update(['status' => 'completed']);
}
public function failed(\Throwable $exception): void
{
$payment = Payment::find($this->paymentId);
if ($payment) {
$payment->update(['status' => 'failed', 'failure_reason' => $exception->getMessage()]);
}
Notification::send(
User::find($payment?->user_id),
new PaymentFailedNotification($this->paymentId, $exception->getMessage())
);
Log::critical('Payment processing failed permanently', [
'payment_id' => $this->paymentId,
'exception' => $exception->getMessage(),
'trace' => $exception->getTraceAsString(),
]);
}
}
// Tests\Unit\Jobs\ProcessPaymentJobTest.php
class ProcessPaymentJobFailedTest extends TestCase
{
use RefreshDatabase;
public function test_failed_updates_payment_status(): void
{
Notification::fake();
Log::spy();
$user = User::factory()->create();
$payment = Payment::factory()->create(['user_id' => $user->id, 'status' => 'pending']);
$job = new ProcessPaymentJob($payment->id);
$exception = new \RuntimeException('Card declined');
$job->failed($exception);
$payment->refresh();
$this->assertEquals('failed', $payment->status);
$this->assertEquals('Card declined', $payment->failure_reason);
}
public function test_failed_notifies_the_user(): void
{
Notification::fake();
$user = User::factory()->create();
$payment = Payment::factory()->create(['user_id' => $user->id, 'status' => 'pending']);
$job = new ProcessPaymentJob($payment->id);
$job->failed(new \RuntimeException('Card declined'));
Notification::assertSentTo($user, PaymentFailedNotification::class,
fn ($n) => $n->paymentId === $payment->id
);
}
public function test_failed_handles_missing_payment_gracefully(): void
{
Notification::fake();
Log::spy();
$job = new ProcessPaymentJob(paymentId: 999999); // non-existent
// Should not throw — failed() must be bulletproof
$this->expectNotToPerformAssertions();
$job->failed(new \RuntimeException('Payment not found'));
}
}
Testing Queue Middleware in Isolation
Queue middleware is often tested only indirectly through job tests, which makes it hard to
verify middleware-specific behaviour like rate limiting, locking, and skipping. Testing
middleware in isolation requires a callable harness that simulates the queue pipeline.
// App\Jobs\Middleware\SkipIfOrderCancelled.php
class SkipIfOrderCancelled
{
public function handle(mixed $job, callable $next): void
{
$order = Order::find($job->orderId);
if (! $order || $order->status === OrderStatus::Cancelled) {
$job->delete(); // remove from queue without processing
return;
}
$next($job);
}
}
// Tests\Unit\Middleware\SkipIfOrderCancelledTest.php
class SkipIfOrderCancelledTest extends TestCase
{
use RefreshDatabase;
public function test_allows_active_orders_through(): void
{
$order = Order::factory()->create(['status' => OrderStatus::Pending]);
$job = new class($order->id) { public int $orderId; public bool $executed = false;
public function __construct(int $orderId) { $this->orderId = $orderId; }
public function delete() {} };
$executed = false;
(new SkipIfOrderCancelled())->handle($job, function () use (&$executed) {
$executed = true;
});
$this->assertTrue($executed);
}
public function test_skips_cancelled_orders(): void
{
$order = Order::factory()->create(['status' => OrderStatus::Cancelled]);
$deleted = false;
$job = new class($order->id) { public int $orderId; public bool $deleted = false;
public function __construct(int $orderId) { $this->orderId = $orderId; }
public function delete() { $this->deleted = true; } };
$job->orderId = $order->id;
$executed = false;
(new SkipIfOrderCancelled())->handle($job, function () use (&$executed) {
$executed = true;
});
$this->assertFalse($executed);
$this->assertTrue($job->deleted);
}
}
// Testing rate-limiting middleware
class ThrottleByUserTest extends TestCase
{
public function test_allows_job_under_rate_limit(): void
{
Cache::flush();
$job = new FakeJobWithUserId(userId: 42);
$executed = false;
(new ThrottleByUser(limit: 5, perSeconds: 60))
->handle($job, function () use (&$executed) { $executed = true; });
$this->assertTrue($executed);
}
public function test_throttles_job_over_rate_limit(): void
{
Cache::flush();
$userId = 42;
// Exhaust the limit
for ($i = 0; $i < 5; $i++) {
Cache::increment("throttle:user:{$userId}:count");
}
$job = new FakeJobWithUserId(userId: $userId);
$released = false;
$job->onRelease(function () use (&$released) { $released = true; });
$executed = false;
(new ThrottleByUser(limit: 5, perSeconds: 60))
->handle($job, function () use (&$executed) { $executed = true; });
$this->assertFalse($executed);
// job should be released back to queue with a delay
}
}
Testing ShouldBeUnique Behavior
ShouldBeUnique uses the cache driver to acquire a lock before processing.
Testing this requires a real cache backend (or at minimum the array driver) and verifying
that duplicate dispatches don't result in duplicate executions.
// App\Jobs\SyncUserProfileJob.php
class SyncUserProfileJob implements ShouldQueue, ShouldBeUnique
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $uniqueFor = 300; // 5 minutes
public function __construct(public readonly int $userId) {}
public function uniqueId(): string
{
return "sync-user-{$this->userId}";
}
public function handle(UserProfileSyncService $sync): void
{
$sync->sync($this->userId);
}
}
// Tests\Feature\Jobs\SyncUserProfileJobUniqueTest.php
class SyncUserProfileJobUniqueTest extends TestCase
{
public function test_unique_id_is_based_on_user_id(): void
{
$job = new SyncUserProfileJob(userId: 7);
$this->assertEquals('sync-user-7', $job->uniqueId());
}
public function test_unique_for_is_5_minutes(): void
{
$job = new SyncUserProfileJob(userId: 7);
$this->assertEquals(300, $job->uniqueFor);
}
public function test_second_dispatch_is_rejected_when_lock_held(): void
{
// Manually acquire the lock that ShouldBeUnique would acquire
$lockKey = 'laravel_unique_job:' . SyncUserProfileJob::class . ':sync-user-7';
Cache::lock($lockKey, 300)->acquire();
Queue::fake();
SyncUserProfileJob::dispatch(userId: 7);
// The second dispatch should be silently dropped (not appear in queue)
Queue::assertNotPushed(SyncUserProfileJob::class);
}
public function test_job_dispatches_when_no_lock_exists(): void
{
Queue::fake();
$lockKey = 'laravel_unique_job:' . SyncUserProfileJob::class . ':sync-user-7';
Cache::forget($lockKey);
SyncUserProfileJob::dispatch(userId: 7);
Queue::assertPushed(SyncUserProfileJob::class);
}
public function test_lock_is_released_after_job_completes(): void
{
config(['queue.default' => 'sync']); // run synchronously
$sync = Mockery::mock(UserProfileSyncService::class);
$sync->shouldReceive('sync')->once();
$this->app->instance(UserProfileSyncService::class, $sync);
SyncUserProfileJob::dispatch(userId: 7);
// After sync execution, lock should be released
$lockKey = 'laravel_unique_job:' . SyncUserProfileJob::class . ':sync-user-7';
$this->assertNull(Cache::get($lockKey));
}
}
Testing Listeners That Dispatch Jobs
Event listeners that dispatch jobs are a common source of hidden bugs. The listener might
dispatch the wrong job class, with wrong data, or fail to dispatch under certain conditions.
Testing requires asserting on both the event handler path and the dispatched job parameters.
// App\Listeners\HandleOrderPlaced.php
class HandleOrderPlaced
{
public function handle(OrderPlaced $event): void
{
// Send confirmation email
SendOrderConfirmationJob::dispatch($event->order->id)
->onQueue('notifications');
// Trigger inventory reservation
ReserveInventoryJob::dispatch($event->order->id)
->onQueue('critical');
// Only sync to analytics if feature flag enabled
if (config('features.analytics_sync')) {
SyncOrderToAnalyticsJob::dispatch($event->order->id)
->delay(now()->addMinutes(5))
->onQueue('low');
}
}
}
// Tests\Feature\Listeners\HandleOrderPlacedTest.php
class HandleOrderPlacedTest extends TestCase
{
use RefreshDatabase;
public function test_dispatches_confirmation_and_inventory_jobs(): void
{
Queue::fake();
$order = Order::factory()->create();
event(new OrderPlaced($order));
Queue::assertPushedOn('notifications', SendOrderConfirmationJob::class,
fn ($job) => $job->orderId === $order->id
);
Queue::assertPushedOn('critical', ReserveInventoryJob::class,
fn ($job) => $job->orderId === $order->id
);
}
public function test_dispatches_analytics_job_when_feature_enabled(): void
{
Queue::fake();
config(['features.analytics_sync' => true]);
$order = Order::factory()->create();
event(new OrderPlaced($order));
Queue::assertPushedOn('low', SyncOrderToAnalyticsJob::class);
}
public function test_skips_analytics_job_when_feature_disabled(): void
{
Queue::fake();
config(['features.analytics_sync' => false]);
$order = Order::factory()->create();
event(new OrderPlaced($order));
Queue::assertNotPushed(SyncOrderToAnalyticsJob::class);
}
public function test_analytics_job_is_delayed_5_minutes(): void
{
Queue::fake();
config(['features.analytics_sync' => true]);
$order = Order::factory()->create();
$now = now();
$this->travelTo($now);
event(new OrderPlaced($order));
Queue::assertPushed(SyncOrderToAnalyticsJob::class, function ($job) use ($now) {
// Check that the job has a delay of approximately 5 minutes
return $job->delay >= $now->copy()->addMinutes(4)->getTimestamp()
&& $job->delay <= $now->copy()->addMinutes(6)->getTimestamp();
});
}
}
Conclusion
Comprehensive job testing requires treating each aspect of a job as a separate test concern:
handle() logic — test directly by instantiating and calling the method. No queue, fast, isolated.
failed() logic — always test. It handles money, notifications, and state. Broken failed() means silent failures in production.
Job configuration — test $tries, $timeout, backoff(). Wrong values cause silent production issues.
Middleware — test in isolation with a callable harness. Verify both the happy path and the skip/throttle path.
ShouldBeUnique — test that lock acquisition prevents duplicates and that lock release occurs after completion.
Listeners — use Queue::fake() to assert the right jobs are dispatched to the right queues with the right data.
The goal is not 100% coverage for its own sake — it's ensuring that the behaviors that matter
in production (retry limits, failure notifications, uniqueness guarantees, queue routing)
are verified before code ships.