# Test par mutation en Go : les bugs que ta couverture à 100 % laisse passer

> Quinze lignes de Go couvertes à 100 %, et des tests qui n'attrapent que deux bugs sur cinq. Le test par mutation, démontré avec Gremlins et go-mutesting sous Go 1.27.1 : mutants survivants, mutant équivalent, coût réel sur gorilla/mux et deux pièges dans les seuils de Gremlins.


*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](/blog/code-coverage-piege-100-go/) 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 1978[^1].

## Mutation testing en Go : mutants tués, mutants survivants et score de mutation
{.subtitle}

### 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.

```go
// 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.

```go
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 :

```text
$ 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 cinq[^2]. 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](/blog/table-driven-tests-go/) : six cas, dont quatre collés aux seuils, et des valeurs exactes au lieu de comparaisons.

```go
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-sept[^3]. 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 :

```go
// 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 seconde[^4]. 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 seuil[^5]. 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](/blog/strategie-test-complete-go/), la mutation reste un audit, pas un juge. Trois outils sont vivants en octobre 2026[^6] :

| 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.

---

[^1]: Richard Lipton le raconte lui-même dans [un billet de 2023 consacré à Richard DeMillo](https://rjlipton.com/2023/02/19/thanks-to-rich/) : « 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 »](https://ieeexplore.ieee.org/document/1646911/), *Computer*, vol. 11, n° 4, avril 1978, p. 34 à 41.

[^2]: La [documentation de la commande `unleash`](https://gremlins.dev/latest/usage/commands/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](https://github.com/avito-tech/go-mutesting) 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](https://github.com/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](https://github.com/go-gremlins/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](https://github.com/go-gremlins/gremlins/pull/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](https://github.com/go-gremlins/gremlins/pull/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](https://github.com/gtramontina/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)`.



