Vos tests passent. Votre rapport de couverture indique 87 %. Mais votre mutation score est de 40 %, et la moitié de vos mutants sont encore vivants.
Ce 40 % ne signifie pas que votre code est cassé. Cela signifie que vos tests le sont. La couverture mesure quelles lignes ont été exécutées pendant une exécution de tests. Le mutation testing mesure si vos tests remarqueraient si ces lignes commençaient à faire la mauvaise chose. Un mutation score de 40 % signifie que 60 % des bugs qui auraient pu être introduits dans votre code seraient passés droit à travers la CI.
Ce qu’est réellement un mutant survivant
Un mutant survivant est un petit bug artificiel que vos tests n’ont pas réussi à attraper.
Les outils de mutation testing fonctionnent en prenant votre code source et en appliquant un ensemble de transformations prédéfinies, une à la fois. Ils peuvent inverser un > en >=, changer un + en -, ou remplacer une condition booléenne par true. Chaque version transformée de votre code est un mutant. L’outil exécute votre suite de tests contre chaque mutant. Si un test échoue, le mutant est « tué ». Si tous les tests passent, le mutant « survit ».
Un mutant survivant signifie une de deux choses. Soit vos tests ne vérifient pas réellement le comportement que le mutant a cassé, soit le mutant est « équivalent » (la transformation produit un code sémantiquement identique, ce qui est un problème difficile connu en mutation testing).
La plupart des survivants ne sont pas équivalents. La plupart sont des bugs morts-vivants.
Un exemple concret : le validateur de mot de passe
Voici une fonction qui vérifie si un mot de passe respecte les exigences de la politique :
// password.js
function isValidPassword(password) {
if (password.length < 8) {
return false;
}
if (!/[A-Z]/.test(password)) {
return false;
}
if (!/[0-9]/.test(password)) {
return false;
}
return true;
}
module.exports = { isValidPassword };
Et voici une suite de tests qui vous donne 100 % de couverture de lignes :
// password.test.js
const { isValidPassword } = require('./password');
test('accepts a valid password', () => {
expect(isValidPassword('Hello1')).toBe(true);
});
test('rejects a short password', () => {
expect(isValidPassword('Hi1')).toBe(false);
});
test('rejects a password without uppercase', () => {
expect(isValidPassword('hello1')).toBe(false);
});
test('rejects a password without a digit', () => {
expect(isValidPassword('Hellooo')).toBe(false);
});
Attendez. isValidPassword('Hello1') retourne true, mais 'Hello1' ne fait que six caractères. La première vérification devrait le rejeter. Le test est faux, mais il passe parce que le test lui-même affirme le mauvais comportement.
Un outil de mutation testing comme Stryker l’aurait attrapé. L’une de ses mutations inverserait < en <= dans la vérification de longueur. Ce mutant survivrait parce que les tests existants ne vérifient pas réellement la limite à 8 caractères. Une autre mutation pourrait supprimer tout le premier bloc if. Ce mutant survivrait aussi, parce que les tests n’incluent pas de mot de passe de huit caractères sans lettre majuscule ou chiffre. La limite supérieure de longueur n’est jamais testée en combinaison avec les autres règles.
Voici une suite de tests qui tue réellement ces mutants :
// password.test.js
const { isValidPassword } = require('./password');
test('rejects password shorter than 8 chars', () => {
expect(isValidPassword('Hello1')).toBe(false);
});
test('accepts password exactly 8 chars with uppercase and digit', () => {
expect(isValidPassword('Hello1!@')).toBe(true);
});
test('rejects password without uppercase', () => {
expect(isValidPassword('hello1!@')).toBe(false);
});
test('rejects password without digit', () => {
expect(isValidPassword('Helloooo')).toBe(false);
});
test('rejects password missing both uppercase and digit', () => {
expect(isValidPassword('helloooo')).toBe(false);
});
Maintenant la limite à 8 est explicitement testée. Le mutant <= échoue parce que 'Hello1!@' (8 caractères) doit être accepté. Le mutant de suppression échoue parce que 'helloooo' passerait à travers sans la vérification de longueur.
Comment le mutation testing fonctionne réellement sous le capot
Le mutation testing est coûteux en calcul parce qu’il exécute votre suite de tests complète une fois par mutant.
Si votre base de code a 10 000 lignes et que votre outil de mutation génère 3 000 mutants, cela fait 3 000 exécutions de suite de tests. Les premières implémentations académiques étaient essentiellement inutilisables sur des bases de code réelles pour cette raison. Les outils modernes sont devenus plus intelligents.
Stryker, le framework de mutation testing le plus largement utilisé pour JavaScript et TypeScript, utilise plusieurs optimisations :
-
Mutant scoping : Stryker n’exécute que le sous-ensemble de tests qui pourraient atteindre la ligne mutée, basé sur les données de couverture d’une exécution sèche initiale.
-
Exécution parallèle : Les mutants sont évalués sur plusieurs processus workers.
-
Mode incrémental : Stryker met en cache les résultats et ne réévalue que les mutants pour le code qui a changé depuis la dernière exécution.
-
Checkers : Pour les langages compilés, Stryker peut vérifier les mutants au niveau de l’AST sans recompiler l’ensemble du projet.
Même avec ces optimisations, une exécution complète de mutation testing sur une grande base de code peut encore prendre 10 à 30 minutes. C’est pourquoi la plupart des équipes exécutent le mutation testing en CI sur les pull requests ou les builds nocturnes, pas à chaque sauvegarde.
Les compromis dont personne ne parle
Le mutation testing n’est pas gratuit, et ce n’est pas toujours le bon outil.
Le problème des mutants équivalents est la plus grande limitation théorique. Certaines mutations ne changent pas le comportement observable. Considérez :
const timeout = 1000 * 60;
Une mutation qui change cela en 1000 * 61 est sémantiquement différente. Mais une mutation qui le change en 60 * 1000 est équivalente. Aucun test ne peut la tuer parce que la valeur est identique. Distinguer les mutants équivalents des vrais survivants est indécidable dans le cas général. Les outils modernes utilisent des heuristiques pour sauter les cas évidents, mais vous en verrez encore quelques-uns.
La performance est réelle. Sur un projet TypeScript de taille moyenne, Stryker pourrait générer 2 000 mutants et prendre 15 minutes pour les évaluer. Cela fait 15 minutes de temps CI à chaque exécution si vous l’activez pour les pull requests. Les équipes commencent généralement avec un seuil (par exemple, échouer le build si le mutation score tombe en dessous de 60 %) et exécutent une analyse complète chaque nuit.
La fausse confiance est un double tranchant. Un mutation score de 100 % ne signifie pas que votre code n’a pas de bugs. Cela signifie qu’aucun bug correspondant aux opérateurs de mutation de l’outil n’aurait pu passer à travers. Le mutation testing ne peut pas inventer des bugs qu’il ne sait pas comment créer. Il n’attrapera pas les erreurs logiques dans vos exigences, les conditions de course qu’il ne peut pas simuler, ou les échecs d’intégration au-delà des limites de service.
Comment vraiment commencer à utiliser le mutation testing
Si vous écrivez du JavaScript ou du TypeScript, Stryker est le point de départ.
Installez-le :
npm install --save-dev @stryker-mutator/core @stryker-mutator/jest-runner
Créez stryker.config.mjs :
// @ts-check
/** @type {import('@stryker-mutator/api/core').PartialStrykerOptions} */
const config = {
packageManager: 'npm',
reporters: ['html', 'clear-text', 'progress'],
testRunner: 'jest',
coverageAnalysis: 'perTest',
mutate: ['src/**/*.js'],
threshold: {
break: 60,
},
};
export default config;
Exécutez-le :
npx stryker run
Commencez par regarder le rapport HTML, pas le score. Le rapport montre chaque mutant survivant en ligne avec votre code source. Lisez les dix premiers survivants. Pour chacun, demandez-vous : un vrai bug à cet endroit causerait-il un problème en production ? Si oui, écrivez un test qui l’attraperait. Si non, considérez si le code est sur-ingénierisé.
Ne courez pas après les 100 %. Sur une base de code mature, 70-80 % est un bon score. En dessous de 50 %, vous avez probablement des tests qui exécutent du code sans rien affirmer de significatif. Au-dessus de 90 %, vous touchez probablement des rendements décroissants et une taxe croissante de mutants équivalents.
Que faire avec vos 40 %
Un mutation score de 40 % est un cadeau. Il vous dit exactement où vos tests sont décoratifs.
Choisissez les trois fichiers avec le plus de mutants survivants. Lisez chaque survivant et demandez-vous quelle assertion manque. Souvent le correctif est simple : vous avez appelé une fonction dans un test mais n’avez jamais vérifié la valeur de retour. Ou vous avez passé des données à travers un parser mais n’avez jamais vérifié la sortie parsée. Ou vous avez testé le happy path trois fois avec différentes entrées mais n’avez jamais testé la branche d’erreur.
Les mutants ne sont pas du bruit. C’est une liste classée des endroits les plus probables où un bug non testé peut se cacher. Commencez par le haut.
FAQ
Quelle est la différence entre la couverture de code et le mutation testing ? La couverture de code mesure quelles lignes ont été exécutées. Le mutation testing mesure si vos tests échoueraient si ces lignes contenaient un bug. 100 % de couverture avec un mutation score de 40 % signifie que vous avez exécuté chaque ligne, mais vos tests ne remarqueraient pas si la plupart d’entre elles étaient fausses.
Le mutation testing peut-il trouver des bugs dans mon code existant ? Non. Le mutation testing évalue vos tests, pas votre code source. Il vous dit où vos tests sont insuffisants. Il ne vous dit pas si votre code est correct, seulement si vos tests attraperaient certaines classes d’erreurs.
Quels langages ont de bons outils de mutation testing ? JavaScript/TypeScript (Stryker), Java (PIT), C# (Stryker.NET), Python (mutmut) et Rust (cargo-mutants) ont tous des outils matures. L’écosystème varie en performance et en opérateurs de mutation supportés.
Le mutation testing devrait-il remplacer la couverture de code ? Non. La couverture est bon marché et rapide. Utilisez-la pour un retour rapide pendant le développement. Utilisez le mutation testing comme une barrière de qualité périodique pour trouver les angles morts que la couverture ne peut pas voir.