notiziephp.it

Test di integrazione del nostro pacchetto Laravel con un server e una coda reali

Traduzione da Spatie
23 Aprile 2026 Tempo di lettura: 8 minuti
Test di integrazione del nostro pacchetto Laravel con un server e una coda reali

Flare si aggancia a molte parti interne di Laravel: richieste HTTP, view, query, operazioni di cache, job in coda, componenti Livewire e altro ancora. Per ognuna di queste, raccogliamo span e li inviamo come trace. Intercettiamo anche le eccezioni e inviamo report di errore.

Stiamo attualmente sviluppando nuove versioni dei pacchetti Flare che raccoglieranno anche i log dalla vostra applicazione. Per verificare che tutto funzioni end-to-end in un'applicazione Laravel, avevamo bisogno di test di integrazione che andassero oltre il mocking. Avevamo bisogno di un server HTTP reale, un queue worker reale e payload reali inviati.

Il testing unitario dei singoli recorder di span, log ed errori è semplice. Laravel fornisce persino facade per testare le chiamate Http e Queue dal framework. Il problema è che funzionano tutti nell'applicazione attualmente avviata.

Volevamo anche verificare che una richiesta HTTP completa attraverso il kernel di Laravel produca esattamente i payload che ci aspettiamo. Che un job in coda inviato da una route crei una trace collegata alla richiesta originale. Che un'eccezione nel mount di un componente Livewire generi sia una trace sia un report di errore.

Non puoi davvero cavartela con il mocking usando facade. Ti serve un'applicazione Laravel reale in esecuzione.

Configurare un'applicazione workbench

Quando sviluppi un pacchetto Laravel, non puoi aggirare il pacchetto Orchestra Testbench: è lo standard de facto per testare i pacchetti Laravel. Sebbene faccia un ottimo lavoro per il testing unitario, non supporta davvero il tipo di test di integrazione di cui avevamo bisogno out of the box.

Fortunatamente testbench ha questa fantastica funzionalità chiamata workbench. Ti permette di generare un'applicazione Laravel completa all'interno del tuo pacchetto che puoi usare per i test.

Puoi installarlo così:

composer require --dev orchestra/testbench

Creerà una directory workbench/ nel tuo pacchetto. Puoi personalizzare questa applicazione skeleton e usarla per eseguire richieste HTTP reali contro di essa.

Con workbench, eseguiamo sostanzialmente due comandi prima di lanciare i test: vendor/bin/testbench workbench:build e vendor/bin/testbench serve. Il primo prepara lo skeleton, il secondo avvia il server HTTP.

La configurazione di workbench si trova in testbench.yaml:

laravel: '@testbench'
providers:
  - Workbench\App\Providers\WorkbenchServiceProvider
migrations:
  - workbench/database/migrations
workbench:
  start: '/'
  discovers:
    web: true
    api: true
    commands: true
    components: true
    factories: true
    views: true
    config: true
  build:
    - asset-publish
    - create-sqlite-db
    - db-wipe
    - migrate-fresh
  sync:
    - from: storage
      to: workbench/storage
      reverse: true

La sezione build è importante. Dice a testbench cosa fare quando costruisci l'applicazione. Nel nostro caso: pubblicare gli asset, creare un database SQLite, svuotarlo ed eseguire le migration fresh.

La sezione sync crea un symlink dalla directory storage dello skeleton di testbench a workbench/storage. Questo diventerà rilevante più avanti.

Ora possiamo creare un'applicazione Laravel reale dentro workbench/ e personalizzarla come vogliamo. Possiamo aggiungere route, controller, job, view e altro ancora.

I nostri test effettueranno richieste HTTP reali a questo server, che attiveranno tutti i normali eventi del lifecycle di Laravel e la strumentazione di Flare.

La prossima sfida è come catturare questi payload nei nostri test per poter fare asserzioni su di essi?

Scrivere i payload su disco invece di inviarli via HTTP

Normalmente, Flare invia dati alla nostra API via HTTP. Non è l'ideale per i test. Non vogliamo colpire un'API reale.

Flare ha il concetto di "sender", responsabile dell'invio dei payload all'API. Per impostazione predefinita, usa un sender HTTP. Ma possiamo sostituirlo durante i test.

Quindi abbiamo creato un FileSender:

class FileSender implements Sender
{
    public function post(
        string $endpoint,
        string $apiToken,
        array $payload,
        FlareEntityType $type,
        bool $test,
        Closure $callback,
    ): void {
        $id = uniqid();
        
        file_put_contents(
            storage_path("{$type->value}_{$id}.json"),
            json_encode($payload)
        );
    }
}

Invece di inviare payload via HTTP, li scrive come file JSON nella directory storage. Il nome del file codifica il tipo (errori, trace o log) e un ID univoco.

L'helper ExpectSentPayloads

Ora la parte divertente. Abbiamo creato un helper di test chiamato ExpectSentPayloads che collega tutto insieme:

$workspace = ExpectSentPayloads::get('/exception');
$workspace->assertSent(reports: 1, traces: 1);
$trace = $workspace->lastTrace()
    ->expectLaravelRequestLifecycle();
$trace->expectSpan(SpanType::Request)
    ->expectAttribute('http.response.status_code', 500);
$workspace->lastReport()
    ->expectExceptionClass(Exception::class)
    ->expectMessage('Test exception');

Diamo un'occhiata a come funziona internamente.

Quando chiami ExpectSentPayloads::get('/exception'), il costruttore fa tre cose:

  1. Pulisce eventuali file payload rimasti da un test precedente
  2. Effettua una richiesta HTTP a http://127.0.0.1:8000/exception usando il client HTTP di Laravel
  3. Legge i file payload che il server ha scritto tramite FileSender

Dopo la richiesta, leggiamo tutti i file da workbench/storage e li categorizziamo per tipo:

foreach (File::files($this->workSpacePath) as $file) {
    $entityType = match (true) {
        str_starts_with($file->getFilename(), FlareEntityType::Errors->value) => FlareEntityType::Errors,
        str_starts_with($file->getFilename(), FlareEntityType::Traces->value) => FlareEntityType::Traces,
        str_starts_with($file->getFilename(), FlareEntityType::Logs->value) => FlareEntityType::Logs,
        default => null,
    };
    // Parse and store each payload...
}

I file vengono poi parsati e salvati nell'istanza ExpectSentPayloads, che fornisce metodi helper per fare asserzioni su di essi.

Attendere il lavoro asincrono

Alcuni test inviano job in coda. La richiesta HTTP ritorna immediatamente, ma il job viene elaborato in modo asincrono dal queue worker. Dobbiamo attendere che il job finisca prima di leggere i file payload.

Per farlo ci basta osservare la tabella jobs nel database SQLite. Quando è vuota, sappiamo che tutti i job sono stati elaborati.

Usiamo una strategia di backoff che alla fine rinuncia dopo circa 10 secondi, se qualcosa è andato storto e i job non vengono elaborati:

$backoff = [
    500_000,   // 500ms
    750_000,   // 750ms
    1_000_000, // 1s
    1_500_000, // 1.5s
    2_500_000, // 2.5s
    4_000_000, // 4s
];
while (true) {
    usleep($backoff[$currentBackoffIndex]);
    if (DB::table('jobs')->count() === 0) {
        return;
    }
    $currentBackoffIndex++;
}

Questo comportamento deve essere abilitato a livello di test case, dato che non tutti i test inviano job e attendere che i job vengano elaborati quando non è necessario aggiunge solo tempo extra ai test.

Come appare un test reale

Ecco un test che verifica che un job in coda crei le trace corrette con le giuste relazioni parent-child:

it('can handle a queued job', function () {
    $workspace = ExpectSentPayloads::get(
        '/trigger-job',
        waitUntilAllJobsAreProcessed: true,
    );
    $workspace->assertSent(traces: 2);
    // First trace: the HTTP request that dispatched the job
    $httpTrace = $workspace->trace(0)
        ->expectLaravelRequestLifecycle();
    $requestSpan = $httpTrace->expectSpan(SpanType::Request)
        ->expectAttribute('http.response.status_code', 200);
    $queuingSpan = $httpTrace->expectSpan(LaravelSpanType::Queueing)
        ->expectParentId($requestSpan)
        ->expectAttribute('laravel.job.queue.connection_name', 'database')
        ->expectAttribute('laravel.job.name', SuccesJob::class);
    // Second trace: the job itself, linked back to the HTTP trace
    $jobTrace = $workspace->trace(1);
    $jobTrace->expectSpan(LaravelSpanType::Job)
        ->expectParentId($queuingSpan)
        ->expectTraceId($queuingSpan->span['traceId'])
        ->expectAttribute('laravel.job.success', true);
});

Da questo emergono due trace: una per la richiesta HTTP, una per l'esecuzione del job. L'ID dello span parent della trace del job punta allo span di queueing nella trace HTTP. Questo è distributed tracing che funziona end-to-end.

Quando un test invia un job in coda, ci ritroviamo con due file trace: uno per la richiesta HTTP e uno per il job. I nostri test devono sapere quale è quale. trace(0) dovrebbe essere la trace HTTP, trace(1) dovrebbe essere la trace del job.

Non possiamo affidarci ai nomi dei file per l'ordinamento, dato che gli ID sono casuali. Invece, ordiniamo per numero di inode. Sulla maggior parte dei filesystem, i file creati prima hanno numeri di inode più bassi. Dato che la trace HTTP viene sempre scritta prima della trace del job, questo ci dà un ordinamento coerente.

In totale abbiamo circa 65 test di integrazione come questo, che coprono richieste HTTP, eccezioni, view, query, transazioni, operazioni di cache, job in coda (incluse chain, batch, retry e failure), componenti Livewire, messaggi di log e contesto.

Riavvio automatico di un server andato in crash

A volte un test fa crashare il server built-in di PHP o la coda. In realtà è proprio qualcosa che vogliamo testare, specialmente nel momento in cui Laravel termina se stesso.

Abbiamo provato a far funzionare una configurazione in cui avviavamo un server e una coda nuovi per ogni test. Sebbene funzionasse perfettamente in locale, era molto instabile in CI. Terminare il processo del server tra i test si è rivelato complicato, e a volte un server restava in background causando conflitti di porta.

Abbiamo invece optato per un singolo server long-lived che si riavvia automaticamente quando va in crash. Se una richiesta non riesce a connettersi, proviamo a riavviare il server e riproviamo la richiesta:

try {
    $response = $client->get($this->endpoint);
} catch (ConnectException|ConnectionException $e) {
    if (! $this->restartServer()) {
        throw new Exception('Workbench server is not running.');
    }
    // Retry the request
    $response = $client->get($this->endpoint);
}

Pur non essendo la soluzione più pulita, si è rivelata la più stabile in CI.

Conclusione

Questa configurazione ci permette di testare l'intera pipeline di strumentazione di Flare end-to-end. Solo un server reale, un queue worker reale e payload reali scritti su disco.

Usando questo approccio siamo riusciti a intercettare molti edge case e a verificare che tutte le diverse parti di Flare collaborino come previsto in un'applicazione Laravel reale.

Stiamo lavorando alle ultime modifiche a questi nuovi pacchetti Flare e non vediamo l'ora di condividerli presto con il mondo.

Per ora, puoi dare un'occhiata al codice nella pull request del pacchetto spatie/laravel-flare.

Condividi su: LinkedIn

Altri articoli recenti