Un formateur transforme build:cache:hit en une note terminée par cache. Une autre entrée laisse des espaces doublés dans la sortie. Aucun test ne dit si ces comportements sont voulus. Avant de demander à l’IA de simplifier le code, il faut pouvoir détecter si ces résultats changent.
Supposons que ce formateur écrive des notes datées à partir de chaînes label:detail. Téléchargez l’exemple complet et lancez-le avec Node.js 22 ou ultérieur. L’exécution consignée utilisait 22.22.1, sans dépendance tierce.
Observer quelques entrées avant de proposer une meilleure implémentation
Décompressez le téléchargement, entrez dans characterization-tests-before-ai-refactoring et lancez :
node observe.mjs original
La commande affiche douze observations en JSON, avec l’entrée, l’instant fourni, l’environnement fourni et la sortie. Commencez par une chaîne ordinaire, puis changez un détail à la fois. Voici deux entrées de cette exécution, limitées à l’entrée et à la sortie :
[
{
"text": "build:cache:hit",
"output": "2026-10-13 | BUILD | cache"
},
{
"text": " build : ready ",
"output": "2026-10-13 | BUILD | ready"
}
]
La deuxième sortie contient deux espaces avant et après le dernier |. Gardez-les visibles pendant l’analyse. Les autres observations montrent que build et build: finissent tous deux par - ; un libellé vide utilise le NOTE_LABEL fourni, ou note si cette valeur est absente ou vide.
Ouvrez maintenant original.mjs :
export function formatNote(text, runtime = systemRuntime) {
const fields = text.trim().split(':');
const fallback = runtime.readEnv('NOTE_LABEL') || 'note';
const label = (fields[0] || fallback).toUpperCase();
const detail = fields[1] || '-';
const day = runtime.now().toISOString().slice(0, 10);
return `${day} | ${label} | ${detail}`;
}
fields[1] explique la perte de hit : les champs suivants ne sont jamais utilisés. Le premier trim() s’applique à toute l’entrée et laisse donc l’espace après build et avant ready. Le rendu ajoute ses propres espaces de séparation à côté.
Une observation donne un sujet à examiner avant de choisir une correction. Dans Unit Conversations, Michael Feathers décrit une progression similaire : appeler le code existant, examiner sa réponse, puis enregistrer les interactions utiles sous forme de tests.
Rendre la date et l’environnement explicites
Une sortie enregistrée avec la date d’aujourd’hui échouera demain. Le NOTE_LABEL d’un développeur peut aussi modifier la sortie d’un libellé vide. Ces valeurs appartiennent aux entrées du test.
L’exemple fournit déjà un petit point de remplacement : l’argument facultatif runtime. Par défaut, il lit l’horloge et la variable d’environnement nommée. Chaque commande documentée d’observation et de test fournit plutôt cette implémentation de runtime.mjs :
export function fixedRuntime({ at, env }) {
return {
now: () => new Date(at),
readEnv: (name) => env[name],
};
}
La plupart des cas utilisent 2026-10-13T12:00:00.000Z et { NOTE_LABEL: 'daily' }. L’un utilise 2026-10-13T23:30:00-07:00 ; sa sortie commence par 2026-10-14, car toISOString() formate en UTC. Les valeurs d’environnement absentes et vides ont leurs propres cas.
Ce type de point de remplacement est souvent appelé seam. Legacy Seam de Fowler décrit comment isoler une dépendance pour que les tests choisissent son comportement. Le téléchargement dispose déjà de ce paramètre. Si la dépendance est enfouie dans le code réel, introduisez le plus petit seam adapté dans une modification séparée et revue avant de réorganiser le parseur ; préservez la manière dont le chemin par défaut obtient ses valeurs.
Ici, aucun test ne modifie l’horloge globale ni l’environnement du processus. Les vérifications ont aussi passé avec TZ=Pacific/Kiritimati et un autre NOTE_LABEL dans le processus enfant, confirmant que les entrées fournies déterminent ces observations. L’interaction de l’adaptateur par défaut avec une vraie application nécessite encore sa propre vérification d’intégration.
Enregistrer les résultats, même ceux que vous questionnez
cases.mjs contient les entrées exploratoires. Après exécution et inspection des résultats originaux, nous les avons enregistrés dans characterized.json. Le fichier conserve les chaînes exactes, y compris les espaces inattendus et les champs supprimés.
Un test de caractérisation demande si le comportement choisi a changé. Il ne prouve pas que le comportement enregistré est correct. Par exemple, conserver cache dans cette fixture laisse ouverte la question de savoir si l’application devrait garder cache:hit.
run.mjs lit le JSON enregistré et y compare le résultat de chaque implémentation :
for (const item of fixtures) {
test(item.name, () => {
const actual = versions[mode](item.text, fixedRuntime(item));
assert.equal(actual, item.output);
});
}
Le lanceur n’appelle jamais le formateur original pour produire le résultat attendu pendant l’assertion. Modifier l’implémentation originale ne peut donc pas redéfinir silencieusement la réponse enregistrée. observe.mjs ne fait qu’afficher et n’écrase pas non plus la fixture.
Exécutez l’original contre cet enregistrement :
node run.mjs original
Les douze vérifications passent. Elles couvrent l’entrée ordinaire, les détails absents ou vides, les champs suivants, les espaces internes, les entrées vides, les libellés de repli fournis ou absents et la date UTC. Ce sont des entrées textuelles choisies. La casse Unicode, les dates invalides, les arguments non textuels, l’ordre des effets de bord et les appelants réels n’ont pas été caractérisés ici.
Refactoriser en conservant les résultats enregistrés
La version préservant le comportement extrait l’analyse et le rendu dans des fonctions nommées. Son parseur conserve volontairement le choix original des champs :
function parseFields(text) {
const [label, detail] = text.trim().split(':');
return { label, detail };
}
function render(day, label, detail) {
return `${day} | ${label} | ${detail}`;
}
refactored.mjs utilise ces fonctions en conservant les règles de repli, la mise en majuscules et la conversion de date UTC. Lancez :
node run.mjs refactored
Les douze observations passent et la sortie complète correspond à l’original octet pour octet. Cela étaye ce refactoring pour les entrées enregistrées, tout en laissant visibles les cas non testés.
Si vous sollicitez l’IA à ce stade, fournissez le source et les cas de caractérisation. Demandez de préserver les chaînes existantes et d’identifier toute modification susceptible de changer le comportement. « Nettoie ce code » laisse beaucoup plus de place à une décision non annoncée sur les espaces ou séparateurs.
Faire échouer visiblement un nettoyage séduisant
Le nettoyage hypothétique de accidental.mjs utilise un parseur qui peut paraître plus pratique :
function parseFields(text) {
const [label, ...rest] = text
.trim()
.split(':')
.map((field) => field.trim());
return { label, detail: rest.join(':') };
}
Il supprime les espaces de chaque champ et rejoint le reste. Exécutez les trois versions séparément :
node run.mjs original
node run.mjs refactored
node run.mjs accidental
| Version | Réussis | Échoués | Code de sortie |
|---|---|---|---|
original |
12 | 0 | 0 |
refactored |
12 | 0 | 0 |
accidental |
10 | 2 | 1, échec attendu |
Les échecs sont exactement les deux surprises observées plus tôt. build:cache:hit finit désormais par cache:hit, et l’entrée espacée produit 2026-10-13 | BUILD | ready. Aucun changement n’a été masqué en régénérant la fixture.
Utilisez node observe.mjs accidental pour inspecter les nouvelles sorties. Lancez les commandes séparément pour continuer après l’échec attendu ; les relier avec && arrête au code de sortie 1. Par défaut, node --test run.mjs vérifie seulement original.
Le résultat rouge signifie que le nettoyage a changé un comportement enregistré. Déterminer si l’une des nouvelles sorties est préférable constitue la décision suivante.
Décider séparément d’un changement de comportement
DECISION.md propose de conserver cache:hit plutôt que cache pour le cas du deux-points supplémentaire. Il consigne l’ancienne sortie et la proposition, en gardant celle-ci hors du refactoring préservant le comportement.
Dans une vraie application, inspectez les entrées représentatives et les consommateurs de cette chaîne avant d’accepter le changement. L’observation ne dit pas si un utilisateur attend la partie supprimée ou dépend de la forme courte. Si la règle est acceptée, implémentez-la dans un correctif séparé, mettez à jour l’attente avec sa justification et ajoutez les cas du comportement convenu. Gardez inchangées les règles d’espacement et de date dans ce correctif.
C’est aussi là que l’analyse d’impact d’une modification est utile : l’observation révèle une différence, les appelants et leurs tests en éclairent les conséquences. Si le correctif contient déjà des tests, examinez les assertions modifiées en même temps pour vérifier qu’elles contrôlent encore le comportement voulu.
Utiliser les observations pour guider la prochaine lecture
Si vous ne trouvez pas encore la source de la valeur formatée, commencez par suivre une valeur dans le code inconnu. Une fois le formateur localisé, gardez ensemble son implémentation actuelle, les cas enregistrés et le refactoring proposé.
Une consigne IA facultative :
Explique comment ce formateur produit chaque sortie enregistrée. Indique le code des champs supprimés, espaces conservés, libellés de repli et date. Propose un refactoring préservant ces observations et liste séparément les changements de comportement envisagés. Ne déduis pas qu’une sortie enregistrée est une exigence.