LLM per la generazione di test automatici: da zero a copertura del 70% senza sforzo

LLM per la generazione di test automatici: da zero a copertura del 70% senza sforzo

A ottobre 2025 ho preso in carico un e-commerce Laravel per un cliente del settore retail con circa 40.000 righe di codice PHP, 186 classi tra controller, service, model e job, e una suite di test che consisteva in esattamente tre file: un test che verificava che la homepage rispondesse con status 200, un test che verificava la creazione di un utente con dati validi, e un test commentato che probabilmente non funzionava da due anni. La copertura misurata con PHPUnit e Xdebug era del 3,2% - il che significava che il 96,8% del codice poteva contenere regressioni invisibili ad ogni deploy. Il team deploiava in produzione con la strategia "speriamo che funzioni": push su main, deploy automatico, e poi refresh manuale delle pagine critiche per verificare che non si fosse rotto nulla. In media, una regressione in produzione ogni due settimane, con un costo medio di 4 ore di debug e hotfix per incidente.

Il budget e le tempistiche non permettevano di scrivere una suite di test completa da zero - servirebbero circa 200-300 ore di lavoro per coprire decentemente 186 classi con test unitari e di integrazione, e il team non aveva le competenze di testing per farlo in modo efficace. Ho proposto un approccio diverso: usare l'API di Claude come generatore semi-automatizzato di test, con supervisione umana sulle asserzioni e validazione manuale dei test generati. In tre settimane di lavoro distribuito (circa 4 ore al giorno), ho portato la copertura dal 3,2% al 68,4% - con 312 test che passavano tutti al verde e una pipeline CI/CD che li eseguiva ad ogni push. Il numero di regressioni in produzione nei tre mesi successivi: due, contro le sei del trimestre precedente. Il costo delle sei regressioni del trimestre precedente, calcolato in ore di debug e hotfix, era stato di circa 24 ore di lavoro sviluppatore - più del tempo totale investito nel progetto di testing. Non è magia - è automazione con supervisione, e i numeri parlano da soli.

Perché un LLM può generare test migliori di quanto ci si aspetti?

La generazione di test è uno dei casi d'uso dove un LLM brilla di più, per una ragione strutturale: i test sono codice altamente formulaico con pattern ripetitivi. Un test unitario segue quasi sempre lo schema arrange-act-assert: prepara i dati di input, esegui il metodo, verifica il risultato. L'LLM è eccellente nel riconoscere la firma di un metodo PHP (parametri, tipi, return type), inferire i casi di test ragionevoli (input valido, input nullo, input con caratteri speciali, input fuori range), e generare il boilerplate del test con le asserzioni appropriate. Il lavoro umano - la parte che l'LLM non può fare in modo affidabile - è verificare che le asserzioni siano semanticamente corrette, non solo sintatticamente valide.

Un esempio concreto chiarisce la distinzione. L'LLM genera un test per un metodo calcolaSconto(float $importo, string $codiceCoupon): float e scrive un'asserzione $this->assertEquals(85.0, $service->calcolaSconto(100.0, 'PROMO15')). L'asserzione è strutturalmente corretta - verifica che lo sconto del 15% su 100 euro produca 85 euro. Ma se la logica di business prevede che il coupon PROMO15 sia valido solo per ordini superiori a 50 euro e solo per clienti registrati da più di 6 mesi, l'LLM non può saperlo guardando solo la firma del metodo - e il test che genera potrebbe passare per caso oggi e fallire domani quando qualcuno aggiorna le regole del coupon. La supervisione umana serve esattamente a questo: verificare che ogni asserzione corrisponda alla logica di business reale, non solo alla logica sintattica del codice.

Nel mio profilo professionale trovi il dettaglio dell'esperienza che porto nell'automazione dei processi di testing - e la regola che applico è che l'LLM scrive il boilerplate (80% del lavoro), l'umano scrive le asserzioni critiche (20% del lavoro ma 100% del valore).

Il workflow: dalla classe PHP al test generato e validato

Il processo che ho sviluppato è un pipeline in tre passaggi che può essere replicato su qualsiasi codebase Laravel o Symfony:

Passaggio 1: estrazione del contesto. Per ogni classe da testare, estraggo la classe stessa, le sue dipendenze (interfacce iniettate, model usati), e se disponibili i test esistenti del progetto come esempio di stile. Questo contesto viene impacchettato in un prompt strutturato:

Genera test PHPUnit per la seguente classe PHP. Usa Pest syntax se
disponibile, altrimenti PHPUnit classico. Regole:
1. Testa ogni metodo pubblico con almeno 3 casi: input valido,
   input nullo/vuoto, e input edge case
2. Per i metodi che accedono al database, usa RefreshDatabase
   e factory/seeder per i dati di test
3. Per i metodi che chiamano servizi esterni, usa mock con Mockery
4. Nomi dei test in italiano descrittivo con il pattern
   "verifica che [azione] quando [condizione]"
5. Ogni test deve avere un singolo assert (o un gruppo strettamente
   correlato). Niente test con 10 asserzioni diverse

Classe da testare:
[contenuto della classe]

Dipendenze:
[interfacce e model usati]

Passaggio 2: generazione e review. L'LLM genera il file di test. Lo leggo, verifico le asserzioni (il punto critico), e correggo quelle che non corrispondono alla logica di business. In media, su 10 asserzioni generate, 7-8 sono corrette, 1-2 richiedono una correzione del valore atteso, e 0-1 devono essere riscritte perché il caso di test non ha senso nel contesto del business.

Passaggio 3: esecuzione e copertura. Eseguo il test generato, verifico che passi, e misuro la copertura incrementale. Se la copertura della classe è sotto il 60%, chiedo all'LLM di generare test aggiuntivi per i rami non coperti, passandogli il report di copertura come contesto.

# Pipeline semi-automatizzata per la generazione test
# Eseguito per ogni classe del progetto

# 1. Estrai la classe e le sue dipendenze
CLASS_FILE="app/Services/OrderService.php"
DEPS=$(grep -E "^use " "${CLASS_FILE}" | sort -u)

# 2. Genera il test via API Claude (script Python dedicato)
python3 tools/generate-test.py \
    --class "${CLASS_FILE}" \
    --deps "${DEPS}" \
    --output "tests/Unit/Services/OrderServiceTest.php"

# 3. Esegui il test e verifica la copertura
php artisan test tests/Unit/Services/OrderServiceTest.php \
    --coverage-text --min=60

# 4. Se la copertura è insufficiente, itera con il report
php artisan test --coverage-html=storage/coverage

I numeri reali: costi, tempi e qualità su 186 classi

La trasparenza sui numeri è fondamentale perché l'hype sull'AI tende a nascondere i costi e a esagerare i benefici. Ecco i dati reali del progetto e-commerce:

  • Classi totali: 186 (controller, service, model, job, event, listener)
  • Classi testate dopo il progetto: 142 (le rimanenti 44 sono classi triviali - model senza logica, event senza handler - per cui un test aggiunto poco valore)
  • Test generati totali: 312 (mediamente 2,2 test per classe)
  • Test corretti dopo review umana: 67 (21,5% del totale - 67 test su 312 hanno richiesto correzione delle asserzioni)
  • Test riscritti completamente: 11 (3,5% del totale - casi dove l'LLM aveva generato test non sensati)
  • Copertura finale: 68,4% (dal 3,2% iniziale)
  • Tempo totale: 62 ore distribuite su 3 settimane (4h/giorno)
  • Costo API Claude: 78 euro totali (circa 0,25 euro per classe testata)
  • Costo equivalente senza LLM: stimato 250-300 ore (4-5 volte il tempo effettivo)

Il dato che considero più significativo è il 21,5% di test corretti dopo review: significa che il 78,5% dei test generati dall'LLM era corretto al primo colpo - un tasso di affidabilità eccellente per un tool di generazione automatica, ma che sarebbe stato un disastro se quei test fossero stati integrati senza review. Un test con asserzione sbagliata è peggio di nessun test: dà una falsa sicurezza che il codice sia corretto quando non lo è.

Dove l'LLM fallisce sistematicamente e dove interviene l'umano

Dopo tre settimane di generazione intensiva, ho identificato le categorie di test dove l'LLM produce risultati di qualità consistentemente bassa e dove il tempo di review supera il tempo di scrittura manuale - rendendo l'automazione controproducente.

La prima categoria è i test che coinvolgono logica di business condizionale complessa. Quando un metodo ha 5-6 rami condizionali che dipendono da combinazioni di stato (utente premium con ordine superiore a X euro durante una promozione attiva nel suo territorio), l'LLM non riesce a generare tutti i casi di test significativi perché non comprende il dominio di business. In questi casi, genero solo lo scaffold del test con l'LLM (setup, teardown, naming dei metodi) e scrivo le asserzioni a mano.

La seconda categoria è i test di integrazione che coinvolgono servizi esterni. L'LLM genera mock di servizi esterni (gateway di pagamento, API di spedizione, servizi di fatturazione elettronica) ma i mock sono quasi sempre troppo semplificati: simulano solo la risposta di successo, ignorano i timeout, i rate limit, le risposte malformate e gli errori parziali. Un mock che restituisce sempre {"status": "success"} non testa nulla di utile - il valore del test di integrazione sta nel verificare come il codice reagisce ai fallimenti. Per questi test, scrivo i mock a mano con scenari di errore realistici basati sulla mia esperienza con i servizi reali.

La terza categoria è i test su codice con side effect impliciti. Quando un metodo modifica lo stato globale dell'applicazione (scrive nel cache, pubblica un evento, invia un'email, modifica una variabile di sessione) senza che questo sia evidente dalla firma del metodo, l'LLM non testa il side effect perché non sa che esiste. La soluzione è passare all'LLM non solo la classe da testare ma anche le classi chiamate internamente, in modo che possa inferire i side effect - ma questo aumenta significativamente il contesto e non sempre produce risultati migliori.

La regola pratica che ho derivato è: l'LLM è lo strumento giusto per i test unitari su metodi con logica deterministica e firma esplicita (calcolaSconto, validaEmail, formattaData, convertiValuta), ed è lo strumento sbagliato per i test di integrazione su flussi complessi che attraversano più servizi e hanno dipendenze implicite dal contesto. Per il primo tipo, la generazione automatica funziona nel 85-90% dei casi. Per il secondo tipo, l'LLM produce scaffold utile ma le asserzioni devono essere interamente umane. Sapere quando delegare e quando scrivere a mano è la competenza che distingue un approccio LLM-assistito efficace da uno che produce test inutili con alta copertura ma basso valore.

Ho descritto l'approccio manuale per l'introduzione di test su codebase PHP legacy nel mio articolo sui test automatici senza riscrittura - quell'articolo copre la strategia di testing per team senza budget per l'automazione AI, con un approccio incrementale basato su smoke test e harness mirati. L'approccio LLM-assistito descritto qui è il complemento per team che hanno budget per l'API e vogliono accelerare drasticamente il tempo di raggiungimento di una copertura decente.

La lezione operativa del progetto è che l'LLM non rende i test "gratuiti" - rende il boilerplate gratuito. Il lavoro intellettuale di capire cosa testare, verificare che le asserzioni corrispondano alla realtà del business, e decidere quale livello di copertura è sufficiente per quale componente, resta interamente umano. Ma quel lavoro intellettuale, che è il 20% del tempo totale, è anche il 100% del valore - e l'LLM ti libera dall'80% di lavoro meccanico che altrimenti ti scoraggerebbe dal farlo. Se gestisci una codebase PHP con copertura di test bassa e vuoi portarla a un livello decente senza investire centinaia di ore di lavoro manuale, contattami per pianificare un progetto di testing LLM-assistito: partiamo dall'analisi della codebase, identifichiamo le classi critiche da testare per prime, e configuriamo la pipeline di generazione con le regole specifiche del tuo progetto.

Ultima modifica: