« Aucune erreur » dans un journal qui ne reçoit rien : les tests qui ne peuvent pas échouer
Le 11 septembre 2026, un test de notre moteur affirmait qu'un refus d'authentification n'écrit aucune erreur dans le journal. Nous avons modifié le code pour que ce refus parte en erreur, exprès. Le test est resté vert.
Trois tests verts qui ne gardaient rien
Aucun des trois n'était cassé au sens habituel : ils s'exécutaient et passaient. Ce qui leur manquait, c'est la possibilité d'échouer.
Un instrument neutralisé. Le test capturait les messages du journal, puis vérifiait qu'aucun n'était de niveau erreur. Pour obtenir son client HTTP, il rechargeait le module principal de l'application. Or ce module reconfigure la journalisation à chaque chargement avec logging.basicConfig(force=True), qui retire tous les récepteurs déjà posés, celui de la capture compris. Une sonde le montre :
journal.error("sonde") # sans monter le client : 1 message capturé
client = monter_client() # recharge l'application
journal.error("sonde") # après le montage : 0 message capturé
« Aucune erreur » était vrai d'un journal qui ne recevait plus rien. Le test aurait passé quoi que le code écrive.
Un chemin jamais atteint. En réparant l'instrument, la première assertion ajoutée a porté sur le code de retour. Elle a rougi : la requête du test, envoyée avec une clé inexistante, rendait 403. Le 401 ne sort que lorsque l'en-tête d'autorisation manque. Le test nommait un refus qu'il ne provoquait pas, et le journal muet cachait aussi cela.
Un instrument demandé, jamais lu. Dans le même fichier, un second test réclamait la capture du journal et n'y lisait rien. Il affirmait qu'une exception imprévue laisse une trace complète au journal : retirer la pile d'appels de cette trace le laissait vert.
Un témoin absent. Le 12 septembre, côté application Shopify, pendant le passage d'un nom de domaine à un autre. Un garde vérifie que le document remis aux relecteurs de Shopify ne nomme aucun hôte que notre serveur web ne sert pas. Pendant la bascule, le serveur sert les deux noms. Un document resté sur l'ancien nom ne nomme donc aucun hôte inconnu, et le garde l'accepte : c'est pourtant exactement le document qu'il devait refuser. Ce qui le rend utile, c'est une seconde assertion, qui exige que le document nomme le nouveau nom. Sur un document ramené à l'ancien nom, le garde rougit avec elle et reste vert sans elle.
Ce qu'ils ont en commun : ils affirment une absence
« Aucune erreur », « aucun hôte inconnu » : une assertion d'absence est vraie sur le vide. Et un test peut se trouver dans le vide de trois façons sans le savoir :
- l'instrument ne voit plus rien : le journal débranché ;
- l'entrée n'atteint pas la branche que le test nomme : le 403 au lieu du 401 ;
- ce que le test examine ne contient pas le cas : le document qui ne parle que de l'ancien nom.
Le test qui ne lit pas son instrument est la forme limite : il ne vérifie rien de ce qu'il annonce. À l'inverse, une présence affirmée sur un instrument mort rougit d'elle-même, puisqu'il n'y a rien à trouver.
Nous avons cherché ces formes dans les tests de nos deux dépôts Python, par un balayage de leur arbre syntaxique. Avant d'en lire le résultat, nous l'avons lancé sur l'état d'avant la réparation, où il devait retrouver les deux tests du journal : il les retrouve. Sur l'état du 13 septembre, 949 fonctions de test dans le moteur et 444 dans l'application Shopify :
| Forme repérée | Moteur | App Shopify |
|---|---|---|
| capture demandée, jamais lue | 0 | 1 |
| capture lue après un rechargement de module | 1 | 0 |
| test dont toutes les assertions sont des absences | 60 | 23 |
| boucle portant une assertion, sans compte préalable de ce qu'elle parcourt | 42 | 10 |
sortie anticipée sous condition (return, skip) | 6 | 7 |
Une forme n'est pas un défaut : c'est l'endroit où regarder. Le seul cas de capture lue après rechargement affirme une présence, il rougirait si la capture mourait. Nous avons mis trois autres candidats à l'épreuve, en cassant le code qu'ils gardent :
- « le bac à sable ne pollue pas les statistiques » : on l'y fait écrire, le test rougit ;
- « une clé ne voit pas les catalogues d'une autre » : on retire le filtre, le test rougit. Mais rien dans le test n'affirme que le catalogue de la première clé a bien été créé. En cassant aussi sa création (un nom de pack inconnu, qui rend 422), le test reste vert, filtre retiré ;
- « un import abandonné ne se lit pas comme une conformité manquée » : on le fait lire ainsi, et la suite entière de l'application reste verte, 518 tests passés. Ce test réclame la capture du journal et ne la lit pas. C'est la forme du deuxième cas, vivante deux jours plus tard dans un autre dépôt.
Comment on s'en aperçoit : casser le code exprès
Aucun de ces tests ne se trahit à la relecture : ils passent, et un test qui passe ne se relit pas. Ce qui les a montrés est un geste ancien, la mutation : modifier volontairement le code que le test garde, pour y faire entrer le défaut qu'il nomme, et vérifier qu'il rougit. Son prix, relevé sur notre semaine de travail du 7 au 13 septembre :
| Étape | Ce qu'on fait | Coût mesuré |
|---|---|---|
| copie jetable | extraire l'état du dépôt dans un dossier temporaire (git archive) | 0,1 s, 3,7 Mo pour le moteur |
| mutation | remplacer une ligne du code, par un script de quelques lignes | négligeable |
| test visé | lancer le seul test concerné | moins d'une seconde |
| suite entière | savoir si un autre test attrape le défaut | 3 à 4 s pour l'application (518 tests), 117 s pour le moteur (1 140 tests) |
Quand un lot de mutations tient dans une seule commande, il a pris de 6 à 36 secondes de bout en bout. La réparation complète du premier test, sonde, réécriture et cinq mutations comprises, un peu plus de quatre minutes. Le coût réel est ailleurs, dans trois pièges rencontrés la même semaine.
La mutation qui n'a pas pris. Un script cherche la ligne à remplacer, ne la trouve pas (elle avait changé entre-temps) et s'arrête sur une erreur. La commande suivante lance les tests quand même : « 10 passed ». Une mutation non appliquée est un test qui ne peut pas échouer, au second degré. Le script doit vérifier que le motif existe exactement une fois, et tout arrêter sinon :
assert source.count(avant) == 1, "mutation non appliquée"
Le rouge qui ne vient pas du défaut. Le test du 401, une fois réécrit, rougissait déjà sur le code intact, pour la raison du 403. Compter ce rouge comme une mutation attrapée aurait été faux. D'abord le vert complet sur le code intact, ensuite, pour chaque rouge, lire quelle ligne rougit et pourquoi.
La mutation qui ne fait pas entrer le défaut nommé. Pour le test des imports abandonnés, notre première mutation supprimait le message propre aux imports. Suite verte, mais sans rien prouver : le message générique qui prenait sa place ne parle pas de conformité non plus. La mesure n'a porté qu'en faisant lire l'import comme une conformité, c'est-à-dire ce que le nom du test interdit.
Le témoin positif, et pourquoi il ne suffit pas seul
La réparation du premier test ajoute un témoin : dans le même test, une panne rendue en 503 par le même gestionnaire doit, elle, écrire une erreur. Si l'instrument meurt, le témoin rougit. Il est nécessaire.
Il ne dit pourtant rien du chemin. Nous avons écrit la variante : même instrument réparé, même témoin 503, mais sans l'assertion sur le code de retour et avec l'ancienne requête. Puis nous avons fait écrire le 401, et lui seul, en erreur. La variante reste verte : sa requête rend 403, le 401 n'est jamais émis, et le témoin voit bien son 503. Le test complet, lui, rougit :
with patch.object(journal, "error") as erreur:
# 1. le chemin : l'entrée atteint la branche nommée
assert client.get("/v1/usage").status_code == 401
# 2. l'absence
assert erreur.call_count == 0
# 3. le témoin : le même instrument voit une présence
retirer_la_cle_admin()
r = client.post("/v1/admin/keys", json={"plan": "trial"})
assert r.status_code == 503
assert erreur.call_count == 1
Et le témoin ne remplace pas la mutation. Il établit que l'instrument voit, pas que le test rougirait si le code régressait. La mutation se fait une fois ; le témoin reste dans le test, et c'est lui qui le garde en vie le jour où une modification voisine le videra sans bruit.
Le geste
Pour chaque assertion d'absence (aucun, jamais, ne contient pas, liste vide, zéro appel, et toute assertion posée dans une boucle) :
- Affirmer le chemin avant l'absence. Le code de retour, le nombre d'éléments parcourus, la ligne créée par la mise en place : ce qui prouve que l'entrée atteint la branche que le test nomme.
- Faire voir une présence au même instrument, par le même canal, dans le même test.
- Une fois, casser le code dans une copie jetable pour y faire entrer le défaut que le nom du test interdit. Vérifier que la mutation a pris, puis lire quelle ligne rougit.
Les deux premiers restent dans le test et coûtent une ligne chacun. Le troisième coûte quelques secondes et ne se garde pas : il se refait quand le test change.
Pour la suite
La même semaine, les tests d'une route d'indexation vérifiaient le statut d'un refus sans relire ce qui avait été écrit : un refus qui n'empêche pas.
Nous appliquons la même règle aux chiffres que nous publions sur la pertinence du moteur : une mesure que l'on peut refaire, et ce qu'elle dit contre nous. La page La mesure en donne une, sur 300 requêtes.