Conception des tests

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 décide de ce qu'il faut faire du résultat — et de la confiance à accorder à un test écrit par un agent.

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.

Comment le mettre en place pour de vrai

Le test de mutation a la réputation d'être inabordable, ce qui est presque toujours un problème de périmètre et non d'outillage. Cet ordre d'opérations le garde assez peu coûteux pour survivre au contact d'un planning de livraison.

1

Mettre d'abord la couverture en place

  • - Le test de mutation sur du code non couvert ne fait que signaler l'évidence, à grands frais
  • - Gardez la couverture comme barrière continue peu coûteuse et la mutation pour du code déjà couvert
  • - Choisissez un module qui compte — paiements, permissions, tarification — plutôt que tout le dépôt
2

Cadrer chaque run

  • - L'analyse de couverture par test est le plus grand levier : elle n'exécute que les tests qui atteignent chaque mutant
  • - Sur les pull requests, ne mutez que les fichiers modifiés, en mode incrémental ; c'est un run de quelques minutes
  • - Gardez le balayage exhaustif planifié, hors du chemin critique, là où une longue durée ne coûte rien à personne
3

Trier les survivants en trois paniers

  • - Une exigence manquante, qui devient un nouveau test positif nommé d'après la règle
  • - Un mutant équivalent qui ne change rien d'observable, documenté et exclu une fois pour toutes
  • - Du code qu'aucune exigence ne réclame, qui est supprimé — la mise à mort la moins chère possible
4

Barrer la régression, pas un chiffre

  • - Fixez un seuil break qui empêche le score de glisser, et arrêtez-vous là
  • - Remontez les survivants comme points de revue plutôt que comme échecs de build, pour que personne n'apprenne à ignorer un build rouge
  • - Suivez la durée d'exécution comme une métrique de premier rang ; une barrière désactivée pour lenteur ne protège rien

Ce que cela change quand un LLM écrit le code

Les agents n'ont pas créé ce problème, mais ils l'ont industrialisé. La question de polarité cesse d'être philosophique dès l'instant où vos tests et votre implémentation ont le même auteur et que cet auteur est un modèle.

Demandez une fonctionnalité avec tests à un agent : vous obtenez les deux, générés depuis une seule lecture de l'exigence, dans un seul contexte. Si cette lecture était fausse, les tests encodent le même malentendu et passent — et la couverture paraît excellente, parce que chaque ligne écrite par le modèle est exercée par un test que le modèle a écrit pour l'exercer. C'est l'échec que la revue ordinaire attrape le moins bien, car les tests ont l'air raisonnables dans le diff. Le test de mutation est la vérification automatique la moins chère qui l'attrape, pour une raison : il ne demande pas si des tests existent, il demande s'ils réagissent. Un test qui reflète l'implémentation ne remarquera pas que l'implémentation change.

Le mettre dans la boucle, pas dans un rapport

Un mutant survivant est un retour inhabituellement bon pour un agent : exécutable, précis et impossible à balayer par un raisonnement. Un paragraphe de conseil de revue est acquiescé puis ignoré ; un changement nommé qui est passé inaperçu est corrigé. C'est exactement la forme du rôle hardener de SwarmForge, qui lance l'outil de mutation fichier par fichier et n'a pas le droit d'avancer avant d'avoir traité les survivants.

Déplacer le critère dans l'instruction d'écriture

La meilleure phrase de ce projet est dans son prompt de rôle coder : écrire des tests qui échoueraient pour une implémentation plausible mais fausse. C'est un critère de mutation intégré à l'étape de génération, bien moins coûteux que de découvrir la même chose dans un audit une heure plus tard. Mettez cette phrase dans vos propres instructions d'agent.

Séparer l'auteur du durcisseur

Le même modèle, dans le même contexte, expliquera pourquoi un survivant est acceptable, parce que le raisonnement qui a produit le trou est encore devant lui. Faites tourner la passe de mutation comme une étape séparée, avec un contexte séparé et sans accès à la justification d'origine — seulement le diff et la sortie de l'outil.

Attendez-vous à la chasse au score à vitesse machine

Chargé d'augmenter le score de mutation, un agent écrira des détecteurs de changement tueurs de mutants plus vite que quiconque ne peut les relire. La loi de Goodhart est pire avec des agents parce que le volume est gratuit. Laissez les agents signaler les survivants et proposer des tests au niveau des exigences ; gardez un humain, ou un rôle de spécification, pour décider ce qu'est réellement l'exigence.

Évaluer du code qu'un agent vient de produire

Couverture et score de mutation forment ensemble une grille utilisable pour du travail écrit par IA. Lisez-les en paire — l'information intéressante est dans le désaccord.

Signal Ce que cela signifie en général Que faire
Couverture haute, score de mutation bas Les tests ont été écrits pour exécuter le code, pas pour le vérifier. La forme classique des tests générés par un modèle. Gardez l'implémentation en revue, jetez ou réécrivez les tests au niveau des exigences.
Les deux hauts, sur un petit diff Du vrai bon travail, ou des détecteurs de changement bien déguisés. Vérifiez la présence d'espions, de snapshots et d'assertions de nombre d'appels. Si les assertions nomment des règles, c'est bon.
Survivants groupés sur les chemins d'erreur Le modèle a correctement implémenté le chemin heureux et raconté le reste. Spécifiez explicitement le comportement en cas d'échec, puis demandez des tests sur cette spécification.
Survivants dans du code qu'aucune exigence ne nomme Généralité spéculative — options, drapeaux et branches défensives que personne n'a demandés. Supprimez le code. C'est de la complexité non testée sans propriétaire.
Le score a monté, les tests touchent maintenant les internes L'agent a optimisé la métrique en soudant la suite à l'implémentation du jour. Refusez. Le prochain refactoring cassera ces tests alors que le comportement est inchangé.
La liste d'exclusions de mutants équivalents grossit vite L'agent discute avec l'outil au lieu d'améliorer la suite. Lisez le diff vous-même. Les exclusions sont une décision humaine, prise une fois, avec un motif consigné.

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

js

Retirer .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 — y compris les fixtures qu'un modèle a inventées pour son propre code.

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

js

Ce 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é. Un agent qui optimise le score produit cette forme par défaut.

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

js

Mê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

json

L'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 }
}

La barrière pour une branche écrite par un agent

sh

Deux questions indépendantes, posées par une étape qui n'a pas écrit le code : ce code a-t-il une forme qui permet de le changer, et les tests qui le protègent réagissent-ils quand il change ? Les deux réponses vont sur la pull request, et aucune n'est négociable par l'auteur.

CHANGED=$(git diff --name-only origin/main... -- 'src/**/*.js')

# structural: complexity against coverage on touched methods
crap-report --changed-only --threshold 30 $CHANGED || exit 1

# behavioural: do the new tests notice anything?
stryker run --incremental --mutate "$CHANGED"

# survivors are review items with a named requirement attached,
# never a licence to write a test that pins the call graph.

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.

Laisser l'auteur durcir son propre travail

Un modèle qui a écrit le code trouvera pourquoi chaque survivant est acceptable, parce que son propre raisonnement lui paraît encore solide. Le durcissement doit se faire dans une étape qui ne voit que le diff et la sortie de l'outil.

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.

Cette répartition compte plus aujourd'hui que lorsque les deux tâches appartenaient au même ingénieur. Les tests doivent toujours naître par le TDD, comme des affirmations positives sur le comportement — y compris quand un agent les écrit, raison pour laquelle l'instruction d'écrire des tests qui échoueraient pour une implémentation plausible mais fausse appartient à vos prompts. Les runs de mutation auditent ensuite ces affirmations depuis un contexte séparé avec lequel on ne discute pas. 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.

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 — et comme vérification permanente sur tout ce qu'un agent a écrit.

En quoi le test de mutation aide-t-il pour du code généré par IA ?

Il attrape l'échec précis que la revue manque : des tests générés depuis le même malentendu que l'implémentation, qui passent et produisent une excellente couverture tout en ne vérifiant rien. Le test de mutation ne se soucie pas de l'existence des tests, seulement de leur réaction au changement du code, donc une suite qui reflète l'implémentation est exposée immédiatement.

Les agents doivent-ils lancer eux-mêmes les tests de mutation ?

Oui, dans une étape séparée de celle qui a écrit le code, et avec les survivants traités comme des questions plutôt qu'un score à maximiser. Laissez l'agent signaler les survivants et proposer des tests au niveau des exigences ; gardez la décision sur ce qu'est l'exigence chez un humain ou un rôle de spécification, sinon vous obtiendrez des détecteurs de changement à vitesse machine.

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.

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 sur une pull request.

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.

© 2026 Ryware Solutions Ltd. · N° d’entreprise 516681764