Fausse carte d'identité > Articles > Un test qui ne peut pas échouer : contrôler si votre suite verte contrôle quoi que ce soit

Un test qui ne peut pas échouer : contrôler si votre suite verte contrôle quoi que ce soit

Nous avons dix tests de régression pour le générateur de noms. Tous les dix étaient verts.

Ce n'est pas une preuve. Une suite de tests rapporte deux choses — que le code passe, et que la suite serait capable de le remarquer s'il ne passait pas — et exécuter la suite ne vous dit jamais que la première. La seconde doit être établie par un autre moyen.

Nous l'avons donc établie de la seule manière qui fonctionne vraiment : nous avons cassé le code exprès, une chose à la fois, et contrôlé que le bon test passait au rouge. Onze défauts délibérés. Dix ont été attrapés. Le onzième ne pouvait pas l'être, pour une raison structurelle qui vaut plus que les dix succès réunis.

Et chemin faisant, nous avons découvert que l'un de nos tests avait, à une époque antérieure, été vert sur du code cassé — non par négligence dans le code testé, mais à cause d'une erreur de deux caractères dans la fixture du test lui-même.

La méthode

La mécanique n'a rien de spectaculaire et se met en place en une heure environ. Pour chaque défaut :

  1. Copier tout l'arbre source dans un répertoire scratch.
  2. Appliquer exactement une mutation à la copie — un fichier, une modification.
  3. Exécuter la suite dans la copie, restreinte au test censé attraper cette mutation.
  4. Exiger que le statut soit FAIL.
  5. Jeter la copie.

Ensuite, à titre de contrôle, exécuter la suite une fois sur une copie non mutée et confirmer qu'elle est entièrement verte. Sans ce contrôle, vous ne pouvez pas distinguer « le test a attrapé la mutation » de « le test était cassé dans l'arbre scratch depuis le début ».

Notez ce qu'exige l'étape 3. Non pas « un test a échoué » — c'est ce test précis qui a échoué. Une mutation qui corrompt un fichier de données fait souvent tomber trois tests à la fois, et si vous ne contrôlez qu'un code de sortie non nul, vous attribuerez la prise au mauvais test et continuerez à croire en un test qui est en réalité mort.

Les onze mutations

Chacune est un défaut plausible, pas un changement de caractère au hasard. La distinction importe : muter < en <= dans une borne de boucle produit des défauts que personne n'écrirait jamais. Ce que vous voulez, c'est la forme d'erreur qui a réellement atteint la production quelque part.

#MutationFichier cibleDoit échouer
1Multiplier par 20 chaque poids de population de villelocality/en_US.tsvtest 5
2Faire tourner la colonne des codes postaux d'une lignelocality/de_DE.tsvtest 7
3Ajouter un BOM UTF-8 en tête d'un fichier de donnéeslocality/fr_FR.tsvtest 9
4Convertir le fichier en fins de ligne CRLFstreets/it_IT.tsvtest 9
5Copier la cohorte de naissance 1941 par-dessus la cohorte 2001cached/en_US/test 10
6Placer une balise PHP fermante dans un commentaire //nouveau fichiertest 8
7Faire tourner les offsets d'index d'un .bin compilé par rapport à son .txtcached/zh_TW/test 4
8Supprimer mt_srand($keep) de ng_pick()ng_source.phptest 1
9Supprimer mt_srand($keep) de ng_pick_or()ng_source.phptest 1
10Supprimer mt_srand($keep) de ng_pick_first()ng_source.phptest 1
11XOR entre l'unique tirage aléatoire du picker et la microseconde couranteng_source.phptest 2

Les mutations 1 et 5 sont les deux défauts sur lesquels il vaut le plus la peine de s'attarder, parce que toutes deux sont réelles. La mutation 1 est une version synthétique d'un défaut que nous avons réellement livré en préproduction : un fichier de localités dont les poids étaient des chiffres d'aire métropolitaine plutôt que des chiffres de ville, ce qui faisait que soixante villes taïwanaises totalisaient 106,9 % de Taïwan. La mutation 5 montre à quoi ressemble une construction de cohortes cassée — des personnes de vingt ans recevant en silence les prénoms de personnes de quatre-vingts ans, chaque nom de la sortie étant parfaitement réel.

Résultat : dix mutations sur onze ont produit le FAIL attendu. Une seule ne l'a pas fait, et c'était la mutation 8.

Le test qui était vert sur du code cassé

Le test 1 contrôle un invariant dont tout le reste dépend. Le picker doit laisser le flux mt_rand dans la même position, que le jeu de données demandé existe ou non. Si ce n'est pas le cas, alors ajouter un seul fichier de données à l'arbre change chaque champ de chaque carte générée, et chaque permalien que le site a jamais émis renvoie à des données différentes.

L'implémentation de l'invariant est une sauvegarde-restauration autour de la branche : tirer un nombre, faire ce que la branche exige, puis mt_srand($keep) pour remettre le flux en place.

La première version du test paraissait tout à fait raisonnable. Amorcer le générateur, appeler le picker avec un jeu de données qui existe, enregistrer le mt_rand() suivant. L'amorcer de nouveau, appeler le picker avec un jeu de données qui n'existe pas et un repli, enregistrer le mt_rand() suivant. Exiger que les deux coïncident.

Le repli était écrit ainsi :

fn() => 'X'

Ce repli ne touche jamais à mt_rand. Et c'est là tout le défaut — dans le test, pas dans le code.

Avec un repli qui consomme zéro tirage, les deux branches consomment exactement un tirage au total. Le flux atterrit ensuite au même endroit, que le réamorçage existe ou non. L'assertion est satisfaite par l'arithmétique plutôt que par la correction. Le test était vert, et il serait resté vert avec mt_srand($keep) entièrement supprimé du fichier.

Le vrai repli est un appel à Faker. Faker consomme un nombre inconnu et variable de tirages — c'est précisément la condition que le réamorçage a pour rôle de neutraliser, et précisément la condition que la fixture avait éliminée. La fixture avait accidentellement simplifié jusqu'à faire disparaître la seule chose qui donnait un sens au test.

Le correctif consiste à rendre le repli délibérément gourmand, et gourmand dans des proportions différentes :

$burn = fn(int $k) => function () use ($k) {
    for ($i = 0; $i < $k; $i++) mt_rand();
    return 'X';
};

mt_srand(4242); ng_pick_first(['t/weighted', 't/flat'], $burn(3));  // dataset present
mt_srand(4242); ng_pick_first(['t/NEMA1', 't/NEMA2'],   $burn(3));  // absent, fallback burns 3
mt_srand(4242); ng_pick_first(['t/NEMA1', 't/NEMA2'],   $burn(10)); // absent, fallback burns 10

Trois appels, une seule assertion : le mt_rand() suivant doit être identique dans les trois cas. C'est le troisième appel qui porte tout le poids. Présent contre absent pourrait passer par coïncidence ; absent-brûle-3 contre absent-brûle-10 ne le peut pas, car seul un réamorçage peut faire atterrir deux appétits différents au même endroit. Les mutations 9 et 10 passent toutes deux au rouge face à cette fixture. Face à l'ancienne, elles ne l'auraient pas fait.

La leçon générale ne porte pas sur les flux de nombres aléatoires. Elle tient à ceci : une fixture conçue pour la commodité tend à effacer exactement la variation que le test a pour raison d'être de détecter, et elle le fait en silence, parce que le résultat est un test qui passe.

La mutation qui ne peut pas être attrapée, et pourquoi nous l'avons consignée

La mutation 8 supprime le même réamorçage dans ng_pick(), et le test 1 reste vert. Ce n'est pas une lacune à combler plus tard. C'est une limite structurelle de la comparaison que le test effectue.

ng_pick() n'a pas de repli. Il n'y a rien à appeler quand le jeu de données est absent ; la fonction renvoie null. Les deux branches — jeu de données présent, jeu de données absent — consomment donc exactement un tirage, et la comparaison présent-contre-absent est de nouveau satisfaite, que le réamorçage soit là ou non. L'astuce qui a sauvé ng_pick_or() et ng_pick_first() n'est pas disponible ici, parce qu'il n'y a pas de second appétit à faire varier. Aucune ingéniosité dans la fixture n'y change quoi que ce soit ; les deux branches sont véritablement indiscernables par la position du flux.

Nous l'avons consigné dans un commentaire juste au-dessus du test :

Si vous retirez mt_srand($keep) de ng_pick() en particulier, ce test reste vert, et c'est une limite structurelle plutôt qu'un oubli. Ne prenez pas un test 1 vert pour la preuve que le réamorçage est présent dans ng_pick().

Le réamorçage dans ng_pick() est donc maintenu en place par la revue de code et par un commentaire dans les sources — pas par la suite. C'est une garantie moins bonne, et le dire est précisément l'essentiel. Un angle mort que vous avez consigné est un risque connu. Le même angle mort non documenté est une suite de tests qui ment à quiconque lira sa sortie verte ensuite — très probablement vous-même dans huit mois.

L'autre genre de test qui ne peut pas échouer

Les tests de mutation trouvent les tests morts par accident. Ils attirent aussi l'attention sur des tests qui n'ont jamais été vivants, et nous en avons trouvé un dans notre propre suite.

Nous avions un contrôle comparant trois quantités par locale : la part d'un corpus détenue par ses dix premières entrées, la couverture de la population nationale revendiquée par le corpus, et la part de la population qui en résulte pour ces dix premières entrées. Pour Taïwan, la suite affiche actuellement 52,81 % du corpus, multipliés par 99,9 % de couverture revendiquée, ce qui donne 52,73 %.

La partie rassurante est le premier nombre. Le ministère de l'Intérieur de Taïwan publie une part des dix premiers de 52,79 % pour le registre complet. Notre corpus calcule 52,81 %. Deux centièmes de point de pourcentage d'écart — exactement le genre d'accord qui vous fait cesser de chercher.

L'écart de deux centièmes est lui-même explicable plutôt que mystérieux, et il vaut la peine d'être cerné, parce qu'un vague « environ 52,8 % » est justement la manière dont une vraie divergence se cacherait. Les 52,79 % du registre portent sur 2 731 noms de famille et 23 373 283 porteurs. Nos 52,81 % portent sur 2 707 noms de famille et 23 367 536 porteurs, parce que nous écartons 24 lignes inutilisables : la catégorie de service 其他 (« autres », 5 174 porteurs, ce n'est pas un nom de famille) et 23 lignes dans la Private Use Area, où le ministère encode des caractères rares qui n'ont aucune attribution Unicode et s'afficheraient sous forme de carrés vides. Cela fait 5 747 porteurs, 0,0246 % de la population, retirés du dénominateur. Un dénominateur légèrement plus petit avec les mêmes dix premiers donne une part légèrement plus grande. Les nombres concordent parce que le nettoyage était limité et correct.

Rien de tout cela ne fait de ce contrôle d'identité un test. Il est sans valeur comme vérification, et sans valeur pour une raison visible sans rien exécuter. Les deux côtés sont calculés à partir des mêmes poids stockés. La couverture revendiquée est la somme de ces poids rapportée à la population nationale ; la part des dix premiers, ce sont les dix premiers de ces mêmes poids rapportés à leur somme. L'identité se réduit à :

(a/b) × (b/c) = a/c

ce qui est vrai pour a, b et c absolument quelconques. Remplissez le corpus de nombres inventés et le contrôle concorde toujours à deux décimales près. Il ne peut pas passer au rouge sur de mauvaises données, parce qu'il ne regarde jamais si les données sont bonnes — seulement si la division fonctionne.

Il y a une chose qu'il peut attraper : un futur remaniement qui calculerait la couverture par une autre voie et briserait ainsi l'identité arithmétique elle-même. C'est une valeur réelle, quoique étroite. Le contrôle est donc resté, mais il a été rétrogradé hors de l'ensemble des tests, vers une sortie informative, avec un statut PASS figé et un avertissement imprimé au-dessus des nombres indiquant qu'il s'agit de cohérence interne et non de vérification.

Cette rétrogradation est le bon geste chaque fois que vous en trouvez un de ce genre. Supprimer le contrôle fait perdre un nombre utile. Le laisser comme test gonfle la couverture apparente de la suite avec une ligne qui ne peut jamais échouer. L'imprimer comme information garde le nombre sous vos yeux et le tient hors du décompte de ce qui est réellement vérifié.

Il y a ici un post-scriptum qui a sa place dans un article sur les tests qui cessent discrètement de signifier quelque chose. Le commentaire explicatif au-dessus de ce contrôle citait un exemple chiffré : 48,14 % du corpus multipliés par 109,8 % de couverture revendiquée donnent 52,84 %. Quand nous avons exécuté le contrôle en écrivant cet article, il a affiché 52,81 %, 99,9 % et 52,73 % à la place. Le commentaire n'était pas faux au moment où il a été écrit — il décrivait une construction dans laquelle le corpus taïwanais était tronqué à ses 401 noms de famille les plus courants, si bien que les poids surestimaient la population et que la couverture ressortait au-dessus de 100 %. Le corpus livre désormais les 2 707 noms de famille avec de vrais nombres de porteurs, et la couverture est juste en dessous de 100 %. Rien n'était cassé ; la prose a simplement cessé de correspondre au code, et rien au monde ne nous l'aurait jamais signalé. Les commentaires qui portent des nombres sont des affirmations non vérifiées qui ont l'air de documentation, et ils se dégradent exactement dans la direction où ils restent plausibles.

Ce que vaut une suite verte

Trois états sont faciles à confondre et méritent d'être séparés explicitement :

ÉtatCe que l'exécution de la suite vous ditCe qu'elle ne vous dit pas
Code correct, test capableVert
Code cassé, test capableRouge
Code correct, test incapableVertQue le test est décoratif
Code cassé, test incapableVertQue le code est cassé

Les lignes trois et quatre sont indiscernables de la ligne une, quel que soit le nombre d'exécutions de la suite. Seule la mutation les distingue, parce que la mutation est ce qui vous place délibérément dans la ligne quatre et demande quelle couleur en sort.

C'est aussi pourquoi les pourcentages de couverture sont un mauvais indicateur indirect. Chacune de nos onze mutations vit dans du code qui était déjà couvert — exécuté par la suite, compté dans le pourcentage. La couverture mesure si une ligne s'est exécutée. Elle ne dit rien sur le fait que quelque chose se serait plaint si cette ligne avait été fausse. Notre ancien test 1 exécutait le réamorçage à chaque passage et n'affirmait rien à son sujet.

La liste de contrôle

  1. Cassez le code et exigez un échec précis. Non pas « la suite est passée au rouge » — c'est ce test qui est passé au rouge. Sinon, une mutation trop large permet à un test mort de s'attribuer la prise d'un test vivant.
  2. Exécutez un contrôle non muté. Un contrôle vert est ce qui donne un sens aux résultats rouges.
  3. Ne mutez qu'une chose à la fois. Deux mutations simultanées se masquent l'une l'autre, et vous n'apprenez rien sur aucune des deux.
  4. Choisissez des défauts que quelqu'un pourrait plausiblement livrer, pas des inversions d'opérateur au hasard. De mauvaises unités dans une colonne de données, un cache obsolète, un index décalé, un mauvais encodage.
  5. Auditez vos fixtures à la recherche de simplifications accidentelles. Un repli qui ne fait rien, un bouchon qui renvoie une constante, une fixture d'une seule ligne là où la production en a des milliers — voilà comment un test cesse discrètement de pouvoir échouer.
  6. Faites varier ce à quoi le code est censé être insensible. Si le code revendique une robustesse à la quantité que le repli consomme, la fixture doit en consommer des quantités différentes. Une seule valeur ne teste rien quant à l'invariance.
  7. Cherchez les identités qui se font passer pour des contrôles. Si les deux côtés dérivent de la même source, écrivez l'algèbre en toutes lettres. Si elle se réduit à quelque chose de vrai pour toutes les entrées, ce n'est pas un test.
  8. Rétrogradez plutôt que de supprimer un contrôle qui ne peut pas échouer : gardez le nombre en sortie informative, retirez-le du décompte des tests.
  9. Consignez ce que votre suite ne peut pas voir. Un angle mort documenté est un risque maîtrisé. Un angle mort non documenté est une fausse garantie qui porte votre nom.

Nous n'avons pas mené cet exercice parce que nous soupçonnions la suite. Nous l'avons mené parce qu'un résultat vert est infalsifiable par construction, et que la seule réponse honnête à un résultat infalsifiable est d'aller construire soi-même la falsification. Cela a coûté un après-midi, cela a trouvé une assertion morte et une identité qui se faisait passer pour un test, et cela a transformé une confiance vague en une affirmation précise et écrite : dix défauts que cette suite attrape, et un qu'elle n'attrape pas.

Sur l'invariant lui-même et sur les raisons pour lesquelles le générateur sauvegarde et restaure le flux, voir Comment fonctionne notre générateur. Sur le défaut réel que la mutation 1 imite — soixante villes totalisant 106,9 % d'un pays —, voir Soixante villes, 106,9 % d'un pays. Sur le piège de la cohérence interne dans son contexte d'origine, voir Sources de données : Taïwan.


Données arrêtées au 2026-07-18

Tous les chiffres ont été redérivés le 18 juillet 2026 à partir des fichiers sur le disque, et la campagne de mutations a été exécutée le même jour contre l'arbre livré.

Sources et remarques :

  • Suite de testsng_regression_tests.php, livrée dans source_share_1.1.8/set/. Dix tests, référencés par les clés 1–10 dans un registre en tête de fichier : (1) invariant du flux mt_rand, (2) déterminisme de la carte selon la graine, (3) distribution des poids et uniformité des fichiers plats, (4) top-10 empirique contre théorique, (5) somme des populations des villes contre la population nationale, (6) identité du dénominateur (INFO, pas une vérification), (7) appariement ville ↔ code postal, (8) piège de la balise fermante dans un commentaire de ligne, (9) encodage des données (BOM, UTF-16, CRLF, UTF-8 malformé), (10) cohortes de naissance séparant réellement les générations.
  • Harnais de mutationng_regr_mutation_check.php, un script de travail plutôt qu'un artefact livré. Onze mutations comme dans le tableau ci-dessus ; il copie l'arbre avec robocopy, applique une mutation, exécute la suite avec --only=N et exige que le statut analysé du test N soit FAIL. Il se termine par une exécution de contrôle non mutée.
  • La campagne du 18 juillet 2026 — dix attrapées, une manquée. Celle qui a échappé au test est la mutation 8 (ng_pick), où le test 1 a rapporté PASS parce que les deux branches montraient la même valeur mt_rand suivante ; c'est un échec de détection par construction, pas par accident. Exécution de contrôle sur une copie non mutée : tests: 10 · PASS 10 · FAIL 0 · WARN 0 · SKIP 0 · ERROR 0 · time 15.1 s.
  • Le défaut du repli — consigné dans les sources au niveau de la fixture, qui indique qu'un repli ne touchant pas à mt_rand fait exécuter exactement un tirage aux deux branches, de sorte que le flux coïncide même sans réamorçage dans le code, et que c'est ainsi que le test a un jour été vert sur du code cassé. La fixture actuelle brûle respectivement 3 et 10 tirages.
  • La limite structurelle — consignée dans un commentaire au-dessus du test 1, et décrite là comme vérifiée par mutation plutôt que supposée. ng_pick() n'a pas de branche de repli, de sorte que présent-contre-absent ne peut pas distinguer la présence du réamorçage.
  • L'identité du dénominateur — le test 6 porte un PASS figé et imprime un avertissement disant qu'il s'agit de cohérence interne, pas de vérification. Sa sortie en direct du 18 juillet 2026 est zh_TW lastname_male · 52.81% top-10-in-corpus · 99.9% coverage · 52.73% product, réexécutée pour cet article. L'exemple 48.14% / 109.8% / 52.84% encore présent dans le commentaire au-dessus de ce test décrit une construction à 401 entrées désormais remplacée et il est obsolète ; data-sources-taiwan.md a été corrigé le 18 juillet 2026 et porte maintenant les chiffres actuels. Notez que les 99,9 % de la suite et les 99,98 % de l'article ne diffèrent que par le dénominateur — la suite divise par une base arrondie de 23 400 000, l'article par la base du registre, 23 373 283.
  • Les chiffres taïwanais — recalculés directement à partir de cached/zh_TW/lastname_male.wgt le 18 juillet 2026 : 2 707 entrées, des poids totalisant 23 367 536, les dix premiers totalisant 12 339 778, soit 52,8074 % du corpus. Rapportés à la base complète du registre, 23 373 283, ces mêmes dix premiers font 52,7944 %. Le ministère publie 52,79 % sur 2 731 noms de famille. La différence de 5 747 porteurs entre les deux dénominateurs correspond exactement aux 24 lignes exclues : 其他 avec 5 174 porteurs, plus 23 lignes de la Private Use Area. Les deux parts sont donc deux mesures différentes, pas une contradiction.

← Articles