Votre rapport de mutation testing est plein de survivants, et au moins l’un d’eux n’a aucun sens pour vous.
L’outil dit qu’il a inversé un > en >= à la ligne 47, ou remplacé un bloc conditionnel entier par true, ou muté un littéral de chaîne que vous ne saviez même pas testé. Vous avez lu le diff trois fois. Vous ne comprenez toujours pas quel comportement le mutant a cassé, ni quel test le détecterait. Alors vous l’ignorez. Le mutant survit. Votre score reste bas.
C’est la raison la plus courante pour laquelle l’adoption du mutation testing stagne. Pas le runtime. Pas les mutants équivalents. Le moment où un ingénieur fixe un survivant, ne peut pas l’associer à un test manquant, et décide que le mutation testing n’est que du bruit.
Ce n’est pas le cas. Vous avez juste besoin d’un point de départ différent.
Le problème : vous commencez par la mutation, pas par le code
La plupart des développeurs abordent les mutants survivants à l’envers. Ils lisent le diff de mutation, essaient de comprendre quel bug synthétique a été introduit, puis essaient d’imaginer un test qui détecterait ce bug spécifique.
Ça marche pour les cas évidents. Ça échoue pour tout ce qui est subtil.
La mutation peut être à l’intérieur d’une fonction helper à trois appels de profondeur. Elle peut affecter un side effect dont vous ignoriez l’existence. Elle peut être dans du code généré ou un callback de framework. Le diff montre ce qui a changé, mais pas pourquoi les tests existants s’en fichaient. Si vous commencez par décoder la mutation, vous faites du reverse engineering sur du code synthétique. C’est difficile même pour des ingénieurs expérimentés.
La meilleure approche est d’ignorer complètement la mutation et de traiter le survivant comme un signal sur votre code, pas sur le bug synthétique.
Un mutant survivant n’est qu’une ligne que vos tests ne vérifient pas
Chaque mutant survivant pointe vers une ligne de code qui s’est exécutée pendant les tests, mais dont l’output ou les side effects n’ont jamais été assertés.
La mutation aurait pu être n’importe quoi. Le fait qu’elle ait survécu signifie une chose : si cette ligne avait produit le mauvais résultat, vos tests seraient toujours passés. Vous n’avez pas besoin de comprendre la mutation spécifique pour corriger ça. Vous devez comprendre ce que cette ligne est censée faire, et écrire un test qui vérifie si elle l’a fait.
Ce recadrage change le problème : au lieu de faire du reverse engineering sur des diffs synthétiques, vous faites du test design classique.
La méthode : partez de la ligne et remontez, pas de la mutation et avancez
Voici un processus en quatre étapes qui fonctionne sur n’importe quel mutant survivant, peu importe à quel point le diff semble confus.
Étape 1 : trouvez la ligne exacte que la mutation a touchée
Le rapport HTML de votre outil de mutation testing affichera la ligne mutée en ligne avec votre code source. Ouvrez ce fichier et trouvez la ligne originale, pas le diff.
Par exemple, disons que Stryker rapporte un survivant dans cette fonction :
// pricing.js
function calculateDiscount(price, customer) {
if (customer.loyaltyYears > 5) {
return price * 0.85;
}
if (customer.isStudent) {
return price * 0.90;
}
return price;
}
module.exports = { calculateDiscount };
La mutation a changé > en >= dans la première condition. C’est le détail qui pourrait vous confondre. Oubliez-le pour l’instant. La ligne est if (customer.loyaltyYears > 5).
Étape 2 : demandez-vous ce que cette ligne est censée imposer
Ne pensez pas à la mutation. Pensez à la business rule.
Cette ligne est censée vérifier si un client est fidèle depuis plus de cinq ans. Si c’est vrai, il obtient une remise de 15%. La frontière compte. Un client ayant exactement cinq ans ne devrait pas obtenir cette remise. Un client ayant six ans devrait l’obtenir.
Regardez maintenant les tests existants :
// pricing.test.js
const { calculateDiscount } = require('./pricing');
test('returns full price for new customers', () => {
expect(calculateDiscount(100, { loyaltyYears: 0 })).toBe(100);
});
test('gives loyalty discount to long-term customers', () => {
expect(calculateDiscount(100, { loyaltyYears: 6 })).toBe(85);
});
test('gives student discount to students', () => {
expect(calculateDiscount(100, { isStudent: true })).toBe(90);
});
Les tests couvrent les deux branches de la première instruction if. Mais ils ne testent pas la frontière. loyaltyYears: 5 n’apparaît jamais. C’est pourquoi le mutant >= a survécu. L’outil a trouvé un trou que vous ne saviez pas être là.
Étape 3 : écrivez un test qui échouerait si cette ligne était fausse
Vous n’avez pas besoin d’écrire un test qui tue cette mutation spécifique. Vous devez écrire un test qui échouerait si la business rule était violée.
// pricing.test.js
test('does not give loyalty discount at exactly 5 years', () => {
expect(calculateDiscount(100, { loyaltyYears: 5 })).toBe(100);
});
test('gives loyalty discount at 6 years', () => {
expect(calculateDiscount(100, { loyaltyYears: 6 })).toBe(85);
});
Maintenant la frontière est explicite. Si quelqu’un change > en >=, le premier test échoue car un client ayant exactement cinq ans recevrait incorrectement une remise. Le mutant meurt. Vous n’avez jamais eu à comprendre ce que >= signifiait dans le diff synthétique.
Étape 4 : relancez le mutation test et confirmez
Lancez votre outil de mutation sur ce seul fichier, ou lancez la suite complète si vous êtes patient. Le survivant devrait avoir disparu. Si ce n’est pas le cas, votre test n’exerce pas réellement la ligne que vous pensez. Vérifiez les données de coverage pour en être sûr.
Quand la ligne elle-même est confuse
Parfois la ligne mutée est à l’intérieur d’un wrapper de librairie, d’un hook de framework, ou de code généré que vous n’avez pas écrit. Dans ces cas, le survivant vous dit quelque chose de différent : vous avez du code dans votre codebase qu’aucun humain ne comprend assez bien pour le tester.
Ce n’est pas un problème de mutation testing. C’est un problème de code quality que le mutation testing a fait remonter.
Vos options sont les mêmes que sans mutation testing : refactorer le code jusqu’à ce qu’il ait une surface testable, ou accepter que ce code n’est pas testé et le marquer comme tel. Certains outils vous permettent d’ignorer des lignes ou fichiers spécifiques. Utilisez ce pouvoir avec parcimonie. Chaque mutant ignoré est un bug qui pourrait partir en production.
Le cas difficile : les mutations qui changent des side effects
Les vérifications de frontière sont faciles. Les side effects sont plus difficiles.
Considérez cette fonction :
// logger.js
function logError(error, context) {
const timestamp = new Date().toISOString();
console.error(`[${timestamp}] ${context}: ${error.message}`);
metrics.increment('error.count');
}
module.exports = { logError };
Un outil de mutation testing pourrait remplacer l’appel console.error entier par rien, ou remplacer le template de chaîne par une chaîne vide. Ces mutants survivent si vos tests ne vérifient pas l’output du log.
La plupart des équipes ne testent pas le logging. C’est généralement acceptable. Mais si vos logs sont consommés par un système d’alerting, ou si metrics.increment alimente un dashboard qui page l’on-call, alors ignorer ces tests est risqué.
L’approche est la même. N’étudiez pas la mutation. Demandez-vous quel comportement cette ligne est censée produire. Si la réponse est “une entrée de log structurée avec un timestamp”, écrivez un test qui assert sur l’output du log :
// logger.test.js
const { logError } = require('./logger');
test('logs error with timestamp and context', () => {
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
logError(new Error('db timeout'), 'payment-service');
expect(spy).toHaveBeenCalledWith(
expect.stringMatching(/\d{4}-\d{2}-\d{2}T.*payment-service.*db timeout/)
);
spy.mockRestore();
});
Le mutant qui supprime l’appel console.error échoue maintenant car le spy ne détecte aucun appel. Le mutant qui corrompt le template de chaîne échoue car la regex ne correspond pas. Vous n’aviez besoin de comprendre aucune des deux mutations.
Pourquoi cette approche scale mieux que l’étude des mutations
Il y a un nombre infini de mutations possibles. Il y a une quantité finie de comportements que votre code est censé avoir.
Si vous essayez d’écrire des tests qui tuent des mutations spécifiques, vous jouez au whack-a-mole avec des bugs synthétiques. Si vous écrivez des tests qui vérifient le comportement réel de votre code, les mutants meurent comme side effect. La deuxième approche est durable. La première ne l’est pas.
C’est aussi comme ça que vous évitez d’écrire des tests trop couplés à l’outil de mutation. Un test qui assert que > est utilisé à la ligne 47 est fragile. Un test qui assert qu’un client de cinq ans paie le plein tarif est correct.
La limitation : les mutants équivalents existent toujours
Cette méthode n’aidera pas avec les mutants équivalents, car les mutants équivalents ne représentent pas des tests manquants. Ils représentent des transformations qui produisent un comportement identique.
Si une mutation change a + b en b + a dans une opération commutative, aucun test ne peut le tuer. Il n’y a pas de comportement manquant à assert. Ce sont des faux positifs, et chaque outil de mutation testing en a. Apprenez à les reconnaître, ignorez-les, et passez à la suite. Ne laissez pas un noise floor de 2% de mutants équivalents vous convaincre que les 98% autres sont aussi du bruit.
Commencez par les trois pires fichiers
Si votre mutation score est bas et que vous avez des dizaines de survivants, n’essayez pas de tous les comprendre. Prenez les trois fichiers avec le plus de survivants. Pour chaque fichier, prenez les trois lignes les plus suspectes. Appliquez cette méthode à chacune.
En une heure, vous aurez écrit neuf tests qui rendent votre codebase plus correcte. Relancez le mutation testing. Votre score bondira. Plus important encore, vous comprendrez votre propre code mieux qu’auparavant.
Les mutants ne vous demandent pas de les comprendre. Ils vous demandent de comprendre votre code.
FAQ
Dois-je comprendre l’opérateur de mutation pour écrire le test ? Non. L’opérateur de mutation est une distraction. Concentrez-vous sur ce que la ligne originale est censée faire. Écrivez un test pour ce comportement. Le mutant mourra comme side effect.
Et si la ligne mutée est à l’intérieur d’une fonction privée que je ne peux pas tester directement ? C’est un signal de design. Si une fonction a un comportement qui vaut la peine d’être testé, elle devrait être testable. Exposez-la pour le testing, ou testez-la via l’API publique qui l’appelle. Si le test de l’API publique ne peut pas atteindre le comportement, ce comportement pourrait être du dead code.
Devrais-je tuer chaque mutant survivant ? Non. Certains mutants touchent au logging, aux metrics, ou à d’autres code d’observabilité où le coût du testing dépasse la valeur. Définissez un threshold qui a du sens pour votre codebase, et concentrez votre énergie sur les mutants dans la business logic.
Et si mon test tue le mutant mais a toujours l’air faux ? Faites confiance à ce sentiment. Un test qui arrive à tuer un mutant mais n’assert pas clairement une business rule est de la technical debt. Réécrivez-le pour exprimer le comportement attendu en langage de domaine, pas en langage de test.