Le point de départ : un parcours qui marche, et qu’il faut quand même remplacer
En septembre 2026, une application web de commande en ligne tourne sur le cluster. Côté serveur : Python, FastAPI, SQLite. Côté visiteur : un parcours en sept étapes, rendu par des gabarits Jinja, qui fonctionne sans une ligne de JavaScript. Chaque étape est un formulaire, chaque envoi un POST suivi d’un 303 vers l’étape suivante — ou vers la même, avec des phrases rouges à corriger.
Ce parcours a des qualités : il est testé (plus de 1 700 tests côté Python), il marche sur n’importe quel navigateur, il ne casse pas quand un script tiers tombe. Il a aussi une limite : chaque correction recharge la page, l’envoi de photos est un formulaire multipart brut, et l’expérience qu’on veut offrir à l’étape suivante du produit ne se fait pas en HTML pur. Le 13 septembre, la décision est prise : le parcours passe à React. La promesse « aucun JavaScript » est abandonnée, consciemment.
Le risque n’est pas technique au sens « React est difficile ». Il est comportemental. Le parcours Jinja contient des centaines de petites décisions : quel champ est désigné fautif, quelle phrase s’affiche quand un quota est atteint, quand un lien de reprise est envoyé par e-mail, ce qui est écrit en base à chaque étape. Réécrire l’interface, c’est réécrire ces décisions — et en perdre quelques-unes en silence.
Le plan : un étrangleur à interrupteur, qui disparaît à la fin
Le motif est connu sous le nom de strangler fig : on fait pousser la nouvelle implémentation à côté de l’ancienne, on déplace le trafic, puis on coupe l’ancienne. La version retenue ici tient en quatorze tâches et en une phrase : les règles descendent dans un service, les deux façades l’appellent, un harnais de parité garde la porte de la bascule, et l’interrupteur disparaît à la fin au lieu de rester.
règles hors des routesrules out of the routes
Jinja l’appelle, rien ne bouge à l’écranJinja calls it, nothing moves on screen
interrupteur, défaut falseswitch, default false
la coquille React est montée, invisiblethe React shell is mounted, invisible
Observation(jinja) == Observation(json)Observation(jinja) == Observation(json)
deux clients neufs, deux basestwo fresh clients, two databases
un commitone commit
gabarits, routes et interrupteur retiréstemplates, routes and switch removed
ATTENDUS = mesuresEXPECTED = measurements
la comparaison devient un inventairethe comparison becomes an inventory
Trois choix structurent tout le reste.
- Les règles quittent les routes. Avant d’écrire un composant React, la logique de chaque étape (validation, quotas, création du brouillon, envoi d’e-mail) est extraite dans un module de service. Les routes Jinja l’appellent ; visuellement, rien ne change. C’est la condition pour que les deux façades partagent les mêmes décisions au lieu d’en avoir deux copies.
- La façade JSON est montée derrière un interrupteur, désactivé par défaut. Le 21 septembre, la coquille React compilée est servie sous le même préfixe d’URL que le parcours Jinja, montée avant le routeur Jinja pour gagner quand l’interrupteur est actif. Défaut
false: la transition est invisible en production, et aucun manifeste Kubernetes n’a besoin de connaître ce réglage. - L’interrupteur est temporaire par contrat. Un interrupteur qui reste devient une deuxième application à maintenir. Il est écrit dès le départ comme une chose à retirer, au même commit que l’ancien rendu.
Le harnais : comparer ce que l’utilisateur constate, pas ce que le serveur renvoie
La première idée naïve serait de comparer les réponses. Elle ne tient pas une minute : d’un côté un 303 puis une page HTML, de l’autre un corps JSON unique. Comparer du HTML à du JSON, c’est comparer du décor.
Le harnais compare donc une Observation : une structure normalisée de ce qu’un utilisateur constate après un geste, produite à l’identique par deux pilotes, l’un qui poste des formulaires comme un navigateur sans JavaScript, l’autre qui poste du JSON comme l’application React. En version légèrement simplifiée :
@dataclass(frozen=True)
class Observation:
"""Ce qu'un utilisateur constate, indépendamment du transport."""
etape: str # où il se retrouve
statut: str # "avance" | "arrete" | "commande"
champs: tuple[str, ...] # les champs désignés fautifs, triés
messages: tuple[str, ...] # les phrases rouges, triées
avis: tuple[str, ...] # les avis (détour, retour, lien envoyé), triés
slug: str # la commande créée, sinon ""
brouillons: int # lignes créées ou supprimées par ce POST
commandes: int
courriels: int # e-mails réellement partis
credits: tuple[tuple[str, int], ...] # crédits de quota consommés
Ce qu’elle laisse tomber est aussi important que ce qu’elle garde. Le code HTTP n’y est pas : un champ piège rempli par un robot donne un 400 avec le formulaire réaffiché d’un côté, un 403 de l’autre. C’est le même refus vu par deux transports, avec la même phrase, sans rien créer. L’encoder ferait rougir le harnais sur du décor et le rendrait inutilisable comme porte de bascule.
Chaque scénario (« un geste », dans le vocabulaire du harnais) se joue deux fois, sur deux clients neufs avec chacun sa propre base SQLite. Ce détail a coûté une fausse piste : partager la base faisait refuser la seconde façade par le crédit de quota que la première venait de consommer. Le harnais accusait alors les façades d’une divergence causée par son propre montage.
Le 22 septembre, le harnais compte 27 scénarios. Son premier constat est un vrai défaut, pas un faux positif : la page de refus pour quota dépassé disait « Rien n’est perdu… » deux fois côté Jinja, une seule côté JSON. La fonction de rendu Jinja enrichissait un texte que le service avait déjà enrichi. Correction : enrichir est le travail du service, la page ne fait plus qu’afficher. Sans le harnais, cette phrase dupliquée aurait disparu à la bascule sans que personne sache pourquoi elle avait existé.
Ce que la parité ne peut pas porter : geler à part
Certaines propriétés n’ont pas d’équivalent des deux côtés. Elles sont gelées à part, sur la façade JSON seule, dans le même fichier — parce que ce fichier est la porte :
- Les codes du contrat JSON (
422à corriger,429quota,409parcours perdu,403refusé, puis502quand le prestataire de paiement est indisponible). Le client React s’en sert pour choisir son écran ; ils ne peuvent pas entrer dans l’Observation, ils sont donc figés par un test dédié. - Les écarts connus. Le titre d’un refus est rendu en
<h1>par Jinja et n’est transmis par aucun champ JSON. Le comportement est le même, l’information affichée non. Plutôt que de le découvrir après la bascule, un test le déclare.
Un cas plus subtil est apparu à la relecture : un GET sur une étape qu’on n’a pas encore atteinte est servi par les deux façades. Faut-il poser une garde « anti-saut » ? La réponse retenue est non — pas pendant cette phase. La parité est la loi : poser une garde d’un seul côté serait exactement la divergence que la phase existe pour empêcher. Un GET n’écrit rien, et chaque POST valide ses propres préalables ; sauter en avant ne montre qu’un formulaire vide que l’envoi refusera. Le test ajouté prouve la parité, pas une garde : le jour où une garde sera décidée, il exigera qu’elle soit posée des deux côtés.
Chaque contrôle est vérifié par une expérience de morsure : on casse volontairement le comportement côté JSON seul et on regarde le test rougir. Pour la garde anti-saut, le rouge ressemble à ceci :
AssertionError: ('apercu-texte', 2) != ('details', 6)
Un test qui ne mord pas ne garde rien.
La bascule : un commit, et aucune URL ne change
Avant de retirer quoi que ce soit, une dernière étape : les départs des scénarios (« placer l’utilisateur à l’étape 5 ») passent eux aussi par l’API JSON. Le test bout en bout est réécrit contre l’API au lieu d’être supprimé : il observe l’étape courante à chaque pas au lieu de suivre un 303. Résultat : 1 732 tests verts, zéro ignoré, et la preuve que la couverture comportementale survit à la disparition de la façade Jinja.
Puis, le 22 septembre à 13 h 07, la bascule du parcours tient en un commit : les sept étapes servies par React, neuf gabarits et quatorze routes retirés, l’interrupteur supprimé. Aucun manifeste d’infrastructure n’a bougé — c’était tout l’intérêt du défaut false, et une recherche dans le dépôt GitOps confirme que le réglage n’y était cité nulle part.
La bascule a failli perdre deux comportements en silence, rattrapés parce que le harnais les décrivait : un témoin de session illisible devait être ignoré et effacé (le rendu Jinja le faisait au passage), et revenir à la première étape d’un parcours existant devait proposer le lien de reprise — sans quoi l’écran n’avait rien à afficher.
Le même schéma est rejoué le lendemain pour les pages d’après-commande (aperçu, paiement, correction, retours du prestataire) : façade JSON à quatre points d’entrée, harnais de parité dédié, bascule le 23 septembre à 15 h 44. Onze routes Jinja et quatre gabarits partent ; aucune URL ne change, ni celles qu’on voit dans le navigateur, ni celles des e-mails déjà envoyés, ni les URL de retour transmises au prestataire de paiement. Les 92 tests qui lisaient du HTML sont réécrits pour asserter l’état structuré, pas supprimés (93 après réécriture). Le soir même, une fois la fenêtre de déploiement passée — plus aucun navigateur n’affiche d’ancien formulaire —, les trois routes POST de transition sont retirées à leur tour.
Après la bascule : de la comparaison à l’inventaire
Une fois l’ancienne façade partie, il n’y a plus rien à comparer. Le harnais ne disparaît pas pour autant : il mue. Le comparateur devient un inventaire, et les observations attendues sont celles mesurées sur le commit précédent, quand la façade Jinja était encore en place et le test d’égalité encore vert.
# Gelé sur le commit d'avant la bascule, quand les deux façades donnaient la même observation
ATTENDUS: dict[str, ObservationCommande] = {
"payer-prestataire-indisponible": _observation(...),
# … 24 observations mesurées, pas écrites à la main
}
# (simplifié) chaque scénario rejoué sur la façade restante doit retrouver sa mesure
def test_le_comportement_est_celui_mesure(scenario):
assert observer_json(scenario) == ATTENDUS[scenario.nom]
La nuance compte. Des valeurs attendues écrites à la main décrivent ce qu’on croit que l’application fait. Des valeurs mesurées sur l’ancienne implémentation décrivent ce qu’elle faisait, défauts compris. Toute dérive rougit désormais avec le nom du scénario.
Le gel a lui-même trouvé deux trous : un scénario « phrase trop courte » postait exactement trois caractères, soit le minimum autorisé (il ne testait rien), et les deux scénarios de la protection anti-robot faisaient échouer leur propre départ. Corrigés, ils mordent.
| Avant | Après la bascule du parcours | |
|---|---|---|
| Façades | Jinja + JSON | JSON seule |
| Scénarios de parité | 27 | 34 + 3 reprises, gelés |
| Suite Python | 1 732 verts | 1 693 verts, 0 ignoré |
| Tests front (Vitest) | — | 83 |
| Bundle front | — | 136 943 / 200 000 octets gz |
La baisse de 1 732 à 1 693 tests n’est pas une perte de couverture : chaque test retiré gardait une route qui n’existe plus, et chaque retrait est justifié dans le rapport de la tâche.
Les leçons : ce qui se réutilise ailleurs
- Comparer des observations, pas des réponses. Définir une structure « ce que l’utilisateur constate » (étape, champs fautifs, phrases, effets en base) que les deux implémentations produisent. Le transport (codes, redirections, enveloppe) reste dehors, avec un commentaire qui dit pourquoi.
- Isoler chaque passage. Deux clients neufs, deux bases. Un état partagé fabrique de fausses divergences, et un harnais qui accuse à tort cesse vite d’être écouté.
- Geler à part ce que la parité ne voit pas. Les codes du contrat, les écarts connus : un test déclaré vaut mieux qu’une surprise découverte en production.
- Ne rien corriger d’un seul côté pendant la transition. Même une bonne idée (une garde anti-saut) attend la fin : sinon la parité n’est plus mesurable.
- Mordre chaque test. Casser volontairement le comportement et voir le rouge. C’est la seule preuve qu’un contrôle garde quelque chose.
- Un interrupteur qui disparaît. Défaut
false, retiré au commit de la bascule, avec les gabarits et les routes. Rien à nettoyer dans l’infrastructure. - Geler les mesures, pas les intentions. Après la bascule, les valeurs attendues sont celles observées sur l’ancienne implémentation, pas celles qu’on imagine.
La limite est honnête : ce harnais prouve que les deux façades se comportent pareil, pas que la nouvelle interface est agréable à utiliser. Le rendu React lui-même est couvert par ses propres tests et par une relecture à l’écran. Mais pour la question « qu’est-ce qu’on a perdu en route ? », la réponse tient dans un fichier de test — et elle est vide.