Testing dei job in coda Laravel: da Queue::fake() a withFakeQueueInteractions() per validare retry, release e failure senza broker

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.

Ultima modifica: