Le contrôle qui devient aveugle exactement là où vous en avez besoin
Nous avons déjà écrit au sujet de tests qui ne peuvent pas échouer — des assertions satisfaites par simple arithmétique, des fixtures qui effaçaient précisément la variation qu'elles étaient censées détecter. Ceux-là sont uniformément morts. Ils sont verts sur du code correct et verts sur du code cassé, et dès que vous en repérez un, vous savez exactement ce qu'il valait : rien, et cela de façon constante.
Cet article porte sur une forme pire encore. Un contrôle qui échoue sélectivement — qui fonctionne sur les défauts ordinaires et se tait sur le défaut extrême. Il affiche du vert, et ce vert n'est pas dénué de sens ; il est activement trompeur, parce qu'il est corrélé à la gravité du problème dans le mauvais sens.
Nous en avons construit un trois fois de suite en écrivant un seul contrôle, et chaque version était cassée d'une manière différente. Puis, deux fois dans la même journée, nous avons rencontré le même mode de défaillance dans un endroit dépourvu de tout test : une recherche de texte qui renvoyait « introuvable » pour une chaîne présente dans le fichier.
Trois versions d'un même contrôle, toutes fausses
Nous voulions vérifier que les traductions de l'interface étaient complètes. Les fiches du générateur portent des valeurs qui sont elles-mêmes des données — signes du zodiaque, couleurs des yeux, types de cheveux, sports — et ces valeurs doivent être traduites pour chaque langue. Il y en a 84, et elles résident dans des fichiers JSON par langue.
Le contrôle, c'est set/ng_check_i18n.php. Il a traversé trois versions avant de faire quoi que ce soit d'utile.
Version 1 : elle ne regardait jamais le fichier
La première version parcourait le source PHP à la recherche des clés dynamiques au moyen d'une expression régulière, en lisant phpadd/ng_extra.php.
Ce fichier n'existe pas. Le vrai chemin est phpadd/name_gen/ng_extra.php.
Les fonctions de fichier de PHP ne lèvent pas d'exception sur un chemin manquant par défaut ; elles émettent un avertissement et renvoient false. L'expression régulière s'est donc exécutée sur un contenu vide, n'a rien trouvé, et le contrôle a conclu qu'il y avait zéro clé dynamique à vérifier. Zéro clé, zéro manquante, 100 % complet. Vert.
Observez ce qu'est devenue la défaillance. Elle n'a pas été avalée par un try/catch ni par un @. Elle a été convertie, silencieusement et par la sémantique ordinaire du langage, de « je ne trouve pas le fichier » en « le fichier ne contient rien d'intéressant » — et ces deux états sont indiscernables dans la sortie. Le contrôle n'aurait pu échouer pour aucune entrée, parce qu'il ne lisait aucune entrée.
Une seconde leçon est enfouie ici, que nous n'avons vue que plus tard. Le chemin n'était pas seulement faux ; c'est toute l'approche qui l'était. Les clés ne sont pas du tout des littéraux dans le source — elles sont construites à l'intérieur des corps de fonctions, dans ng_extra_sports(), dans ng_extra_zodiac(), dans des tableaux assemblés à l'exécution. Aucune expression régulière sur ce fichier ne les aurait trouvées, même au bon chemin. Corriger le chemin aurait produit un contrôle toujours presque aveugle, mais désormais aveugle pour une raison plus subtile, et il aurait eu l'air réparé. La version finale dérive au contraire la liste des clés empiriquement, à partir des fichiers JSON.
Un contrôle qui annonce un succès sans lire son entrée n'est pas un contrôle faible. C'est un résultat fabriqué.
Version 2 : elle devenait aveugle précisément au défaut maximal
La deuxième version avait la bonne liste de clés. Pour éviter le bruit dû aux clés qui n'apparaissent légitimement que dans une seule langue, elle exigeait qu'une clé soit présente dans au moins deux langues avant de réclamer qu'elle le soit dans toutes.
Le site a trois langues actives : en comme référence, plus uk et de. La situation sur le disque était que uk avait les 84 traductions de valeurs et de aucune.
Donc, pour chacune des 84 clés, le nombre de langues la contenant était de un. Le seuil de deux n'a jamais été atteint. Pas une seule clé n'a été contrôlée. Vert.
Relisez le mécanisme, car c'est sa forme qui compte. Le contrôle aurait parfaitement fonctionné s'il avait manqué quarante clés à de — alors soixante seraient apparues dans deux langues, auraient été contrôlées, et quarante échecs auraient été signalés. Il est resté complètement muet parce que le trou était total. La condition ne s'est pas déclenchée pour exactement la raison qui rendait ce déclenchement urgent.
C'est la propriété qui rend la cécité sélective pire que la mort uniforme. Un contrôle qui ne fonctionne jamais ne vous enseigne rien de faux ; vous découvrez qu'il est mort la première fois que vous le testez. Un contrôle dont la sensibilité est inversement proportionnelle à la taille du défaut donne son vert le plus assuré au pire moment. C'est un détecteur de fumée qui s'éteint au-dessus d'une certaine température.
Le seuil est désormais 1, avec au-dessus un commentaire qui consigne pourquoi :
Le seuil est 1, pas 2. La première version exigeait « la clé existe dans au moins deux langues » — et est restée muette précisément dans le pire cas : seule
ukavait des traductions de valeurs,den'en avait aucune. La condition « dans deux langues » n'a pas tenu parce que le trou était complet. Un contrôle qui devient aveugle au défaut maximal est pire que pas de contrôle du tout : il fournit une fausse assurance.
Version 3 : elle criait au loup sur la référence elle-même
Une fois le seuil corrigé, le contrôle s'est mis à signaler. Sa première sortie fut un avertissement contre en.
Les clés dynamiques sont des traductions de valeurs de données anglaises — Sagittarius, Very curly, Ski Jumping. Dans la locale anglaise, la valeur est la traduction. Il n'y a rien à faire. Mais le contrôle appliquait une règle uniforme à chaque langue, et en y échouait, parce que en ne contenait pas de traduction de l'anglais vers l'anglais.
C'est la défaillance inverse, et elle n'est pas anodine. Un contrôle qui avertit d'un état correct entraîne son lecteur à sauter les avertissements. Tout avertissement réel à venir doit désormais disputer l'attention du lecteur à un avertissement connu comme faux, et le faux est toujours là. L'état final est un contrôle qui s'exécute techniquement et qui est fonctionnellement ignoré — la même valeur que la version 1, atteinte par le lecteur au lieu du code.
La correction exempte explicitement la locale de référence, avec le raisonnement consigné :
enest exclue du contrôle des clés dynamiques délibérément : ce sont des traductions de valeurs de données (Sagittarius, Very curly, Ski Jumping), et les valeurs sont déjà en anglais — il n'y a rien à traduire. Sans cette exemption, le contrôle avertissait contre sa propre référence, et le bruit vous entraîne à l'ignorer.
Ce que les trois ont en commun
| Version | Comportement sur un système correct | Comportement sur le pire défaut | Pourquoi cela avait l'air correct |
|---|---|---|---|
| 1 | Vert | Vert | Fichier manquant lu comme un fichier vide |
| 2 | Vert | Vert | Seuil inatteignable quand la lacune est totale |
| 3 | Avertissement | Avertissement | Règle uniforme appliquée à la référence |
Chacune d'elles s'est exécutée jusqu'au bout, a imprimé un résultat plausible et s'est terminée avec le code zéro. Aucune n'a annoncé un problème la concernant. La seule façon dont l'une des trois a été prise en défaut, c'est que quelqu'un savait indépendamment quelle devait être la réponse et a remarqué que le contrôle disait le contraire — soit précisément le savoir dont un contrôle est censé supprimer la nécessité.
Le défaut qu'il avait été construit pour trouver était d'ailleurs réel : de avait 0 traduction de valeur sur 84, si bien qu'un visiteur allemand voyait Sagittarius, Hazel, Very curly et Ski Jumping posés au milieu de pages par ailleurs allemandes. Cette lacune a depuis été comblée ; de porte maintenant les 84. Trois versions cassées d'un contrôle se dressaient entre un défaut réel, visible, exposé à l'utilisateur, et quiconque aurait pu en être informé.
La même défaillance sans le moindre code : les diacritiques
Deux fois dans la même journée, une recherche de chaîne a signalé que la chaîne était absente d'un fichier qui la contenait.
Vietnamien. Nous retirions une ligne du corpus vietnamien des prénoms. Thị n'est pas un prénom — c'est le marqueur traditionnel de deuxième nom féminin, et il avait été ingéré comme s'il s'agissait d'un nom. Le script de suppression a reçu la chaîne à supprimer, prise dans un rapport plutôt que dans le fichier.
Le rapport l'avait écrite Thi. Le fichier contenait Thị, où ị est U+1ECB, latin small letter i with dot below. En octets :
Thi -> 54 68 69
Thị -> 54 68 E1 BB 8B
Trois octets contre cinq. Aucune correspondance, rien de supprimé — et le script a annoncé un succès, car de son point de vue il avait mené à bien une recherche-suppression avec zéro erreur. La mauvaise ligne a survécu à une correction écrite explicitement pour la retirer, et a été consignée comme corrigée.
Français. Le sens inverse, le même mécanisme. Nous vérifiions si le corpus français des noms de famille contenait la couche migratoire — les noms de famille maliens, sénégalais, portugais et vietnamiens que tout jeu de données français honnête doit comporter. Nous avons cherché TRAORE.
Introuvable. La conclusion tirée fut qu'une couche démographique entière manquait au corpus, ce qui est un grave défaut de données et aurait justifié une reconstruction substantielle.
Le corpus contient Traoré, au poids 6 674. À côté de Da Silva à 29 515, Dos Santos à 17 661, Gonçalves à 16 448 et Nguyen à 11 922. La couche était entièrement présente. La recherche portait sur la forme sans accent, et é n'est pas e.
Il vaut la peine de mettre les deux incidents côte à côte, car ils ont des signes opposés :
| Incident | Ce qui était cherché | Ce qui était dans le fichier | Résultat | Dommage |
|---|---|---|---|---|
| Vietnamien | Thi | Thị (U+1ECB) | Introuvable | Un défaut réel a été caché, et signalé comme corrigé |
| Français | TRAORE | Traoré (U+00E9) | Introuvable | Un défaut inexistant a été inventé |
Une recherche a dissimulé un bogue. L'autre en a fabriqué un. Ni l'une ni l'autre n'a levé d'erreur, ni l'une ni l'autre n'a renvoyé un ensemble de résultats vide d'une manière qui paraissait anormale, et toutes deux ont produit la sortie la plus plausible qu'une recherche puisse produire : rien de trouvé.
Pourquoi « rien de trouvé » est le résultat le plus dangereux en informatique
Une exception est un bon résultat. Un plantage est un bon résultat. Les deux sont sans ambiguïté, les deux vous arrêtent, les deux se nomment eux-mêmes.
« Rien de trouvé » n'est rien de tout cela. C'est la sortie correcte pour au moins quatre situations complètement différentes :
- La chaîne n'y est vraiment pas.
- La chaîne y est, dans une normalisation différente — accents, casse, composition Unicode, caractères invisibles bidi ou de largeur.
- La recherche n'a jamais lu l'entrée : mauvais chemin, mauvais encodage, tampon vide, permission refusée.
- L'entrée a été lue mais est inutilisable — un PDF extrait sous forme de binaire, un fichier UTF-16 lu comme de l'UTF-8, un fichier avec une marque d'ordre des octets collée au premier champ.
Seule la première est une information. Les trois autres sont des défaillances de la recherche, et toutes les quatre se présentent de façon identique : un résultat vide et un code de sortie zéro.
Nous avons rencontré le cas 4 la même semaine, dans une enquête sans rapport. En vérifiant si le régulateur sud-africain réserve des numéros de téléphone à un usage fictif, nous avons cherché « reserved », « drama », « film » et « test » dans une copie extraite du Government Gazette pertinent. Les quatre ont renvoyé zéro. Cela a brièvement été consigné comme un constat confirmé : l'Afrique du Sud ne réserve rien.
L'extrait était du binaire brouillé. Il aurait aussi renvoyé zéro correspondance pour « the ». Nous avions fondé une conclusion sur une recherche structurellement incapable de réussir — l'erreur identique à celle de la version 1 du contrôle de traduction, dans un autre support, cinq jours plus tard. Cette histoire, et les plages réservées qui, elles, étaient confirmables, est racontée dans Faux numéros de téléphone qui ne peuvent pas exister.
La règle qui aurait tout évité
Il existe une habitude qui répond à chacun des incidents ci-dessus, et elle est d'une simplicité presque insultante :
Copiez depuis le fichier la chaîne que vous cherchez. Jamais depuis un rapport, un message de chat, un message de commit ou votre mémoire.
Chacune de ces défaillances provenait d'une chaîne passée par un intermédiaire lisible par un humain. Thi avait perdu son diacritique quelque part entre le fichier et le rapport. TRAORE avait été tapé de mémoire, sous la forme qu'un moteur de recherche accepterait. Les deux paraissaient parfaitement correctes à l'écran. Les diacritiques sont le piège idéal pour cela, parce que la forme corrompue n'est pas du charabia — c'est un mot lisible, plausible, d'aspect professionnel, qui se trouve être une chaîne différente.
Trois pratiques en découlent, par ordre croissant de l'agacement qu'elles vous causeront et de ce qu'elles valent :
Prouvez que la recherche peut réussir avant de croire qu'elle a échoué. Faites un grep sur quelque chose dont vous savez que c'est dans le fichier. Si grep . ne renvoie rien, le fichier n'est pas ce que vous croyez. C'est une seule commande, et elle distingue complètement le cas 1 des cas 3 et 4.
Après une suppression, vérifiez avec un vidage hexadécimal. Pas « le script a dit OK » — le script a dit OK dans l'incident vietnamien. Imprimez les octets des lignes qui restent et regardez-les. Cinq octets là où vous en attendiez trois, c'est visible en hexadécimal et invisible dans un terminal.
Cherchez en normalisé quand vous voulez du normalisé. Si vous voulez savoir si un nom est présent indépendamment des accents, retirez les accents des deux côtés avant de comparer, délibérément, comme une étape documentée. Ce qui a mal tourné avec TRAORE, ce n'est pas que la recherche insensible aux accents soit mauvaise — c'est que la recherche était accidentellement sensible aux accents alors que la personne qui la lançait croyait le contraire.
Concevoir des contrôles qui échouent bruyamment aux extrêmes
Côté code, le contrôle de traduction livre un ensemble de propriétés qu'il vaut la peine d'exiger de tout contrôle avant de faire confiance à son vert.
Testez le contrôle aux deux bornes, pas au milieu. Un défaut partiel est le cas facile ; chaque version de notre contrôle aurait attrapé de s'il lui avait manqué quarante clés. Donnez-lui le cas vide et le cas total. La version 2 a survécu à une année de sorties d'apparence plausible et est morte à l'instant où quelqu'un a demandé ce qu'elle faisait quand une langue a zéro traduction.
Faites de « aucune entrée » un échec, pas une réussite. Si un contrôle lit un fichier, ne trouve rien et annonce un succès, ce succès est indiscernable d'une fabrication. Posez en assertion que l'entrée n'est pas vide, sous forme de contrôle distinct et préalable. count($keys) === 0 devrait être un résultat rouge, pas un score de 100 %.
Méfiez-vous des seuils qui dépendent de la santé des données. « Présent dans au moins deux langues », « au moins dix lignes », « si le fichier fait plus de N » — chacun d'eux est un interrupteur que le défaut lui-même peut couper. Si un garde-fou existe pour réduire le bruit, demandez explicitement ce qu'une entrée catastrophique lui fait. Le nôtre éteignait entièrement le contrôle.
Exemptez explicitement les cas connus comme bons, et écrivez pourquoi. L'avertissement de la version 3 contre en n'avait pas tort sur les données ; il avait tort sur la règle. La correction est une exemption assortie d'un commentaire, non une règle assouplie, car une règle assouplie cesse aussi d'attraper les vrais cas.
Traitez un contrôle bruyant comme un contrôle cassé. Un avertissement toujours présent est un avertissement jamais lu. Il n'y a que deux états acceptables pour un avertissement récurrent : corriger la condition, ou l'exempter explicitement. Le laisser subsister pour être filtré mentalement est le troisième état, et c'est ainsi qu'un contrôle qui s'exécute devient un contrôle décoratif.
Préférez la dérivation empirique à la reconnaissance de motifs sur le source. La version 1 tentait de récupérer une liste de clés par expression régulière depuis du PHP qui construit les clés à l'exécution. Les lire depuis l'artefact où les clés atterrissent réellement ne peut pas sous-déclarer silencieusement de la même manière ; si l'artefact manque, vous obtenez zéro clé et — selon la règle ci-dessus — cela doit être rouge.
Deux sortes de contrôle mort
Il vaut la peine d'expliciter en quoi cela diffère de l'article sur les tests de mutation, car les remèdes sont différents.
| Ce que l'on compare | Uniformément mort | Sélectivement aveugle |
|---|---|---|
| Comportement sur du code correct | Vert | Vert |
| Comportement sur un petit défaut | Vert | Rouge — correctement |
| Comportement sur un défaut total | Vert | Vert |
| Comment on le découvre | Tests de mutation | En lui donnant le cas extrême |
| Ce qu'il vous coûte | Rien ; il n'a jamais aidé | La confiance, au pire moment |
Les tests de mutation trouvent la première sorte de façon fiable, parce qu'une mutation est un petit défaut délibéré et qu'un test uniformément mort y reste vert. Ils ne trouveront pas la seconde sorte de façon fiable — une mutation modeste est exactement l'entrée qu'un contrôle sélectivement aveugle traite correctement. Notre version 2 aurait passé un test de mutation haut la main. Supprimer quarante traductions la fait passer au rouge, dans les temps, avec l'air d'être en bonne santé.
La pratique supplémentaire n'est donc pas plus de mutations, mais des mutations plus extrêmes : non pas « retirer une traduction » mais « retirer toutes les traductions d'une langue, puis retirer la langue, puis vider le fichier, puis supprimer le fichier ». Un contrôle devrait devenir plus bruyant à mesure que l'entrée empire, de façon monotone. S'il existe une entrée assez grave pour le faire taire, c'est justement celle-là qui arrivera.
La liste de contrôle
- Copiez les chaînes de recherche depuis le fichier, jamais depuis un rapport, un chat ou votre mémoire. Les diacritiques survivent au trajet visuellement, mais pas au niveau des octets.
- Avant de croire un résultat négatif, prouvez que la recherche aurait pu trouver quelque chose. Faites un grep sur une chaîne dont vous savez qu'elle est présente.
- Après une suppression, vérifiez en hexadécimal. « Le script a annoncé un succès » est ce que le script a dit quand il n'avait rien supprimé.
- Faites d'une entrée vide un résultat rouge. Zéro clé trouvée ne doit jamais s'arrondir en 100 % complet.
- Donnez à chaque contrôle le cas de défaillance totale, pas un cas partiel. Les défauts partiels sont l'entrée facile ; c'est dans l'extrême que loge la cécité.
- Auditez chaque seuil et chaque garde-fou pour savoir si le défaut peut les désactiver. « Dans au moins deux langues » a été désactivé par le fait que la lacune était complète.
- Exemptez explicitement le cas de référence, avec une raison écrite. N'assouplissez pas la règle pour faire taire un avertissement portant sur un état qui est correct.
- Traitez un avertissement présent en permanence comme un défaut du contrôle. Le bruit sur le cas normal est la façon dont un contrôle qui s'exécute devient un contrôle ignoré.
- Distinguez « absence confirmée » de « introuvable ». C'est la même sortie et ce sont des affirmations complètement différentes.
Aucune des trois versions de ce contrôle n'a jamais annoncé de problème. Pas davantage le script qui n'a rien supprimé, ni la recherche qui a inventé une couche migratoire manquante, ni le grep lancé sur une gazette corrompue. Chacun d'eux a réussi, a imprimé un résultat d'apparence raisonnable et s'est terminé avec le code zéro.
C'est là toute la difficulté. Le code cassé s'annonce lui-même ; une vérification cassée, non, car le mode de défaillance d'une vérification consiste à vous donner raison.
Pour l'autre moitié de ce sujet — des assertions satisfaites par l'algèbre, et comment nous avons établi lesquels de nos tests étaient capables d'échouer — voir Un test qui ne peut pas échouer. Pour l'audit des numéros de téléphone, où le même résultat négatif aveugle est apparu dans un document de régulateur, voir Faux numéros de téléphone qui ne peuvent pas exister. Pour les défauts de données que ces recherches traquaient, voir Ce que nous avons fait de travers et Pièges dans les données de noms (article compagnon, pas encore publié).
Données arrêtées au 2026-07-18
Tous les chiffres ont été vérifiés le 18 juillet 2026 par rapport aux fichiers présents sur le disque.
Sources et remarques :
- Le contrôle de traduction —
set/ng_check_i18n.php, 125 lignes, un script autonome dansset/. Les trois versions cassées sont consignées sous forme de commentaires d'avertissement à l'endroit exact de chaque correction : l'erreur de chemin aux lignes 52–53, le seuil des deux langues aux lignes 70–76, l'exemption de la locale de référence aux lignes 100–107. Les versions antérieures elles-mêmes ne subsistent pas — le répertoire n'est pas sous gestion de versions, si bien que l'implémentation à balayage par expression régulière n'est attestée que par le commentaire rétrospectif qui la décrit. - ⚠ Pas dans la suite de régression. Ce contrôle est un script autonome et ne fait pas partie de
ng_regression_tests.php, qui ne contient aucun code i18n. Il n'est pas non plus encore raccordé au contrôle post-déploiement ; cela reste une recommandation. Un contrôle dont il faut se souvenir et qu'il faut lancer à la main est une quatrième façon d'être aveugle, et c'est l'état actuel. - Le nombre 84 — vérifié de deux façons indépendantes. Par composition de
phpadd/name_gen/ng_extra.php: 56 sports + 12 signes du zodiaque + 6 couleurs des yeux + 5 types de cheveux + 6 couleurs de cheveux = 85, moins un doublon (Brownapparaît à la fois comme couleur des yeux et comme couleur de cheveux) = 84 uniques. Par différence de clés avec le JSON en production dansphpadd/i18n/:ena 86 clés dont 0 hors de l'ensemble de référence,uketdeen ont 170 chacune, dont 84 sont hors de cet ensemble. - ⚠ Un chiffre contradictoire dans le source. Le commentaire à
ng_check_i18n.php:72indique queukavait 69 traductions de valeurs. Le relevé d'audit comme les fichiers actuels donnent 84. 69 n'est corroboré par rien et n'a pas pu être reconstitué ; 84 est le chiffre mesuré et c'est celui qui est utilisé ici. - « 0 sur 84 » est historique. La lacune de
deétait réelle au moment de l'audit et est consignée commede: 0/84face àuk: 84/84. Elle a depuis été comblée —demesure maintenant 84 sur 84 sur le disque. Les trois langues concernées sonten(référence),uketde, qui constituent l'ensemble actif complet pour les clés de valeurs dynamiques. - L'incident vietnamien — consigné dans
name_dev/NEVER-DO.mdaux lignes 409–417. La chaîne a été prise dans un rapport où elle apparaissait commeThi; le fichier contenaitThị, avecịà U+1ECB. Séquences d'octets54 68 69contre54 68 E1 BB 8B— trois octets contre cinq. La suppression n'a rien trouvé et a annoncé un succès. La ligne a maintenant disparu ; le corpus ne conserve queThịnh(poids 18) etĐức Thịnh(poids 5). - L'incident français — consigné dans
NEVER-DO.mdaux lignes 488–492, et présenté là comme le même mécanisme que le cas vietnamien avec le signe opposé : l'un a caché un défaut, l'autre en a inventé un.Traoréest présent à la fois dansnames/fr_FR/lastname_male.tsvet danslastname_female.tsvà la ligne 1006, poids 6 674, exactement une fois par fichier. La couche migratoire environnante que la recherche devait détecter :Da Silva29 515,Dos Santos17 661,Gonçalves16 448,Nguyen11 922. - La gazette sud-africaine — un Government Gazette de l'ICASA extrait en texte, qui s'est révélé être du binaire largement brouillé et a renvoyé zéro correspondance pour « reserved », « drama », « film » et « test ». Consigné ici comme une instance du cas 4, non comme un constat sur la numérotation sud-africaine.