LLM per generazione test automatici: da 5% a 70% di copertura su codebase PHP legacy

LLM per generazione test automatici: da 5% a 70% di copertura su codebase PHP legacy

Il 1 febbraio 2026 ho dedicato una sessione di laboratorio a verificare se il pattern "LLM genera test, sviluppatore rivede" regge davvero su codebase PHP legacy. Per il test ho usato una sandbox costruita apposta: un Hetzner CX32 (4 vCPU Intel, 8 GB RAM DDR4, 80 GB NVMe) con Debian 12, PHP 8.3, Pest 3 (su PHPUnit 11), Xdebug per il coverage, una codebase PHP legacy sintetica di 140.000 righe che imita pattern reali che incontro negli audit - vecchio codice procedurale che è stato incapsulato di fretta in classi senza Dependency Injection, funzioni che mescolano I/O e logica, controller da 800 righe, 38% di codice duplicato misurato con PHP Copy/Paste Detector. La copertura di partenza, misurata con Xdebug + Pest, era del 5,2%. L'obiettivo era il 70% - soglia sopra la quale la maggior parte dei team consulenziali considera il codebase "sanamente testato" per modifiche successive - con la regola ferrea che ogni test dovesse testare qualcosa di vero e non passare solo per ragioni tautologiche. Nelle prime tre settimane di esperimento ho generato 2.400 test via Claude Sonnet 4.6 e, guardandoli con occhio critico, ne ho dovuti scartare il 41%. Il restante 59% ha prodotto una copertura del 68% a fine terzo mese, con 11 bug reali trovati durante la scrittura dei test che la codebase fino ad allora ospitava silenziosamente. Il pattern funziona, ma l'aneddoto "generiamo test con l'LLM" nasconde almeno quattro modi in cui il processo degenera se lo fai male. Raccontare come si rilevano e come si evitano è molto più utile che raccontare il 70% di copertura.

Perché un LLM genera test che passano sempre ma non testano nulla?

La risposta breve è che un LLM ottimizza per plausibilità, non per falsificabilità. Scrive test plausibili dato il codice e il prompt, ma senza istruzioni esplicite contrarie tende a generare test che verificano cose che non possono essere vere diversamente - assertion tautologiche. La classe del problema è ricorrente e la chiamo "tautological tests": codice che sembra testare ma verifica solo che PHP è PHP. Ho catalogato quattro anti-pattern tipici in tre mesi di osservazione su 2.400 test generati.

Il primo anti-pattern è assertion sul proprio input: il test chiama un metodo, ma nelle assertion verifica il valore del parametro appena passato invece del valore di ritorno. Il secondo è mock che restituiscono il valore atteso: il test stubba una dipendenza con un valore, il codice sotto test legge lo stub, e il test assert-a il valore che lo stub ha restituito - il System Under Test non ha fatto nulla di significativo. Il terzo è "not null" come unica assertion: $this->assertNotNull($result) quando il metodo restituisce un array statico costruito inline. Il quarto è assertion su tipo invece che su valore: assertIsString($result) quando il metodo hardcoda una stringa di ritorno - qualunque modifica interna al metodo che mantiene stringa passa il test, ma il test non vale niente.

La ragione profonda è che l'LLM non ha accesso al behavior vero del codice - ha accesso solo al testo sorgente. Senza strumentazione che faccia girare davvero il codice prima di generare i test (characterization testing nel senso classico di Feathers), l'LLM sta inferendo il comportamento da pattern sintattici, e quell'inferenza sbaglia spesso. Il workflow corretto non chiede al modello "scrivi test per questa classe", ma "ecco il codice, ecco l'output su 30 chiamate reali con input diversificati, scrivi test che verifichino esattamente questi output". Il costo: una volta aggiunto lo stadio di esecuzione dinamica, la percentuale di test utili sale dal 59% al 86% nei miei benchmark.

Se vuoi vedere come costruisco pipeline AI che sostituiscono lavoro ripetitivo senza sostituire il giudizio tecnico, nel mio hub sull'automazione AI per aziende trovo articoli che affrontano use case concreti - documentazione, test, classificazione, report automatici - con la stessa metodologia: contratto strutturato prima del prompt, validazione dopo, feedback loop sul risultato.

Il primo segno del problema: la coverage sale, i bug trovati no

Nella prima settimana di esperimento, entusiasmo ingenuo: ho generato 800 test in 2 giorni, coverage passata dal 5,2% al 38%. Ho committato senza guardarli uno per uno, mi sono detto "passano, il dashboard sale". Alla terza settimana ho aperto un mutation testing con Infection per validare la qualità reale dei test. Il risultato è stato: mutation score 11%. Tradotto: di ogni 100 modifiche artificiali al codice (sostituire > con <, cambiare + in -, negare una condizione), solo 11 venivano catturate dai test. 89 mutazioni passavano inosservate. Il sistema di test era decorativo: dava un numero di coverage, non dava sicurezza sulle modifiche.

La differenza fra code coverage e mutation score è il metro vero. Coverage misura quali righe sono state eseguite dai test; mutation score misura se i test catturano cambiamenti semantici al codice. Un codebase con 70% di coverage e 20% di mutation score è peggio di un codebase con 40% di coverage e 60% di mutation score - il secondo è manutenibile, il primo è falso conforto. L'incident della terza settimana mi ha costretto a cambiare workflow.

L'anatomia del test inutile: esempi concreti dal mio laboratorio

Per fissare i quattro anti-pattern, un esempio di ciascuno come l'LLM me l'ha prodotto prima della correzione.

Anti-pattern 1 (assertion sull'input):

public function test_calculate_vat_applies_correct_rate(): void
{
    $price = 100.00;
    $result = $this->calculator->calculateVat($price, 22);
    $this->assertEquals(100.00, $price); // assert sull'input, non sul risultato
}

Anti-pattern 2 (mock che restituisce valore atteso, poi asserted):

public function test_user_repository_returns_user(): void
{
    $mock = $this->createMock(UserRepository::class);
    $mock->method('findById')->willReturn(new User(id: 42, email: 'test@example'));
    $service = new UserService($mock);

    $user = $service->getUser(42);
    $this->assertEquals(42, $user->id); // circular: l'id è quello che ho messo nel mock
}

Anti-pattern 3 (not-null come unica assertion su metodo che restituisce sempre un array):

public function test_build_response_returns_array(): void
{
    $response = $this->builder->buildResponse([]);
    $this->assertNotNull($response);
    $this->assertIsArray($response); // tautological: il metodo ha return type array
}

Anti-pattern 4 (assertion su tipo invece che valore quando il valore conta):

public function test_format_iban_returns_string(): void
{
    $formatted = $this->formatter->format('IT60X0542811101000000123456');
    $this->assertIsString($formatted); // passa anche se il formato è sbagliato
}

Questi test passano tutti, aumentano la coverage, non verificano nulla di utile. Un developer senior li identifica in 10 secondi guardandoli. Un LLM che li legge per riformularli senza cambiare la loro semantica può anche "migliorarli" al livello stilistico e lasciarli tautologici. La disciplina del processo è identificare e rigenerare ogni volta che uno di questi pattern emerge.

Il workflow corretto: characterization test first, prompt vincolato dopo

Il cambio di workflow che ha portato il mutation score dall'11% al 57% è strutturale e ha tre fasi. Prima fase, characterization: eseguo il codice sotto test con un set di input plausibili (5-10 casi) e registro gli output effettivi. Uso Xdebug con var_export() strutturato per catturare input-output come coppie serializzate. Questo è il ground truth del comportamento attuale - cattura anche i bug esistenti, ma è la base da cui partire se vuoi che i test verifichino davvero cosa fa il codice oggi.

// Esegui il codice con input reali e cattura output
$observations = [];
foreach ($sampleInputs as $input) {
    try {
        $output = $service->process($input);
        $observations[] = ['input' => $input, 'output' => $output, 'exception' => null];
    } catch (\Throwable $e) {
        $observations[] = ['input' => $input, 'output' => null, 'exception' => get_class($e)];
    }
}
file_put_contents('observations.json', json_encode($observations, JSON_PRETTY_PRINT));

Seconda fase, prompt vincolato: passo al LLM il codice sotto test più il file observations.json con la regola esplicita "genera test che verifichino che gli output matchino esattamente questi valori per questi input; non aggiungere test senza input osservato; se un'osservazione è un'eccezione, il test deve asserire che quella eccezione viene lanciata". Il prompt include anche una lista esplicita degli anti-pattern da evitare, con esempi negativi. Questo riduce la probabilità di test tautologici di un ordine di grandezza.

Terza fase, mutation score gate: dopo aver generato i test, lancio Infection sul modulo. Se il mutation score scende sotto il 50%, il commit non va - i test hanno passato il syntax check ma non catturano i bug veri. Il developer umano guarda i survived mutant e identifica quali test erano inutili, li rimuove, e rigenera con prompt più stringente.

Questo workflow a tre fasi è lo stesso pattern che applico al bot di code review LLM per GitHub Actions - estrazione deterministica del ground truth (in quel caso lo fa PHPStan), prompt vincolato al contesto reale, validazione a valle. La disciplina non cambia: il modello non è la fonte di verità, è il generatore di forma sopra una verità che estrai da altre parti.

Il prompt che produce test realistici (e non plausibili)

Il system prompt che uso nella versione attuale è il risultato di 14 iterazioni su tre mesi. Tre regole lo tengono in carreggiata.

Regola uno: non testare comportamenti non osservati. L'istruzione esplicita è "genera test solo per i casi presenti in observations.json. Se ti viene in mente un caso edge plausibile ma non osservato, NON generare un test per esso - segnalalo come <suggestion>case non coperto: <descrizione></suggestion> e lo scriverò manualmente se lo ritengo rilevante".

Regola due: assertion mirate sul valore di ritorno. L'istruzione esplicita è "ogni test deve contenere almeno una assertion sul return value del metodo sotto test. assertNotNull e assertIsArray contano come assertion supplementari, non come assertion principali. Se il metodo restituisce void, verifica lo stato osservabile via un altro metodo pubblico o un'invocazione tracciabile del mock".

Regola tre: niente mock di dipendenze che non usi. L'istruzione è "stubba SOLO le dipendenze che il metodo sotto test chiama davvero. Se vedi una dipendenza iniettata ma non usata nel flusso coperto dal test, non stubbarla - lasciala reale o passagli null se il tipo lo permette. Mock superflui creano test smell e aumentano la superficie di manutenzione".

Con queste tre regole il mutation score medio dei test generati è passato dall'11% al 57%. È sotto il livello di test scritti a mano da senior (tipicamente 75-85%) ma è un aumento di un ordine di grandezza rispetto alla versione ingenua, ed è sufficiente a rendere il codebase manutenibile per modifiche future.

Il linter dei test generati: il controllo deterministico che precede mutation testing

Prima di far girare Infection (che richiede 2-8 minuti per modulo), un linter custom scorre i test generati e rigetta quelli che violano regole deterministiche. I controlli sono questi.

<?php
final class GeneratedTestLinter
{
    public function lint(string $testFile): array
    {
        $ast = $this->parser->parseFile($testFile);
        $issues = [];

        foreach ($ast->getMethods() as $method) {
            if (!$this->isTestMethod($method)) continue;

            $assertions = $this->extractAssertions($method);
            if (count($assertions) === 0) {
                $issues[] = "no assertions in {$method->getName()}";
                continue;
            }

            if ($this->allAssertionsAreTypeOrNullChecks($assertions)) {
                $issues[] = "weak assertions in {$method->getName()}: only type/null checks";
            }
            if ($this->hasAssertionOnInput($method)) {
                $issues[] = "anti-pattern: assertion on input parameter in {$method->getName()}";
            }
            if ($this->hasOnlyCircularMockAssertions($method)) {
                $issues[] = "anti-pattern: circular mock assertion in {$method->getName()}";
            }
        }
        return $issues;
    }
}

Il linter non è perfetto - un LLM creativo può scrivere test tautologici che nessun regex cattura - ma filtra il 70-80% dei test inutili prima ancora di arrivare al mutation testing. Con entrambi i filtri in pipeline (linter statico + mutation score gate), la probabilità che un test inutile sopravviva fino al commit è sotto l'1%.

Il workflow incrementale: dal 5% al 70% in 12 settimane

Il passaggio da 5% a 70% non avviene in un colpo. Nella mia sandbox l'ho scomposto in quattro tappe. Settimana 1-3: safety net sui moduli più toccati dall'attività di sviluppo corrente (i 15 file con più churn git negli ultimi 90 giorni). Target: coverage 25% su quei moduli, mutation score > 50%. Settimana 4-6: critical path - i moduli di dominio (billing, auth, shopping cart) indipendentemente dal churn. Target: coverage 60% lì, mutation score > 55%. Settimana 7-9: periphery - utility, helper, wrapper. Target: coverage 80% sui moduli facili, mutation score > 45%. Settimana 10-12: legacy hotspots - i moduli che tutti hanno paura di toccare. Target: coverage 40% ma con test che documentino il comportamento attuale, non lo presumano corretto.

La strategia è opposta a "vado dalla classe A alla classe Z ordine alfabetico". Segue il value operativo: prima coperti i moduli che cambiano di più, poi i moduli critici per il business, poi la periferia facile, poi infine gli hotspot difficili dove il rischio di rompere qualcosa è alto. Il pattern è il "Working Effectively with Legacy Code" di Feathers applicato letteralmente - con il delta che l'LLM produce i test meccanici mentre tu conservi energia per le decisioni architetturali.

Quando fermarsi: il 70% come asymptote, non come obiettivo per principio

Arrivare al 70% è realistico in 10-12 settimane per una codebase da 140k righe con un developer dedicato al 50% al progetto. Arrivare al 90% costa altri 6 mesi e raramente vale la pena - gli ultimi 20 punti percentuali sono casi edge, branch difensivi, error path che richiedono setup elaborati. Il ROI è decrescente e, sopra l'80%, comincia a essere negativo.

Il modo operativo che uso per capire quando fermarsi è una domanda semplice: "se un bug nuovo venisse segnalato in un modulo non ancora coperto, quanto velocemente potrei aggiungere un test caratterizzante e un fix?". Se la risposta è "poche ore", il modulo è già tracktabile con i pattern appresi e non vale investire prima che succeda. Se la risposta è "giorni perché quel modulo ha troppe dipendenze hard-coded", allora quel modulo ha un problema di testability che nessun LLM risolve - serve refactoring. L'LLM amplifica la disciplina di chi già sa testare; non sostituisce la disciplina di chi non sa.

Il pattern che ho descritto ha un punto debole che conviene dichiarare esplicitamente: test generati dall'LLM non sostituiscono i test integration end-to-end, che restano lavoro di ragionamento architetturale del senior. L'LLM è bravissimo su unit test di metodi puri o quasi puri, è mediocre su test di workflow che attraversano più moduli, ed è pessimo su test che richiedono setup di fixture complesse con stato pre-esistente del database. Usarlo dove brilla - unit test con characterization - e non dove zoppica, è parte integrante del fit for purpose di questo workflow.

Un codebase che passa da 5% a 70% di coverage in tre mesi non è più lo stesso codebase. Non perché "il numero è salito", ma perché il team ha costruito un livello di sicurezza da cui può fare refactoring, aggiungere feature, accettare pull request senza il terrore paralizzante tipico delle codebase legacy. Il valore reale della coverage alta non è il numero: è la velocità di cambiamento che abilita. E nel 2026, con l'LLM che toglie all'umano il 70% del lavoro meccanico di scrittura test, quella velocità è accessibile a team che fino a due anni fa non avevano budget o competenze per ottenerla. La differenza fra chi ci arriva e chi resta al 10% non è tecnologica - è disciplinare: chi sa leggere un test tautologico e rigenerarlo, chi sa quando fermarsi, chi sa che il numero di coverage non è il traguardo ma il sintomo di un processo sano.

Se stai affrontando una codebase PHP legacy senza test e vuoi capire se il pattern "LLM characterization + mutation gate" è adatto alla tua situazione, il modulo di preventivo gratuito ti dà una prima lettura in 7 domande, 2 minuti. Ti dico se il tuo progetto rientra nelle cose che so fare bene e, se il caso richiede un profilo diverso, te lo dico e ti indico una direzione utile quando posso.

Ultima modifica: