Testing di API Laravel con Pest 3 nel 2026: dataset, mutation testing e CI per PMI che vogliono dormire
A novembre 2024 mi è capitato di fare un audit su un'API Laravel di una PMI veneta che gestiva l'integrazione con tre marketplace: ordini in entrata, sincronizzazione catalogo, fatturazione automatica. Il progetto aveva 67 endpoint REST, zero test automatici, e una suite "manuale" su Postman che il team eseguiva quando si ricordava. Il giorno dell'audit, un deploy di routine aveva rotto il calcolo IVA su un endpoint specifico: per 18 ore le fatture generate per uno dei marketplace partivano con aliquota 0% invece del 22%. Nessuno se n'era accorto perché i test "manuali" coprivano solo gli endpoint principali. Quando ho chiesto al lead developer perché non avessero una suite automatica, la risposta è stata quella che sento più spesso dai clienti PMI: "non abbiamo tempo per il testing, dobbiamo consegnare feature". Quello che spesso non si capisce è che il tempo non testato non sparisce: si trasforma in tempo speso a riparare incident in produzione, con interessi composti.
In questo articolo non racconto cos'è Pest in astratto. Racconto come uso Pest 3 sulle API Laravel dei miei clienti, cosa testo (e cosa decido scientemente di non testare), come strutturo la suite per minimizzare la flakiness in CI e come uso le feature più recenti del framework - mutation testing, dataset, arch presets - per ottenere un livello di confidenza che PHPUnit puro non offre.
Perché Pest 3 e non più PHPUnit "nudo" nel 2026?
PHPUnit è ancora il motore sotto il cofano: Pest non lo rimpiazza, lo wrappa con una sintassi più dichiarativa. La domanda corretta non è "Pest o PHPUnit", è "vuoi scrivere class UserApiTest extends TestCase { public function test_user_can_list_orders() { ... } } o vuoi scrivere it('lists orders for an authenticated user', function () { ... })?". Sembra cosmetica. Non lo è.
Su una codebase con 200+ test, la differenza si paga in due modi. Primo, il tempo di lettura: una suite Pest si scorre come una specifica BDD, mentre una suite PHPUnit costringe a navigare nomi di metodi snake_case e annotazioni. Secondo, le feature di alto livello che Pest 3 ha aggiunto e che PHPUnit non offre nativamente: mutation testing, dataset ergonomici, architecture testing con preset, higher-order expectations che riducono il boilerplate sui test su collezioni. Non sono zuccheri sintattici - sono strumenti che spostano il piano del testing da "controllo di funzionamento" a "controllo di robustezza". La documentazione ufficiale di Laravel sul testing include Pest fra le opzioni supportate da quando Laravel 11 ha rifatto l'application skeleton.
Il punto chiave per una PMI: Pest e PHPUnit convivono nella stessa suite. Migrare non è un big bang. Puoi tenere i test PHPUnit esistenti e scrivere quelli nuovi in Pest, e php artisan test esegue entrambi senza conflitti. Questo abbatte la barriera all'adozione: non c'è bisogno di un "mese di porting" prima di poter scrivere il primo test Pest.
Cosa testare davvero in un'API Laravel (e cosa decidere di non testare)
L'errore più comune nelle PMI che si avvicinano al testing è cercare il "100% coverage". È una metrica seducente perché misurabile, ma è anche un'ottima ricetta per scrivere centinaia di test inutili che rallentano la CI senza mai trovare un bug. Coverage alta non significa qualità: significa che hai eseguito ogni riga di codice. Mutation testing - di cui parliamo sotto - misura una cosa molto più utile: se i tuoi test si accorgerebbero di un cambio di comportamento.
Il mio criterio operativo, su un'API Laravel di una PMI, è testare in priorità decrescente:
- Endpoint pubblici esposti a integrazioni esterne. Sono il contratto verso il mondo. Una regressione qui rompe i client di terze parti senza preavviso.
- Logica di calcolo finanziario o fiscale. Calcolo IVA, sconti, totali ordine, conversioni valuta. Ogni bug qui ha un costo diretto e tracciabile.
- Permission e autorizzazione. Un endpoint che permette a un utente non autorizzato di vedere/modificare dati di altri è un incidente di sicurezza. Test di autorizzazione per role e per ownership.
- Validazione di input critici. Specialmente sui FormRequest: una validazione mancante è una superficie d'attacco gratis. Per un riferimento operativo sul refactoring delle validazioni in Laravel 12, vedi anche come spostare le logiche complesse in Rule Object dedicati.
- Job di coda critici. Quelli che generano fatture, inviano notifiche transazionali, sincronizzano sistemi esterni. Per un trattamento approfondito vedi il refactoring di job in coda con Queue::fake() e withFakeQueueInteractions() in Laravel 12.
Cosa non testo, deliberatamente: getter/setter banali, factory di Eloquent, codice di terze parti (a meno di non avere mock smart, come quelli descritti nel mio articolo sul refactoring HTTP Client di Laravel con Http::fake() e Macro testabili), glue code di config che non contiene logica di business. Su un'API di 67 endpoint, una suite scritta con questo criterio sta tipicamente sui 200-400 test e copre il 70-80% del rischio reale con un decimo dello sforzo di una suite "100% coverage".
Setup minimo: Pest 3 + RefreshDatabase + factory in 30 minuti
L'installazione di Pest 3 su un'app Laravel esistente è una sequenza di tre comandi. Pest 3 richiede PHP 8.2 o superiore ed è costruito su PHPUnit 11; controlla la compatibilità del progetto prima di partire.
composer require pestphp/pest --dev --with-all-dependencies
composer require pestphp/pest-plugin-laravel --dev
php artisan pest:installA questo punto hai una directory tests/ con la struttura standard Laravel (tests/Feature e tests/Unit) e un file tests/Pest.php che contiene la configurazione globale. È qui che dichiari il trait di reset del database, applicato a tutti i Feature test:
// tests/Pest.php
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class)->in('Feature');RefreshDatabase è il trait che uso di default: avvolge ogni test in una transazione che fa rollback alla fine, è significativamente più veloce di DatabaseMigrations o DatabaseTruncation, ed è ben documentato nella pagina ufficiale di Laravel sul database testing. Lo svantaggio è che non vede le modifiche fatte da test che non usano la transazione (raro nei Feature test) e non testa migrazioni reali. Per pipeline che testano anche le migrazioni, dedica una job CI specifica con DatabaseMigrations.
Un esempio concreto di Feature test API che uso come template: list di ordini paginati per un utente autenticato con scope multi-tenant.
// tests/Feature/Api/OrdersListTest.php
use App\Models\Customer;
use App\Models\Order;
use App\Models\User;
it('returns only orders belonging to the authenticated user customer', function () {
$customer = Customer::factory()->create();
$otherCustomer = Customer::factory()->create();
$user = User::factory()->for($customer)->create();
Order::factory()->count(3)->for($customer)->create();
Order::factory()->count(2)->for($otherCustomer)->create(); // rumore
$response = $this->actingAs($user)->getJson('/api/v1/orders');
$response->assertOk()
->assertJsonCount(3, 'data')
->assertJsonPath('meta.total', 3);
});Tre cose da notare. Primo, le factory di Eloquent (Customer::factory()->create()) sostituiscono completamente i seeder per i dati di test: i seeder sono per popolare ambienti, le factory per popolare un singolo test. Secondo, for($customer) collega esplicitamente la relazione, evitando che la factory generi un altro Customer non collegato. Terzo, actingAs() autentica via guard senza dover passare per il login HTTP - è dieci volte più veloce e lo stesso effetto pratico per testare la business logic dell'endpoint.
Datasets, mutation testing e arch presets: le feature Pest 3 che cambiano il gioco
I dataset sono la feature Pest che fa la differenza più grande sui test di autorizzazione. Su un endpoint che ha quattro ruoli (admin, manager, operator, viewer) con regole di accesso diverse, la suite "tradizionale" PHPUnit ti porta a scrivere quattro test quasi identici. Con un dataset Pest, scrivi un test e quattro righe di parametri.
// tests/Feature/Api/OrdersAuthorizationTest.php
it('allows or denies order creation based on role', function (string $role, int $expectedStatus) {
$user = User::factory()->create()->assignRole($role);
$payload = Order::factory()->raw();
$this->actingAs($user)
->postJson('/api/v1/orders', $payload)
->assertStatus($expectedStatus);
})->with([
'admin can create' => ['admin', 201],
'manager can create' => ['manager', 201],
'operator can create' => ['operator', 201],
'viewer is denied' => ['viewer', 403],
]);Quattro scenari espressi in un solo test, con descrizioni leggibili nel report. Quando aggiungi un quinto ruolo, aggiungi una riga al dataset, non scrivi un nuovo test. Sulle suite reali questo riduce il codice di test del 30-50% sui pattern di autorizzazione.
Mutation testing è la feature di Pest 3 che cambia davvero la conversazione sul "quanto sono buoni i miei test". Funziona così: Pest applica piccole mutazioni al codice di produzione (cambia un > in >=, inverte una condizione, rimuove una chiamata) e ri-esegue la suite. Se i test continuano a passare nonostante la mutazione, il test non sta davvero verificando quel comportamento. È un modo per misurare la robustezza dei test molto più informativo della code coverage. Si lancia con:
./vendor/bin/pest --mutate --parallelLa prima volta che ho fatto girare mutation testing su un progetto cliente, ho scoperto che il 22% delle mutazioni "sopravviveva" - significava che un quinto del codice di business poteva essere modificato senza che la suite reagisse. Quei punti scoperti erano esattamente dove poi sono stati introdotti i bug nei mesi successivi.
Architecture testing con i preset Pest 3 è l'altra feature che porto in produzione su tutti i clienti. Permette di esprimere regole strutturali - "tutti i Controller terminano con Controller", "nessun model accede direttamente a request()", "nessun file usa dd() o var_dump()" - come test eseguibili in CI. Il preset security blocca eval, md5 e altri pattern problematici; il preset strict impone strict types e classi final. Sono test che valgono dieci code review, perché fanno fallire la CI prima del merge.
// tests/Architecture/ConventionsTest.php
arch('controllers follow Laravel conventions')->preset()->laravel();
arch('production code is secure')->preset()->security();
arch('no debug helpers in production code')
->expect(['dd', 'dump', 'var_dump', 'ray'])
->not->toBeUsed();Per chi ha già toccato il refactoring di Fat Controller verso service layer, questo è il complemento naturale: i pattern Service Layer e Repository sui Fat Controller di Laravel 12 diventano molto più sostenibili quando una regola architetturale impedisce di sbagliare di nuovo.
Da test locali a CI: pipeline GitHub Actions e gate di merge
Una suite di test che gira solo in locale è metà del lavoro. Il valore reale arriva quando la suite blocca i merge che la rompono. Per le PMI con cui lavoro la pipeline di base è GitHub Actions con due job: test funzionali e mutation testing. Il primo gira ad ogni push su feature branch; il secondo gira solo sui PR verso main perché è significativamente più lento.
Il file .github/workflows/tests.yml minimale che uso come baseline:
name: tests
on:
push:
branches: [main]
pull_request:
jobs:
pest:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: secret
MYSQL_DATABASE: testing
ports: ['3306:3306']
options: --health-cmd="mysqladmin ping" --health-interval=5s
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
coverage: pcov
- run: composer install --no-progress --prefer-dist
- run: php artisan key:generate --env=testing
- run: php artisan migrate --env=testing --force
- run: ./vendor/bin/pest --parallel --coverage --min=70Tre dettagli importanti. Primo, --parallel - Pest gira i test su più processi in parallelo, sfruttando paratest sotto il cofano, e Laravel gestisce automaticamente database isolati per ogni processo. Su una suite di 400 test, parallelo riduce il tempo di esecuzione da ~80s a ~25s su una macchina a 4 core (Laravel gestisce automaticamente l'isolamento dei database per processo, come documentato nelle pagine ufficiali sul parallel testing). Secondo, --min=70 - fail della build se la code coverage scende sotto il 70%. Lo uso come gate pragmatico: non come obiettivo di qualità ma come segnalatore di degrado. Terzo, MySQL come service container, non SQLite in-memory: per quanto sia tentazionale usare SQLite per la velocità, ti morde quando il codice di produzione usa feature MySQL-specifiche (window function, JSON_TABLE, full-text indexing). Test in MySQL = bug trovati prima della produzione.
Per un setup CI che integri anche la sicurezza statica del codice (Larastan, PHPStan), il pattern che applico è documentato nella mia checklist di hardening per applicazioni Laravel/Symfony: le static analysis e il testing automatico sono complementari, non alternativi.
Il testing automatico di un'API Laravel non è una pratica per "sviluppatori senior": è la differenza fra una notte di sonno tranquilla e il telefono che squilla alle tre del mattino quando il marketplace si lamenta che da due ore manda errori 500. Per la PMI veneta che ti raccontavo all'inizio, ho implementato esattamente la pipeline che hai letto qui: 220 test Feature su Pest 3, mutation testing al 78%, suite completa in CI in 28 secondi. Tre mesi dopo, hanno deployato cinque feature nuove sull'integrazione marketplace senza un solo incident in produzione. Se vuoi capire come applicare questo approccio al tuo backend Laravel, scopri come lavoro con le PMI sui temi di qualità del software - dieci anni di codice in produzione mi hanno insegnato che il test automatico è l'unica forma di "documentazione" che non mente. Se stai valutando di portare il tuo team verso una pipeline di testing matura ma non sai da dove cominciare, contattami per una consulenza e in due settimane ti consegno un piano di adozione concreto: cosa testare per primo, come strutturare la suite, come integrarla in CI senza fermare il flusso delle feature.