Aller au contenu

Le test par mutation expliqué

Spec: ISO/IEC/IEEE 29119-4 Spec: PHPUnit Evidence: Test-backed

La couverture de lignes te dit qu’une ligne s’est exécutée pendant la suite de tests. Elle ne te dit pas qu’un test aurait échoué si cette ligne était fausse. Le test par mutation comble cet écart en cassant délibérément le code et en vérifiant si les tests le détectent. Cette page explique ce que signifie un score de mutation et comment NextPDF l’utilise comme diagnostic, et non comme trophée.

La couverture est l’une des métriques de test auxquelles on fait le plus confiance, et aussi l’une des plus trompeuses. Un test qui appelle une méthode sans rien affirmer en exécute chaque ligne : couverture parfaite, détection nulle. La littérature normative est explicite : l’ordonnancement entre critères de couverture ne donne aucune indication sur leur capacité à révéler des défauts. C’est cette capacité qu’elle nomme efficacité des tests (ISO/IEC/IEEE 29119-4, §C.2.4). Un pourcentage de couverture et une garantie de détection de défauts sont deux affirmations différentes.

Pour un moteur PDF, ce n’est pas une question théorique. Une vérification de plage d’octets de signature, un décalage de référence croisée, une branche d’encodage : les tests peuvent entièrement « couvrir » tout cela sans jamais affirmer la valeur qui compte. Une suite au vert qui repose sur des tests faibles est pire qu’un manque assumé, car elle dissuade activement quiconque d’aller y regarder.

  • Le test par mutation applique des milliers de petites modifications délibérées (mutants) au code source — transformer un < en <=, un + en -, une valeur de return — puis réexécute les tests sur chacune d’elles.
  • Si un test échoue sur un mutant, le mutant est tué : un test affirmait bien ce comportement. Si tous les tests passent encore, le mutant a survécu : le comportement a été exécuté mais jamais vérifié.
  • L’indicateur de score de mutation (MSI) correspond, grosso modo, au nombre de mutants tués rapporté au total des mutants non équivalents. Il mesure si tes tests détectent les changements, pas s’ils exécutent le code.
  • Certains mutants sont équivalents — ils ne modifient pas le comportement observable, donc aucun test ne peut les tuer. Les compter comme des échecs est malhonnête. NextPDF les prouve et les consigne dans un registre au lieu de les écarter de façon informelle.
  • NextPDF utilise le MSI pour repérer et renforcer les tests faibles. C’est un point de contrôle diagnostique dans l’intégration continue, pas un argument marketing.

Le test par mutation s’exécute sur le moteur avec le mutateur Infection. Il est configuré sur l’arborescence du code source de production, avec les familles de mutateurs arithmétiques, booléens, de frontière conditionnelle, d’égalité, de valeur de retour et de suppression activées — précisément les opérateurs qui révèlent la logique « exécutée mais non affirmée ». Le flux est mécanique :

  1. Start green The suite must pass before mutation begins.
  2. Mutate Apply one small, deliberate change to the source.
  3. Re-run Run the tests that cover the mutated line.
  4. Killed A test failed — the behaviour is genuinely asserted.
  5. Escaped All tests still pass — a weak spot to strengthen.
  6. Equivalent No test can kill it because behaviour is unchanged — proven and ledgered, not scored as a miss.
La boucle de test par mutation qu'exécute NextPDF : partir de tests au vert, générer un mutant, réexécuter les tests qui couvrent le code, puis classer le mutant comme tué (un test l'a attrapé), survivant (un écart « couvert mais sans affirmation » à corriger) ou prouvé équivalent (aucun test ne peut le tuer ; consigné au registre, pas comptabilisé en défaveur du score).

Deux choix de conception rendent ce chiffre digne de confiance. D’abord, le score est intégré comme point de contrôle. L’intégration continue impose un MSI minimal (et un MSI couvert minimal) et exécute une variante limitée au diff sur les lignes modifiées. Ainsi, une modification qui ajoute du code mais pas de véritables affirmations est attrapée lors de la revue, et non découverte plus tard. Ensuite, NextPDF n’écarte pas en silence les mutants gênants. Les mutants réellement sémantiquement équivalents — par exemple !== face à != lorsque le typage strict garantit que les deux opérandes partagent un type — sont inscrits dans un registre de mutation, avec un test de preuve d’équivalence explicite. Par conséquent, le décompte des survivants reflète des écarts réels, pas un artefact comptable. Ce sont PHPStan niveau 10, strict_types et les propriétés typées qui rendent ces preuves d’équivalence solides.

Evidence: Test-backed Le test par mutation est configuré dans le moteur sur les répertoires du code source de production avec les familles de mutateurs qui révèlent le comportement activées. Il est imposé comme point de contrôle d’intégration continue avec un MSI minimal et une variante limitée au diff. C’est une vérification de build, pas une réflexion ajoutée après coup.

Evidence: Test-backed Le problème des mutants équivalents est traité honnêtement. Les mutants sémantiquement équivalents sont classés dans un registre de mutation et étayés par des tests de preuve d’équivalence dédiés, chaque preuve reposant sur PHPStan niveau 10 plus le typage strict. Le décompte des survivants représente donc un comportement réel non détecté, pas un bruit impossible à tuer qui rend le score artificiellement plus mauvais.

Evidence: Standard-backed La mutation est une technique reconnue, pas une invention de NextPDF. Spec: ISO/IEC/IEEE 29119-4, §B.2.4 décrit l’application de mutations génériques à des éléments d’une spécification pour en dériver des mutations spécifiques pour les tests. La technique n’est nécessaire que parce que la même norme énonce que l’ordonnancement par subsomption des critères de couverture ne les ordonne pas selon leur capacité à révéler des défauts (ISO/IEC/IEEE 29119-4, §C.2.4).

Evidence: Standard-backed La couverture elle-même est bien définie et limitée. Spec: PHPUnit distingue la couverture de lignes, de branches et de chemins. La couverture de lignes enregistre seulement qu’une ligne exécutable s’est exécutée. Connaître la définition, c’est précisément ce qui rend son insuffisance évidente.

L’essentiel n’est pas la commande — c’est ce qu’un mutant survivant te révèle :

<?php
declare(strict_types=1);
final class ByteRange
{
// Suppose the production guard is:
// if ($offset < 0) { throw new InvalidByteRange(); }
public function assertNonNegative(int $offset): void
{
if ($offset < 0) {
throw new InvalidByteRange('offset must be >= 0');
}
}
}
// A test that EXECUTES this line but does not assert the boundary:
// $byteRange->assertNonNegative(5); // no exception expected, none asserted
// gives 100% line coverage of assertNonNegative().
//
// Mutation flips `< 0` to `<= 0`. Behaviour now differs ONLY at $offset === 0.
// If no test passes 0 and asserts what happens, every test still passes:
// the mutant ESCAPED. Coverage said "tested"; mutation said "the boundary
// is unasserted". The fix is a test that pins offset === 0, not a higher
// target.
//
// composer mutation:diff → mutate only changed lines, enforce min MSI
// composer mutation:full → full-tree mutation gate

Ce mutant survivant résume toute la valeur de l’approche. Il a localisé une affirmation manquante, précise et bien réelle, qu’un rapport de couverture jugeait entièrement testée.

L’idée fausse principale est que le score de mutation serait une note à maximiser. Un MSI très élevé obtenu en écrivant des tests pour tuer des mutants est aussi creux qu’une couverture élevée obtenue en appelant des méthodes sans rien affirmer. Dans ce cas, la métrique a été détournée et ne mesure plus la détection. NextPDF utilise le MSI pour repérer les tests faibles. Le livrable est une meilleure affirmation ; s’en vanter n’est explicitement pas le but.

La deuxième idée fausse est que tout mutant survivant serait un défaut dans les tests. Certains mutants sont réellement équivalents et ne peuvent pas être tués par un test, car ils ne modifient pas le comportement observable. Les traiter comme des échecs produit un score malhonnête, artificiellement bas, et habitue l’équipe à ignorer le rapport. La réponse de NextPDF est de prouver l’équivalence explicitement et de la consigner au registre, pas de la supprimer en silence ni de prétendre que le chiffre est pire qu’il ne l’est.

Le test par mutation mesure si les tests détectent les changements injectés. Il ne prouve pas que le code est correct. Il ne mesure ni la performance ni la conformité. Il ne peut pas tuer un mutant véritablement équivalent. Le score de mutation actuel, le seuil de MSI minimal en vigueur, le nombre d’équivalents consignés et tout chiffre de couverture sont des signaux de qualité vivants, générés à partir des artefacts d’intégration continue et publiés avec le build. Ils sont délibérément absents ici, car un chiffre figé dans la prose devient obsolète et finit par mentir. Le seul fait stable que cette page énonce est PHPStan niveau 10, et c’est une propriété de configuration qui étaye les preuves d’équivalence, pas une mesure.

La sélection des mutateurs, les seuils et la politique du registre relèvent de la configuration de mutation du moteur et peuvent évoluer. Cette configuration fait foi si elle diverge un jour de cette page. Aucune affirmation n’est faite ici sur l’efficacité des tests d’une quelconque autre bibliothèque.

  • Mutant — un seul petit changement délibéré apporté au code source, utilisé pour vérifier si la suite de tests remarquerait ce changement.
  • Mutant tué — un mutant qui a fait échouer au moins un test ; le comportement était réellement affirmé.
  • Mutant survivant — un mutant qui n’a fait échouer aucun test. Le comportement a été exécuté mais jamais affirmé — un point faible à corriger.
  • Mutant équivalent — un mutant qui ne modifie pas le comportement observable, donc aucun test ne peut le tuer. NextPDF les prouve et les consigne au registre.
  • MSI (indicateur de score de mutation) — grosso modo, les mutants tués divisés par le total des mutants non équivalents ; une mesure de détection, pas d’exécution.
  • Couverture de lignes — une métrique qui enregistre seulement qu’une ligne exécutable s’est exécutée pendant la suite ; définie par PHPUnit, et insuffisante à elle seule.