
Votre test attrape-t-il quelque chose ? Prouvez-le en cassant le code (tests par mutation)
Quand un agent écrit du code et ses tests, les deux peuvent partager le même angle mort : le test a été écrit en même temps que le code et vérifie ce que le code fait, pas ce qu'il devrait faire. Un résultat vert ne prouve alors rien. Le test par mutation pose une question plus simple : si je casse cette ligne, un test s'en aperçoit-il ?
Un peu d'histoire
Les tests par mutation ont été proposés par Richard Lipton au début des années 1970 et formalisés en 1978 avec DeMillo et Sayward : pour juger une suite de tests, planter de petits bugs volontaires dans le code et voir si les tests s'en aperçoivent. Un mutant qui survit est un bug que vos tests auraient laissé passer. Longtemps jugés trop coûteux, ils ne coûtent presque rien aujourd'hui, et servent plus que jamais quand le code est écrit par des modèles.
1. La méthode
- Lancez les tests sur le vrai code : ils doivent tous être verts. Si l'un est déjà rouge, chaque mutation paraîtra « attrapée » pour une raison étrangère.
- Changez une petite chose :
==en!=,-eqen-ne,<en<=, une condition en « toujours vrai »,|| exiten|| true. - Lancez les tests. Rouge : le défaut est attrapé. Vert : il a survécu, les tests sont aveugles à cet endroit.
- Restaurez exactement le code, et passez à la mutation suivante.
Des outils existent pour la plupart des langages (mutmut pour Python, par exemple). Notre homelab est surtout fait de scripts shell, alors nous en avons écrit un petit qui mute aussi les opérateurs de Bash ; un outil de mutation qui ne mord que sur du Python ne mesure rien dans un parc de scripts shell.
2. Ce qu'il a trouvé chez nous
Une suite de 68 contrôles, tous verts. Cinq mutations plus tard, deux angles morts : deux chemins d'échec jamais éprouvés. Une fois comblés, une mutation paraissait attrapée, mais par un scénario déjà rouge avant la mutation : une fausse détection. Version finale : 81 contrôles, 11 défauts injectés, 11 attrapés.
Une autre fois, un test vérifiait qu'un mot apparaissait dans un fichier. Inverser une seule comparaison dans le code échangeait entièrement deux verdicts, et le test restait vert. La correction : faire exécuter au test la fonction qui décide, au lieu de chercher un mot dans sa sortie.
Pièges que nous avons payés
En Python, restaurer un mutant par une copie de fichier dans la même seconde peut laisser en place le .pyc compilé du mutant : le passage suivant teste encore le mutant. Supprimez __pycache__ après chaque mutant, ou lancez avec PYTHONDONTWRITEBYTECODE=1.
Dans les tests shell, OUT=$(ma_fonction) exécute la fonction dans un sous-shell : un code de sortie qu'elle y range est perdu, et chaque scénario de refus passe au vert. Faites passer la sortie par un fichier et gardez le code de sortie dans le shell courant.
Un mutant qui survit n'est pas toujours un défaut : certains changements ne peuvent être détectés par aucun test (un « mutant équivalent »). Lisez un score bas comme une couverture, pas comme une note, et regardez chaque survivant avant de conclure.
Des données de test écrites de mémoire ne prouvent rien sur le vrai système : nos tests étaient verts sur des données inventées pendant que le code lisait des champs qui n'existaient pas dans la vraie réponse de l'API. Construisez vos données de test à partir de réponses mesurées.
Sources
Utile ? Dites-nous ce qu'il faut mesurer ensuite.