Tout ingénieur se méfie d'un build rouge. L'artefact dangereux est le vert, parce que le vert transporte une affirmation implicite : ceci a été mesuré, et cela a tenu. Cette affirmation a deux modes de défaillance et un seul est célèbre. Un test peut se tromper sur le système. Un test peut aussi avoir raison sur le néant : pointé vers un artefact que personne ne livre, un fichier que personne ne charge, une promesse que personne ne lance. Il passe pour toujours, et il ne protège rien.
Nous avons passé une saison à collectionner des spécimens du second type, dans nos propres dépôts, là où une telle collection doit commencer. Cinq d'entre eux, avec le code, et l'habitude qui aurait attrapé chacun plus tôt.
Spécimen un : la suite qui vérifiait un fantôme
En migrant un site entre deux moteurs de rendu, notre suite lisait le répertoire de sortie publié et y vérifiait des centaines de propriétés :
SWA = ROOT / "swa" # the directory the deploy uploads
def test_every_route_declares_its_canonical():
for page in SWA.rglob("index.html"):
assert '<link rel="canonical"' in page.read_text()Vert pendant des semaines. Quatre cent vingt routes vérifiées. Le problème : swa/ était toujours écrit par l'ancien moteur. Le nouveau, qui était tout l'objet du projet, écrivait dans .build/astro/, et aucun test n'ouvrait ce dossier. Nous vérifiions l'artefact que nous remplacions.
Le remède ne fut pas plus d'assertions. Ce fut une question posée à la suite entière, quels octets ceci ouvre-t-il, réellement ?, à laquelle on répond en faisant composer d'abord à la suite l'artefact publiable et en ajoutant un test dont le seul travail est d'échouer si le répertoire publié n'est pas cette composition :
def test_the_published_artefact_is_the_composition():
# `data-page-owner` is written by the new renderer and nothing else.
# If no published page carries it, swa/ is still the old site.
owned = [p for p in SWA.rglob("*.html") if "data-page-owner" in p.read_text()]
assert owned, "the published artefact is the old renderer; the composition never shipped"Ennuyeux, structurel, et le test le plus précieux du dépôt, parce qu'il valide la seule relation que tous les autres supposent en silence.
Spécimen deux : la porte qui lisait le texte, pas le comportement
Notre collecteur de preuves porte une promesse dure : lecture seule. Elle est imposée en parsant chaque fichier PowerShell et en parcourant l'arbre syntaxique à la recherche de verbes mutateurs :
def test_no_powershell_file_has_a_write_path():
for f in COLLECTOR.rglob("*.ps1"):
ast = parse_powershell(f.read_text())
verbs = [c.command_name for c in ast.find_all(CommandAst)]
assert not [v for v in verbs if v.split("-")[0] in MUTATING], fUne bonne porte. Aussi une porte sur du texte. Une version nous a appris la différence : un Import-Module -Force imbriqué déchargeait un helper partagé de la session appelante, et le collecteur ne pouvait plus s'exécuter du tout, sur aucun tenant et dans aucun mode :
# The defect. -Force on a nested import re-imports the module fresh,
# evicting the already-loaded copy from the caller's session.
Import-Module (Join-Path $PSScriptRoot 'Evidence.psm1') -Force # Initialize-Evidence now gone upstreamToutes les portes textuelles restaient vertes, car le texte était impeccable. Le programme que le texte décrivait était cassé. Le correctif fut une seconde garde qui charge les modules exactement comme le collecteur les charge et demande à la session vivante ce qui a survécu :
Describe 'the modules load in the collector''s own order' {
It 'keeps Initialize-Evidence reachable after the siblings import' {
Import-Module ./modules/Evidence.psm1
Import-Module ./modules/Sharing.psm1 # must not evict the above
Get-Command Initialize-Evidence -ErrorAction Stop | Should -Not -BeNullOrEmpty
}
}La leçon dépasse PowerShell : pour chaque propriété imposée statiquement, demandez ce qui arrive si le code passe la vérification sans faire la chose. Si rien ne s'en apercevait, votre porte prouve l'analysabilité, pas le comportement, et elle devrait le dire tout haut plutôt qu'emprunter l'autorité de l'affirmation plus forte.
Spécimen trois : le tableau publié qui ne tournait jamais
Notre moteur documente sa façon de raisonner sur des preuves incomplètes : un tableau d'opérateurs et de bornes, quel côté peut décider, quel côté ne le peut jamais :
operator lower bound proves what it cannot prove
> (min) pass fail (needs an upper bound)
not-contains fail on known part pass (needs the whole set)
== nothing decides only when single and fully observedIl était dans le document d'architecture, cité, poli, et, comme l'a révélé un rapport de couverture, à peine exécuté par un test. Vrai le jour de sa rédaction et sans garde à jamais ; n'importe quel refactor pouvait inverser une ligne en silence et le document aurait continué à promettre l'ancien comportement d'un air sérieux. Le tableau est donc devenu exécutable, un cas par cellule, généré de la même source que le document affiche :
@pytest.mark.parametrize("operator,bound,expected", CASES_FROM_THE_TABLE)
def test_each_cell_of_the_published_table(operator, bound, expected):
assert decide(operator, bound) is expected
def test_a_bound_of_none_decides_nothing():
assert decide(">", bound=None) is Outcome.UNKNOWNUne garantie publiée, ou bien elle tourne en CI, ou bien c'est un espoir avec de la typographie.
Spécimen quatre : la fixture qui flattait le test
Le plus silencieux. Un test évalue une règle et attend unknown ; il passe :
def test_denied_sharing_run_is_all_unknown():
doc = load("coverage-denied-sharing.json")
assert doc.counts.unknown == 13 # ← where does 13 come from?La fixture avait été générée quand un défaut de sélection faisait tourner toutes les règles du moteur au lieu des deux du profil. Le 13 a été copié de cette sortie cassée. Ce n'était pas une spécification ; c'était le fossile d'un défaut, maintenu en vie par un répertoire de fixtures que rien ne rafraîchissait. Nous ne l'avons attrapé que lorsque les fixtures ont été régénérées depuis le moteur actuel et que le fossile a contredit la réalité : le profil sélectionne deux règles, donc le compte honnête était trois, pas treize.
L'habitude qui l'attrape plus tôt est un commentaire :
# Three, because `--profile sharing` selects three rules since SPO-SHARE-005.
# It read thirteen for as long as a bare profile name failed open to every
# rule on disk, and this fixture was generated then.
assert doc.counts.unknown == 3Un nombre épinglé que vous ne savez pas sourcer est un nombre copié de ce que le système faisait le jour où vous avez écrit le test, et le système ce jour-là pouvait avoir tort.
Spécimen cinq : les tests qui prouvaient les règles et jamais le câblage
Le plus grand, et le plus proche de nous. Soixante-cinq tests, tous verts, couvraient une API règle par règle : validation, limites de débit, le plafond de la modération, la forme d'un token. Aucun n'ouvrait le socket qu'ouvre une requête. Chaque test exerçait un helper pur ; aucun ne pilotait le handler HTTP qui assemble ces helpers en une réponse. Les règles étaient prouvées. Le câblage entre elles était supposé.
Derrière ce vert se tenait un mécanisme qui ne pouvait pas fonctionner en production. Une édition de la newsletter enregistre qui l'a déjà reçue, pour qu'une reprise n'envoie qu'à ceux qui manquaient. Le marqueur va dans une table nommée newssent :
// The whole idempotency of a send rests on this row, and nothing creates the
// table it lives in.
await marker().upsertEntity(
{ partitionKey: editionId, rowKey: key(subscriber.email) },
"Replace",
);La production n'avait pas de table newssent. Le premier envoi expédiait l'édition, échouait à écrire le marqueur dans une table qui n'existait pas, et signalait le destinataire comme un échec. Pire, la garde qui lit le marqueur traite une table absente comme « pas encore envoyé », donc chaque reprise réexpédiait la liste entière. L'idempotence était morte à la naissance, et chaque test restait vert, parce que le double en mémoire que chaque test utilisait répondait au createTable par un haussement d'épaules et à l'upsert par un succès. Un faux ne peut pas ne pas exister.
Ce qui l'a mesuré n'a pas été une assertion de plus. C'était le même handler, piloté contre un vrai Table Storage tournant en local, la ligne persistée relue :
// Real handler, real Table Storage, real OData. Only a table that can truly be
// absent exposes the missing create; only a real query proves the filter.
const res = await handler("newsletter-send")(admin(edition), ctx());
const written = await readAll(sent); // read the state back, do not trust the return
assert.equal(written.length, res.sent); // "sent" must imply "recorded"Le correctif fut une ligne que les autres magasins avaient déjà, un createTable défensif avant la boucle. Le défaut n'est pas le sujet. Le sujet est qu'une suite peut prouver toutes les règles qu'un système possède et ne jamais prouver que le système tourne, et l'écart reste invisible jusqu'à ce qu'un test ouvre le même socket, et le même stockage, que la production ouvrira.
Une garantie mérite d'être nommée ici, car la nommer, c'est la moitié du travail. La remise d'un message confirmé dans la boîte, et d'une édition de la newsletter, est une remise au moins une fois (at-least-once) : un crash entre l'envoi et son enregistrement répète l'envoi à la reprise, il ne le laisse jamais tomber. C'est un choix. Un doublon est une gêne ; un message perdu est un client. Le test n'affirme pas « exactement une fois », ce qui serait un mensonge ; il affirme qu'après un crash à chaque point, le travail reste récupérable, et que rien n'est sauté en silence.
Le vert est une affirmation, pas un état
Chaque test qui passe affirme une relation entre trois choses : le code, l'artefact sous test, et le monde que l'artefact va rencontrer. Chaque spécimen a cassé une patte différente. La suite mesurait le mauvais artefact. La porte mesurait du texte au lieu du comportement. Le document promettait ce que rien n'exécutait. La fixture a figé un monde cassé et l'a nommé attendu. La dernière suite prouvait toutes les règles et ne les lançait jamais ensemble.
Le remède est une question sous cinq déguisements, et elle vaut d'être posée aujourd'hui à votre suite la plus verte : si ceci cessait d'être vrai, quelque chose ici passerait-il au rouge ? Prenez votre tableau de bord le plus rassurant, votre badge CI le plus stable, et suivez une lumière verte jusqu'aux octets qu'elle ouvre. Si la piste se termine sur un artefact que personne ne livre, une promesse que personne ne lance ou un nombre sans source, la lumière ne mesure pas le système.
Elle le décore.