Testing dei job in coda Laravel: da Queue::fake() a withFakeQueueInteractions() per validare retry, release e failure senza broker
In un progetto per un'azienda del settore servizi digitali, la suite di test di un applicativo Laravel 10 con 23 job in coda copriva solo il dispatch: ogni test verificava che Queue::assertPushed() trovasse il job corretto dopo l'azione, ma nessun test eseguiva il metodo handle(). Il risultato: un job di sincronizzazione con un ERP esterno che in produzione falliva silenziosamente dopo 3 retry - il $this->release(60) nel codice non era mai stato testato, e la condizione di uscita dal ciclo di retry aveva un off-by-one che causava un loop infinito. Le best practice Microsoft per i background job identificano l'idempotenza e la gestione dei retry come i due requisiti più critici per i processi asincroni - esattamente i due aspetti che Queue::fake() da solo non può validare.
Qual è la differenza tra Queue::fake() e withFakeQueueInteractions()?
Queue::fake() intercetta il dispatch dei job: verifica che un job venga accodato, su quale coda, con quali dati, quante volte. Non esegue handle() - è un test sul "cosa viene dispatchato", non sul "cosa fa il job". withFakeQueueInteractions(), introdotto in Laravel 11, risolve il problema complementare: permette di istanziare un job, chiamare handle() direttamente, e asserire sulle interazioni con la coda (release(), delete(), fail()) senza un broker reale.
I due strumenti coprono livelli di test diversi. Queue::fake() è un test di integrazione: verifica che il controller o il service dispatchino il job giusto. withFakeQueueInteractions() è un test unitario del job stesso: verifica che la logica interna risponda correttamente a ogni scenario - successo, retry, failure, cancellazione:
use Illuminate\Support\Facades\Queue;
use App\Jobs\SyncErpOrderJob;
use App\Services\ErpClient;
/* Test di integrazione: il job viene dispatchato? */
public function test_order_creation_dispatches_erp_sync(): void
{
Queue::fake();
$order = Order::factory()->create();
app(OrderService::class)->confirm($order);
Queue::assertPushed(SyncErpOrderJob::class, fn ($job) =>
$job->orderId === $order->id
);
Queue::assertPushedOn('integrations', SyncErpOrderJob::class);
}
/* Test unitario: il job gestisce i retry correttamente? */
public function test_erp_sync_releases_on_temporary_failure(): void
{
$mock = $this->mock(ErpClient::class);
$mock->shouldReceive('pushOrder')
->once()
->andThrow(new TemporaryErpException('ERP unavailable'));
$job = new SyncErpOrderJob(orderId: 42);
$job->withFakeQueueInteractions();
$job->handle($mock);
$job->assertReleased(120);
$job->assertNotFailed();
$job->assertNotDeleted();
}
public function test_erp_sync_fails_on_permanent_error(): void
{
$mock = $this->mock(ErpClient::class);
$mock->shouldReceive('pushOrder')
->once()
->andThrow(new InvalidOrderException('Missing SKU'));
$job = new SyncErpOrderJob(orderId: 42);
$job->withFakeQueueInteractions();
$job->handle($mock);
$job->assertFailed();
$job->assertNotReleased();
}La combinazione dei due approcci garantisce copertura completa: il flusso applicativo dispatcha il job corretto (Queue::fake), e il job stesso gestisce ogni scenario in modo prevedibile (withFakeQueueInteractions).
Come strutturare job resilienti e testabili?
Un job ben strutturato separa tre responsabilità: la configurazione della coda (proprietà della classe), la logica di business (delegata a un service iniettato), e la gestione degli errori (retry vs failure). Laravel fornisce strumenti nativi per ciascuna - job middleware per rate limiting e prevenzione di esecuzioni concorrenti (introdotti in Laravel 8), l'interfaccia ShouldBeUnique per impedire la duplicazione (Laravel 8.14), e retry() con backoff esponenziale per i transient failure:
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use App\Services\ErpClient;
class SyncErpOrderJob implements ShouldQueue, ShouldBeUnique
{
use InteractsWithQueue, Queueable;
public int $tries = 5;
public array $backoff = [30, 60, 120, 300];
public function __construct(public readonly int $orderId) {}
public function uniqueId(): string
{
return "erp-sync-{$this->orderId}";
}
public function middleware(): array
{
return [new WithoutOverlapping($this->uniqueId())];
}
public function handle(ErpClient $erp): void
{
try {
$erp->pushOrder($this->orderId);
} catch (TemporaryErpException $e) {
$this->release($this->backoff[$this->attempts() - 1] ?? 300);
return;
} catch (InvalidOrderException $e) {
$this->fail($e);
}
}
}ShouldBeUnique acquisisce un lock in cache al dispatch - se un job con lo stesso uniqueId è già in coda, il nuovo viene scartato silenziosamente. WithoutOverlapping impedisce che due istanze dello stesso job girino contemporaneamente su worker diversi. $backoff come array definisce delay crescenti per ogni retry - 30s, 60s, 120s, 300s - una strategia di backoff esponenziale senza librerie esterne.
Errori comuni nel testing dei job in coda
Il primo errore è testare solo con il driver sync. Impostare QUEUE_CONNECTION=sync nei test esegue handle() sincronamente nel processo del test - utile per verificare gli effetti collaterali, ma maschera problemi di serializzazione. Un job che passa un Closure o un oggetto non serializzabile nel costruttore funziona con sync ma esplode con Redis o SQS. Queue::fake() rileva questi problemi perché il job viene effettivamente serializzato.
Il secondo è non usare Http::preventStrayRequests() nei test dei job che chiamano API esterne. Un job testato con withFakeQueueInteractions() esegue handle() realmente - se il job chiama un servizio HTTP esterno senza Http::fake(), il test farà una chiamata di rete reale. Combinare Http::fake() con withFakeQueueInteractions() è il pattern corretto per job che integrano servizi esterni.
Il terzo è ignorare il monitoraggio in produzione. I test automatici validano il comportamento atteso, ma i fallimenti in produzione hanno cause che i test non possono prevedere - timeout di rete, memory limit, serializzazione di model con relazioni caricate. Laravel Horizon per le code Redis fornisce dashboard in tempo reale su throughput, runtime e failure rate - il logging strategico delle metriche di coda è il complemento per chi usa driver diversi da Redis.
La qualità dei job in coda determina l'affidabilità dei processi asincroni dell'applicativo - e i processi asincroni sono spesso quelli a più alto impatto di business (pagamenti, sincronizzazioni, notifiche). Il refactoring del codice legacy che include la ristrutturazione dei job verso pattern testabili è un investimento che si ripaga alla prima failure evitata in produzione. Per conoscere il mio approccio al testing e alla modernizzazione di applicativi Laravel, visita la mia pagina professionale. Se i job del tuo applicativo non sono testati o falliscono in modo imprevedibile, contattami per una consulenza dedicata - partiamo dall'inventario dei job e dalla copertura di test attuale.