Les tests de mutation sont négatifs. Les tests TDD sont positifs.
On appelle les deux des tests, mais ils affirment des choses opposées. Un test TDD énonce ce que le système doit faire. Un run de mutation ne peut que signaler ce que votre suite n'a pas remarqué. Savoir quelle polarité vous tenez change ce qu'il faut faire du résultat.
Deux types d'affirmation
Un test piloté par les tests est écrit avant que le comportement existe. Il échoue, puis il passe, et dès lors il devient une affirmation permanente : cette entrée doit produire ce résultat. La suite s'accumule en une spécification lisible, et le fait de l'écrire d'abord met le design sous pression, car du code difficile à appeler est difficile à tester. Un run de mutation fait quelque chose de structurellement différent. Il prend du code qui fonctionne déjà, le modifie exprès et relance la suite. Chaque résultat est formulé au négatif : cette altération est passée inaperçue. Rien dans cette sortie ne dit à quoi sert le logiciel.
Une suite TDD verte est un ensemble d'affirmations sur des intentions. Un score de mutation élevé est l'absence d'un certain type de preuve contre votre suite. Une seule de ces deux choses est une spécification.
Le test positif
- + Écrit avant le code, donc l'exigence existe en mots avant d'exister en logique
- + Échoue d'abord, ce qui est la seule preuve peu coûteuse que le test peut échouer
- + Nomme une règle, donc il survit au refactoring : les internes peuvent bouger sans que l'assertion bouge
- + Applique une pression de design tant que le design est encore peu coûteux à changer
- + Se lit comme de la documentation par la personne suivante, y compris par l'agent suivant
La vérification négative
- − S'exécute après coup, sur du code et des tests qui existent déjà
- − Un mutant tué n'apporte aucune information ; seuls les survivants portent un signal
- − Un survivant est la preuve d'un angle mort, jamais d'un défaut du produit
- − Ne dit rien des exigences manquantes, seulement de l'insensibilité aux changements tentés
- − Produit un rapport et une liste de tâches, pas un artefact que quelqu'un conserve
Le même mot, deux instruments différents
| Le test positif | La vérification négative | |
|---|---|---|
| Ce qu'il affirme | Le système doit se comporter ainsi. | Votre suite n'a pas remarqué ce changement. |
| Ce que le vert prouve | Tout comportement spécifié jusqu'ici est implémenté. | La suite est sensible à ce jeu d'opérateurs. Rien sur la justesse. |
| Ce qu'il laisse derrière lui | Des tests, et un design façonné par le fait de les écrire. | Un rapport. Il faut en extraire la valeur avant qu'elle expire. |
| Quand il tourne | En continu, à la minute, pendant que le code s'écrit. | En CI : en incrémental sur les fichiers modifiés, ou en balayage nocturne. |
| Modèle de coût | Payé régulièrement et amorti dans le développement. | Mutants multipliés par la durée des tests concernés. Croît avec les deux. |
| Effet sur le design | Pression de testabilité. Un design maladroit fait mal tout de suite. | Aucun. Il note ce qui existe déjà. |
| Comment l'échec arrive | Rouge, exprès, écrit par vous, une étape à la fois. | Un survivant que personne n'a écrit et qu'il faut maintenant interpréter. |
Le même survivant, deux réponses
C'est ici que la polarité cesse d'être de la philosophie. Un rapport de mutation vous donne un emplacement ; ce que vous écrivez ensuite décide si la suite devient plus forte ou seulement plus rigide.
Le code, et le mutant qui survit
jsRetirer .trim() est une mutation d'appel de méthode classique. Elle survit dès qu'aucune fixture ne porte d'espaces autour, c'est-à-dire dans la plupart des cas.
// importer.js
const normalise = (value) => value.trim().toLowerCase();
export function importRows(rows) {
return rows.map((row) => ({ email: normalise(row.email) }));
}
// Surviving mutant: normalise() with .trim() removed.
// The suite passes either way, so the report flags it. Tuer le mutant, et coupler la suite
jsCe test tue le survivant. Il gèle aussi l'implémentation actuelle : normalise doit rester accessible et être appelée exactement ce nombre de fois. Renommez-la, inlinez-la ou déplacez-la derrière une frontière, et le test casse alors que le comportement n'a pas changé.
it('calls normalise once per row', () => {
const spy = vi.spyOn(internals, 'normalise');
importRows([{ email: ' Ada@Example.COM ' }]);
expect(spy).toHaveBeenCalledTimes(1);
});
// Green. Mutant dead. Score up.
// Nothing here states what an imported email address should look like. Répondre à la question posée par le survivant
jsMême mutant, même mise à mort, mais l'assertion est une règle qu'un responsable produit pourrait lire. Elle passe par la fonction publique, donc les internes restent libres de bouger, et elle s'explique d'elle-même dans six mois au moment de l'échec.
it('trims and lower-cases every imported email address', () => {
expect(importRows([{ email: ' Ada@Example.COM ' }]))
.toEqual([{ email: 'ada@example.com' }]);
});
// Same mutant dead, but the suite gained a specification
// instead of a snapshot of today's call graph. Garder l'audit assez peu coûteux pour continuer à le lancer
jsonL'analyse de couverture par test est le plus grand levier de performance : elle n'exécute que les tests qui atteignent réellement chaque mutant. Le mode incrémental garde un run de pull request en quelques minutes. Le seuil break est un plancher contre la régression, pas une cible à poursuivre.
{
"testRunner": "vitest",
"coverageAnalysis": "perTest",
"incremental": true,
"mutate": ["src/**/*.js", "!src/**/*.test.js"],
"thresholds": { "high": 80, "low": 60, "break": 60 }
} Quand le négatif devient toxique
Courir après le score
Dès que le score de mutation devient une cible, on écrit des tests pour tuer des mutants au lieu d'énoncer des exigences. Ils passent la revue parce qu'ils sont verts, et ce sont eux qui cassent au refactoring suivant alors que rien du comportement n'a changé.
Se battre contre les mutants équivalents
Certaines mutations changent le code sans changer le comportement observable. Aucun test honnête ne peut les tuer. Documentez-les, excluez-les, passez à autre chose. Le temps passé ici achète un chiffre, pas de la confiance.
Affirmer la mutation, pas la règle
Si vous ne pouvez pas nommer l'exigence qu'un nouveau test protège, vous avez écrit un détecteur de changement. Il signalera comme échec chaque modification future et apprendra à l'équipe à ne plus lire la sortie des tests.
Tout lancer, à chaque fois
Un balayage complet sur une grande base de code est un job nocturne, pas une barrière de pull request. Ciblez les fichiers modifiés dans le run de PR avec l'analyse de couverture par test, et gardez le run exhaustif hors du chemin critique.
Transformer chaque survivant en affirmation positive
Lire le survivant comme une question
- - Quel comportement devrait être vrai pour que ce changement casse quelque chose ?
- - Quelle exigence, si quelqu'un l'avait écrite, échouerait en ce moment ?
- - Qui, en aval, remarquerait si cette mutation partait en production ?
Écrire la réponse, pas la mise à mort
- - Nommer le test d'après la règle, jamais d'après le mutant ni le numéro de ligne
- - Assertir via la surface publique pour que les internes restent libres de changer
- - Si la règle n'est atteignable qu'en exposant les internes, c'est un constat de design, pas de test
Ou supprimer le code
- - Si aucune exigence n'est nommable, rien ne dépend réellement de ce comportement
- - Un survivant dans du code non spécifié est souvent une exigence morte plutôt qu'un test manquant
- - Le supprimer est la mise à mort la moins chère possible, et cela accélère l'audit suivant
L'un écrit la spec, l'autre l'audite
Le test de mutation n'est pas un meilleur TDD, et il ne le remplace pas. Il ne produit ni tests, ni design, ni énoncé d'intention. Ce qu'il produit, c'est une liste d'endroits où votre spécification est moins sensible que vous ne le supposiez, ce qui est réellement utile et réellement différent.
Gardez donc la répartition des rôles explicite. Les tests naissent par le TDD, comme des affirmations positives sur le comportement. Les runs de mutation ont lieu périodiquement, comme un audit de ces affirmations. Et aucun survivant ne va directement dans un test : traduisez-le d'abord en exigence, ou supprimez le code où il vit. Un constat négatif ne devient une valeur durable que lorsque quelqu'un le transforme en affirmation positive sur ce à quoi sert le logiciel.
Articles d'ingénierie liés
La couverture et la complexité ont leur propre métrique, et il existe désormais un workflow d'agents qui confie le durcissement à un rôle dédié.
La métrique CRAP : complexité et couverture en un chiffre
Comment la formule CRAP combine complexité et couverture, ce que chaque score exige, et comment en faire une barrière sans lancer un chantier de nettoyage.
SwarmForge : ce que l'essaim d'agents d'Uncle Bob réussit
Une revue de la décomposition en rôles, de l'isolation par worktree et du protocole de passation de SwarmForge, et de ce qui se transpose à toute équipe.
FAQ
Le test de mutation est-il meilleur que la couverture de code ?
Il répond à une question plus forte pour un prix bien plus élevé. La couverture compte si une ligne a été exécutée ; la mutation demande si quelque chose a réellement été vérifié. Utilisez la couverture comme barrière continue peu coûteuse et la mutation comme audit périodique sur le code qui compte.
Quel score de mutation viser ?
Au-delà d'environ 80 %, on parle généralement d'un bon score, et 60 à 80 % reste exploitable avec de vraies lacunes, mais le chiffre n'a de sens que par module. Une règle plus utile : aucune régression sur les fichiers modifiés, avec un seuil break qui empêche le score de glisser.
Le test de mutation remplace-t-il le TDD ?
Non. Le test de mutation n'écrit rien et ne spécifie rien. Il note la suite que vous avez déjà, et chaque constat exige encore une décision humaine sur l'exigence qu'il implique.
Quels outils utilisent les équipes ?
PIT est le choix établi sur la JVM, Stryker Mutator couvre JavaScript, TypeScript, C# et Scala, et mutmut et cosmic-ray sont les options habituelles en Python. Tous permettent de restreindre un run aux fichiers modifiés, ce qui rend la pratique abordable.
Où placer le test de mutation en CI ?
En incrémental sur les pull requests, limité aux fichiers modifiés avec l'analyse de couverture par test, plus un balayage complet planifié. Remontez les survivants comme points de revue plutôt que comme échecs de build, et ne bloquez que sur la régression du score.