Test par mutation en Go : les bugs que ta couverture à 100 % laisse passer
Ou : Saboter son propre code pour vérifier que quelqu’un regarde
Quinze lignes de Go, et go test -cover affiche 100 %. J’y ai glissé cinq bugs, un par un, et les tests en ont vu deux. C’est le principe du test par mutation, le mutation testing : modifier volontairement le code de production, un >= changé en >, une division changée en multiplication, puis relancer la suite de tests. Si un test échoue, le mutant est tué. Si tout reste vert, il a survécu, et un vrai bug de la même forme passerait lui aussi. En Go, Gremlins fait ce travail en une commande et ajoute un chiffre que la couverture de code ne donne jamais : la part des mutants que tes tests attrapent. Ici, 40 %.
En avril, l’article sur le piège des 100 % de couverture se terminait par un conseil : compléter la couverture par un audit de mutation, de temps en temps. Voici la pratique, avec ses bonnes surprises et deux mauvaises, dont une logée dans l’outil lui-même. L’idée n’a rien de neuf : Richard Lipton l’a eue dans un travail d’étudiant en 1971, et l’article fondateur, signé avec Richard DeMillo et Fred Sayward, date de 19781.
Mutation testing en Go : mutants tués, mutants survivants et score de mutation
Cent pour cent de couverture, trois mutants vivants
La fonction calcule le montant d’un panier : 10 % de remise dès 100 euros, livraison à 4,90 euros sous 50 euros. Les montants sont en centimes.
| |
Le test est de ceux qu’on écrit vite. Il vérifie un sens, pas une valeur : la livraison fait monter le prix, la remise le fait baisser.
| |
Les deux branches sont exécutées, go test -cover répond coverage: 100.0% of statements. Gremlins v0.6.0, installé avec go install github.com/go-gremlins/gremlins/cmd/gremlins@v0.6.0, ne lit pas les tests : il les lance contre des versions sabotées du code. Il mesure d’abord la couverture, puis ne teste que les mutants placés sur des lignes couvertes, puisqu’aucun test ne peut voir un sabotage dans une ligne qu’il n’exécute pas. Sortie réelle, sous Go 1.27.1 :
| |
Les deux mutants tués inversaient une condition entière, >= devenu < ou < devenu >= : trop grossier pour passer. Les trois survivants racontent chacun un bug crédible. Ligne 8, >= devenu > : le client à 100 euros pile perd sa remise. Ligne 11, < devenu <= : celui à 50 euros pile paie la livraison. Ligne 9, la division devenue multiplication : sur un panier de 200 euros, la remise grimpe à 2 000 euros, le total tombe à moins 1 800 euros, et le test, qui voulait seulement un montant inférieur à 200 euros, est ravi.
Gremlins appelle efficacité des tests la part de mutants tués parmi ceux que les tests atteignent : deux sur cinq2. La couverture disait que les tests étaient passés partout. La mutation dit qu’ils n’ont presque rien regardé en passant.
Viser les frontières plutôt que les sens
La correction tient en un test en table : six cas, dont quatre collés aux seuils, et des valeurs exactes au lieu de comparaisons.
| |
Même couverture, 100 %, mais cette fois Killed: 5, Lived: 0 et une efficacité de 100 %. La couverture n’a pas bougé d’un point entre les deux versions du test. Elle n’avait aucun moyen de voir la différence.
Par curiosité, j’ai lancé sur le même paquet go-mutesting, le fork maintenu par Avito. Il produit dix-sept mutants au lieu de cinq, parce qu’il touche aussi aux constantes : 5000 devient 5001, 490 devient 491. Avec les tests faibles, son score vaut 0,29 ; avec les tests en table, dix-sept mutants tués sur dix-sept3. Deux outils, deux scores sur le même code. Un score de mutation se compare avec lui-même, d’une semaine à l’autre, jamais d’un outil à l’autre.
Le mutant que personne ne peut tuer
Certains survivants ne signalent aucun trou dans les tests. Celui-ci, par exemple :
| |
Gremlins change > en >=, et le mutant survit, alors que mes tests vérifient précisément le cas Plafonner(10, 10). Il ne peut pas en être autrement : quand n égale limite, les deux versions renvoient la même valeur. Ce mutant est équivalent, aucun test ne le tuera jamais, et l’outil ne sait pas le reconnaître seul. Un humain doit lire la ligne et l’écarter. C’est la principale raison de ne pas exiger 100 % : le premier mutant équivalent rend l’objectif impossible, et pousse l’équipe à tordre le code pour plaire à l’outil.
Ce que ça coûte, et le chiffre qui ment à son tour
Pour une mesure plus réaliste, j’ai lancé Gremlins sur gorilla/mux, un routeur HTTP très répandu : 2 332 lignes de Go hors tests, une suite qui tourne en 0,04 seconde4. Premier essai, réglages par défaut : 221 mutants hors délai, 20 tués, aucun survivant, et une efficacité affichée de 100 %. Le délai accordé à chaque mutant vaut trois fois la durée de la mesure de couverture, alors que chaque mutant impose de recompiler le paquet, et seize s’exécutaient en parallèle. Gremlins exclut les mutants hors délai du calcul : ce 100 % portait sur 20 mutants sur 241.
Avec --timeout-coefficient 20, il ne reste que 2 mutants hors délai, pour 220 tués, 19 survivants et 92,05 % d’efficacité, en 65 secondes et 13 minutes de processeur cumulées. Le coût suit une règle simple : nombre de mutants multiplié par une compilation et une suite de tests. Raisonnable pour un paquet, ruineux pour un gros module, ce que le README de Gremlins reconnaît lui-même : un tel passage peut durer des heures.
Deuxième surprise, dans le garde-fou. --threshold-efficacy 80 doit faire échouer la commande sous 80 %. Sur mes tests faibles, à 40 %, elle sort avec le code 0. La même valeur placée dans .gremlins.yaml produit bien le code 10 et ERROR: below efficacy-threshold. Une correction attend depuis mai 2026 : passée en ligne de commande, la valeur se lit comme zéro, et zéro désactive le seuil5. Et avec un seuil de 100 dans le fichier, mes tests renforcés, efficaces à 100 %, échouent quand même : la comparaison s’écrit <= là où il fallait <. Un mutant de frontière, exactement celui que Gremlins sait fabriquer, vit dans le code de Gremlins.
Où le brancher, et avec quel outil
En avril, j’écrivais que la mutation n’était pas un outil de CI par pull request. Je nuance. Avec --diff main, Gremlins ne teste que les mutants des lignes modifiées depuis une branche ou un commit ; sur mon paquet, une ligne changée a laissé trois mutants « SKIPPED » et deux testés. Mon usage tient en trois règles :
- sur chaque pull request,
--diffet lecture des survivants, sans seuil bloquant ; - chaque nuit ou chaque semaine, le passage complet sur les paquets critiques, avec un seuil inférieur à 100 % écrit dans
.gremlins.yaml; - un délai élargi dès que les mutants hors délai dépassent la poignée.
Dans une stratégie de test complète, la mutation reste un audit, pas un juge. Trois outils sont vivants en octobre 20266 :
| Outil | Fonctionnement | Licence | Dernière version |
|---|---|---|---|
| Gremlins | commande gremlins unleash, option --diff | Apache 2.0 | v0.6.0, décembre 2025 |
| go-mutesting (Avito) | commande, plus de mutants par défaut | MIT | commit du 2 octobre 2026, passage à Go 1.27 |
| ooze | bibliothèque lancée comme un test Go | MIT | branche principale, août 2026 |
DevOps Dave : J’ai branché Gremlins sur la CI avec --threshold-efficacy 100. Tout est vert depuis un mois.
Security Sarah : Tu l’as déjà vu rouge ?
DevOps Dave : Jamais. Nos tests sont bons.
Security Sarah : Ton option est ignorée. Et le jour où tu la mettras dans le fichier de configuration, ton 100 % sera « sous le seuil ».
Un contrôle qu’on n’a jamais vu échouer ne contrôle rien. C’est tout le propos du test par mutation, et il s’applique aussi à l’outil qui le pratique. La couverture te dit que tes tests sont passés par chaque ligne. La mutation te dit s’ils ont regardé quelque chose en passant. Même chez Gremlins, un <= attend encore son test.
Richard Lipton le raconte lui-même dans un billet de 2023 consacré à Richard DeMillo : « I conceived the idea in a student paper in 1971 ». L’article fondateur est R. A. DeMillo, R. J. Lipton et F. G. Sayward, « Hints on Test Data Selection: Help for the Practicing Programmer », Computer, vol. 11, n° 4, avril 1978, p. 34 à 41. ↩︎
La documentation de la commande
unleashdéfinit l’efficacité commeKILLED / (KILLED + LIVED)et la couverture des mutants comme(KILLED + LIVED) / (KILLED + LIVED + NOT_COVERED). Les mutants hors délai n’entrent dans aucun des deux calculs. Le délai de chaque test vaut la durée de la mesure de couverture multipliée par un coefficient, fixé à 3 dans le code de la v0.6.0 (DefaultTimeoutCoefficient). ↩︎go-mutesting a été créé par Markus Zimmermann ; le fork d’Avito est celui qui continue. Attention à son vocabulaire : chez lui, un mutant « PASS » est un mutant tué, puisque la suite de tests a réussi à le détecter. Ses étiquettes v2 ne s’installent pas avec
go install, lego.modne portant pas le suffixe/v2;@latestrésout le dernier commit, celui du 2 octobre 2026 dans mon essai. ↩︎Mesure du 16 octobre 2026 sur gorilla/mux, commit
db9d1d0du 19 juin 2024, avec Gremlins v0.6.0 et Go 1.27.1, sur une machine à 16 fils d’exécution, nombre de travailleurs par défaut. Le README de Gremlins précise que l’outil vise les modules de taille modeste et qu’un très gros module peut demander des heures. ↩︎Les deux défauts sont décrits dans des demandes de fusion encore ouvertes le 16 octobre 2026 : la n° 281 explique que la valeur passée en option revient de la configuration sous forme de texte, que la conversion en nombre échoue en silence et donne zéro ; la n° 280 montre qu’avec
<=, un seuil de N % ne peut pas être atteint quand le résultat vaut exactement N %. J’ai reproduit les deux avec la v0.6.0. ↩︎Gremlins : v0.6.0 publiée le 6 décembre 2025, dernier commit le 30 mars 2026, toujours en 0.x, donc sans garantie de compatibilité des options. go-mutesting d’Avito : v2.3.1 le 26 décembre 2025, passage à Go 1.27 fusionné le 2 octobre 2026. ooze : dernière étiquette v0.3.1 en mai 2023, mais une branche principale active en août 2026, compatible avec Go 1.25 et suivants ; il se lance par
go test -tags=mutationdepuis un fichier de test qui appelleooze.Release(t). ↩︎