Québec, Canada

403-1381 1re Avenue

+1 581.849.27.96

bdgouthiere@gmail.com

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// Package panier calcule le montant d'une commande.
package panier

// Total renvoie le montant à payer, en centimes : 10 % de remise
// dès 100 euros d'achats, livraison à 4,90 euros sous 50 euros.
func Total(sousTotal int) int {
	total := sousTotal
	if sousTotal >= 10000 {
		total -= sousTotal / 10
	}
	if sousTotal < 5000 {
		total += 490
	}
	return total
}

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.

1
2
3
4
5
6
7
8
func TestTotal(t *testing.T) {
	if Total(2000) <= 2000 {
		t.Error("la livraison doit être facturée")
	}
	if Total(20000) >= 20000 {
		t.Error("la remise doit s'appliquer")
	}
}

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 :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
$ gremlins unleash
      KILLED CONDITIONALS_NEGATION at panier.go:11:15
       LIVED ARITHMETIC_BASE at panier.go:9:22
       LIVED CONDITIONALS_BOUNDARY at panier.go:8:15
       LIVED CONDITIONALS_BOUNDARY at panier.go:11:15
      KILLED CONDITIONALS_NEGATION at panier.go:8:15

Mutation testing completed in 478 milliseconds 901 microseconds
Killed: 2, Lived: 3, Not covered: 0
Timed out: 0, Not viable: 0, Skipped: 0
Test efficacy: 40.00%
Mutator coverage: 100.00%

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
func TestTotal(t *testing.T) {
	tests := []struct {
		nom       string
		sousTotal int
		attendu   int
	}{
		{"petit panier", 2000, 2490},
		{"un centime sous la livraison offerte", 4999, 5489},
		{"seuil de la livraison offerte", 5000, 5000},
		{"un centime sous la remise", 9999, 9999},
		{"seuil de la remise", 10000, 9000},
		{"gros panier", 20000, 18000},
	}
	for _, tt := range tests {
		t.Run(tt.nom, func(t *testing.T) {
			if got := Total(tt.sousTotal); got != tt.attendu {
				t.Errorf("Total(%d) = %d, attendu %d", tt.sousTotal, got, tt.attendu)
			}
		})
	}
}

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 :

1
2
3
4
5
6
7
// Plafonner limite n à la valeur limite.
func Plafonner(n, limite int) int {
	if n > limite {
		return limite
	}
	return n
}

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, --diff et 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 :

OutilFonctionnementLicenceDernière version
Gremlinscommande gremlins unleash, option --diffApache 2.0v0.6.0, décembre 2025
go-mutesting (Avito)commande, plus de mutants par défautMITcommit du 2 octobre 2026, passage à Go 1.27
oozebibliothèque lancée comme un test GoMITbranche 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.



  1. 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. ↩︎

  2. La documentation de la commande unleash définit l’efficacité comme KILLED / (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). ↩︎

  3. 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, le go.mod ne portant pas le suffixe /v2 ; @latest résout le dernier commit, celui du 2 octobre 2026 dans mon essai. ↩︎

  4. Mesure du 16 octobre 2026 sur gorilla/mux, commit db9d1d0 du 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. ↩︎

  5. 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. ↩︎

  6. 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=mutation depuis un fichier de test qui appelle ooze.Release(t). ↩︎