- 2026-02-10
- 5 minuti
Mutation Testing: guida pratica per trovare bug che la coverage non vede

Hai una coverage al 95%, tutti i test verdi, il badge nel README che brilla. Poi un bug banale arriva in produzione, uno sconto calcolato al contrario o un boundary off-by-one, e ti chiedi: ma i test cosa stavano verificando, esattamente?
Con gli LLM il problema si amplifica. Finalmente i test entrano nel concreto all’interno del flusso di sviluppo, finalmente non sono un “ci penseremo poi”. Ma se deleghi all’LLM il compito di occuparsi della suite di test, come fai a sapere di stare testando le cose giuste? Soprattutto quando il volume cresce e diventa molto difficile revisionare manualmente ogni cosa?
Qui entra in gioco il mutation testing: un approccio che ribalta la domanda. Non “il mio codice è coperto?” ma “i miei test sono in grado di trovare i difetti?”.
Il problema: coverage non significa qualità
Guarda questo esempio. Un servizio banale con uno sconto per ordini grandi:
public decimal CalculateDiscount(decimal price, int quantity)
{
if (quantity > 10)
return price * 0.9m;
return price;
}
E il test che lo accompagna:
[Fact]
public void CalculateDiscount_WithHighQuantity_ReturnsValue()
{
var result = _service.CalculateDiscount(100, 20);
Assert.True(result > 0);
}
Eventuali tool a supporto direbbero che abbiamo una coverage del 100% perché abbiamo coperto entrambi i possibili path di esecuzione.
Possiamo ritenerci soddisfatti?
Immaginiamo che questa funzione evolva nel tempo: potresti cambiare lo sconto da 10% a 50%, invertire la condizione, restituire sempre price senza sconto: il test passerebbe lo stesso.
Questo scenario era già un problema prima degli LLM ma adesso è amplificato: se produci Nx codice in più, la review non può essere accurata come quando produci x. Leggere il codice di altri è estremamente più complesso che scriverlo, soprattutto quando non puoi chiedere spiegazioni o motivazioni all’autore.
E c’è un bias cognitivo reale: se il codice è ben presentato e organizzato, tendiamo a pensare che sia corretto. Siamo pigri. Vogliamo finire in fretta e la coverage verde ci dà la scusa perfetta per andare avanti senza guardare troppo.
Come funziona il mutation testing
Il concetto è semplice: testare i test.
Pensaci: la coverage ti dice “questa riga è stata eseguita durante un test” ma non ti dice se il test ha verificato qualcosa di sensato su quella riga. Paradossalmente potresti attraversare tutto il codice senza mai controllare un risultato.
Il mutation testing ribalta l’approccio: invece di verificare se il codice è coperto, introduce piccole modifiche nel tuo sorgente, chiamate mutanti, e controlla se i test se ne accorgono. Se una modifica passa inosservata, hai un buco nella suite. Se i test falliscono, significa che stanno facendo il loro lavoro.
Il processo in 4 step:
- Analisi del sorgente: il tool identifica i punti in cui inserire modifiche plausibili
- Generazione dei mutanti: crea versioni del codice con una singola modifica ciascuna (cambiare
>in>=,*in/,trueinfalse, rimuovere una riga) - Esecuzione della suite - lancia i tuoi test contro ogni mutante
- Classificazione: se almeno un test fallisce, il mutante è killed (bene). Se tutti i test passano nonostante la modifica, il mutante è survived (male: hai un buco)
Tornando al nostro esempio di prima: un mutation tool potrebbe generare questi mutanti dal metodo CalculateDiscount:
- Cambiare
quantity > 10inquantity >= 10(boundary mutation) - Cambiare
price * 0.9minprice / 0.9m(arithmetic operator) - Rimuovere l’intero
ife restituire sempreprice(statement deletion)
Il nostro test con Assert.True(result > 0) non ne catturerebbe nessuno. Tutti e tre sopravviverebbero. Un test che invece verifica Assert.Equal(90, result) intercetterebbe tutte le modifiche.
Il risultato finale è il mutation score: la percentuale di mutanti individuati sul totale.
mutation score = killed / (killed + survived)
Un mutation score dell'80% significa che il 20% delle modifiche plausibili al tuo codice non verrebbe rilevato dai test esistenti. Non è una metrica da portare al 100%, ma ti dice dove i tuoi test sono deboli.
💡 La differenza fondamentale: la coverage misura quali righe vengono eseguite. Il mutation score misura quali difetti verrebbero catturati.
Setup pratico con Stryker
Stryker Mutator è un tool di riferimento per mutation testing. Supporta JavaScript/TypeScript, C# e Scala. È open source, attivamente mantenuto, e con un ecosistema maturo.
Vediamo come configurarlo. Per un progetto .NET:
# Installa il tool globale
dotnet tool install -g dotnet-stryker
# Lancia dalla cartella del progetto di test
dotnet stryker
Stryker analizza il sorgente, genera i mutanti, esegue la suite e produce un report HTML interattivo. Puoi navigare file per file e vedere esattamente quali mutanti sono sopravvissuti e perché.
Per ogni mutante survived, il report ti mostra la riga originale, la modifica introdotta e quali test sono stati eseguiti. In questo modo viene creata una mappa precisa dei punti ciechi presenti nella nostra suite di test.
Mutazione incrementale: solo il codice cambiato
Fin qui tutto bello, ma c’è un elefante nella stanza: il tempo di esecuzione. Generare e testare centinaia di mutanti su un intero progetto può richiedere ore. Su una codebase grande, diventa impraticabile.
Stryker risolve con la mutazione incrementale: puoi configurarlo per mutare solo il codice modificato rispetto a un branch base.
# Muta solo i file cambiati rispetto a main
dotnet stryker --since:main
Questo lo rende pratico da integrare in CI: lanci il mutation testing solo sulle righe toccate dalla PR. Il costo diventa proporzionale alla dimensione della modifica, non del progetto.
# Esempio: step in una pipeline GitHub Actions
- name: Mutation Testing (solo codice cambiato)
run: dotnet stryker --since:main --break-at:80
Il flag --break-at:80 fa fallire la build se il mutation score scende sotto l'80%. Un guardrail automatico.
Perché conta di più con il codice AI-generated
Potresti pensare: “ok, ma vale la pena anche senza AI?”. Sì, assolutamente. Il mutation testing esiste dagli anni ‘70. Ma con gli LLM c’è un’accelerazione su tre fronti che lo rende ancora più rilevante:
- Volume insostenibile per la review: leggere il codice di altri è estremamente più complesso che scriverlo. Quando il volume si moltiplica, inevitabilmente la qualità della review crolla
- Bias di correttezza: gli LLM sono bravissimi a produrre codice “bello” e ben organizzato, con nomi sensati e commenti nei posti giusti. Tutte cose che ci portano inconsciamente ad accettare la soluzione proposta (halo effect di Thorndike)
- Validazione circolare: se chiedi a un LLM di scrivere il codice e poi di scrivere i test per quel codice, ottieni una verifica che riflette gli stessi assunti e gli stessi punti ciechi. Il test tende a verificare esattamente quello che il codice fa, non quello che dovrebbe fare. La coverage sale, i test passano, ma nessuno ha effettivamente sfidato la logica. Il mutation testing spezza questo circolo perché è indipendente dall’intenzione sia di chi ha scritto il codice sia di chi ha scritto i test.
In fondo, tutto si riduce a una domanda: chi è il responsabile della qualità del codice generato da un LLM? “Chi lo ha accettato in produzione” è la risposta facile. Ma se il junior neoassunto ha modo di droppare una tabella in produzione, è colpa sua o dei processi che lo hanno consentito? Un errore non è quasi mai una decisione isolata, ma una catena di atti e omissioni.
Il mutation testing è uno dei pochi strumenti che ti dà una garanzia formale all’interno di questa catena: non si basa su opinioni, non si lascia impressionare dalla formattazione. Introduce un difetto e chiede ai test di trovarlo. Punto.
⚠️ Il mutation testing non è un sostituto per altri livelli di verifica. Non copre problemi di concorrenza, errori di integrazione o difetti di configurazione. È un complemento, non una soluzione unica.
Errori comuni da evitare
- Puntare al 100% di mutation score: non è l’obiettivo. Alcuni mutanti sono equivalenti (la modifica non cambia il comportamento). Punta a un threshold realistico (70-85%) e concentrati sui mutanti survived più critici
- Lanciarlo su tutto il progetto ad ogni commit: usa la mutazione incrementale (
--since:main). Muta solo il codice cambiato, altrimenti il tempo di esecuzione diventa proibitivo - Ignorare i mutanti survived: il valore del mutation testing sta nell’analisi dei sopravvissuti. Se un mutante survived non ti preoccupa, è probabile che quel test non servisse in partenza
- Usarlo come unica metrica di qualità: il mutation testing verifica la qualità dei test, non la qualità del design. Combinalo con test architetturali, linting e code review
Conclusioni
La coverage ti dice se il codice viene eseguito durante i test. Il mutation score ti dice se i test troverebbero un bug. Sono due domande molto diverse, e nel mondo in cui il volume di codice prodotto cresce ogni settimana, la seconda diventa più importante della prima.
Se usi agenti AI per scrivere codice (o anche se non li usi), prova a lanciare Stryker sulla tua codebase. I mutanti sopravvissuti ti racconteranno cose che la coverage non ti dirà mai.
Il mutation testing non è la soluzione a tutto, ma è un pezzo fondamentale di un approccio alla qualità che non si accontenta delle apparenze. E in un’epoca in cui il codice si scrive più velocemente che mai, rallentare per verificare davvero è un atto di responsabilità.
Risorse utili:
- Stryker Mutator - il tool di riferimento, supporta JS/TS, C# e Scala
- Stryker.NET - documentazione specifica per .NET
- Mutation Testing su Wikipedia - background teorico e storia della tecnica














