Tests en pratique - 06 - Tester du legacy sans tout casser

Ajouter des tests a du code existant sans tests. Approval testing, Golden Master et l'approche incrementale.

06 - Tester du legacy sans tout casser

Ce que tu vas apprendre

  • La définition de "legacy code" selon Michael Feathers
  • L'approval testing (Golden Master) pour figer le comportement existant
  • Les tests de caracterisation pour documenter le code
  • L'approche incrementale pour ajouter des tests sans bloquer le dev

Prerequisites

Savoir écrire des tests avec bun test. Comprendre les snapshots (voir l'article sur les snapshot tests).


Du code sans tests, c'est du legacy

Michael Feathers dans "Working Effectively with Legacy Code" donne une définition qui fait mal : du legacy code, c'est du code sans tests. Pas du vieux code. Pas du code mal écrit. Du code sans tests.

Par cette définition, la plupart des projets que j'ai rejoints etaient du legacy. Y compris des projets lances 6 mois plus tot avec les technos les plus recentes. Etre en TypeScript dernier cri ne change rien si tu n'as aucun filet de sécurité pour refactoriser.

Le problème c'est un cercle vicieux. Tu ne peux pas refactoriser sans tests (trop risque). Tu ne peux pas ajouter des tests sans comprendre le code. Tu ne peux pas comprendre le code sans le refactoriser (trop confus). Par ou commencer ?

Approval testing : le Golden Master

L'idee est elegante. Tu ne comprends pas ce que le code fait ? Pas grave. Lance-le, capture la sortie, et fige-la comme référencé. Tout changement futur qui modifie la sortie declenchera un échec.

Sur paltemps.fr, on avait un endpoint legacy qui generait un export CSV des missions. Personne ne savait exactement quelles colonnes il retournait, dans quel ordre, avec quel formatage. Mais il marchait. Voici comment on l'a protégé :

typescriptimport { describe, it, expect } from "bun:test";
import { app } from "../src/app";

it("legacy CSV export returns expected output", async () => {
  // Seed des donnees connues
  await seedTestMissions();

  const res = await app.handle(
    new Request("http://localhost/api/export/missions?format=csv")
  );
  const csv = await res.text();

  // Le snapshot fige la sortie actuelle
  expect(csv).toMatchSnapshot();
});

La première fois que tu lances ce test, Bun créé un fichier snapshot avec la sortie CSV. Les fois suivantes, il compare. Si le CSV change (colonnes en plus, format différent, ordre modifie), le test echoue.

Tu n'as pas eu besoin de comprendre le code de génération CSV. Tu as juste fige son comportement. Maintenant tu peux refactoriser le code interne en toute sécurité : tant que la sortie reste identique, le test passe.

Tests de caracterisation

Un test de caracterisation documente ce que le code fait réellement, pas ce qu'il devrait faire. La nuance est importante. Si le code a un bug, le test de caracterisation capture le bug comme comportement attendu.

La technique :

  1. Appelle la fonction avec des entrees connues
  2. Regarde ce qu'elle retourne
  3. Ecris un expect avec la valeur observee
typescript// Je ne sais pas ce que cette fonction fait exactement
// Mais je vais documenter son comportement actuel

describe("calculateLegacyCommission", () => {
  it("returns 0.15 for amount 1000", () => {
    // J'ai lance la fonction, elle retourne 0.15
    expect(calculateLegacyCommission(1000)).toBe(0.15);
  });

  it("returns 0.10 for amount 500", () => {
    expect(calculateLegacyCommission(500)).toBe(0.10);
  });

  it("returns 0 for amount 0", () => {
    expect(calculateLegacyCommission(0)).toBe(0);
  });

  it("returns 0.20 for amount 5000", () => {
    expect(calculateLegacyCommission(5000)).toBe(0.20);
  });
});

Apres avoir écrit ces tests, je comprends mieux la fonction. Il y a des paliers de commission. Je peux maintenant la refactoriser en toute sécurité, peut-etre la rendre plus lisible, extraire les paliers dans une constante.

L'approche incrementale

Ne teste pas tout le legacy d'un coup. C'est impossible et demotivant. Voici l'approche qui fonctionne en pratique :

Étape 1 : Tu dois modifier un bout de code legacy. Avant de toucher quoi que ce soit, ajoute un test de caracterisation sur la partie que tu vas changer.

Étape 2 : Refactorise cette partie. Le test te protégé. Si ca casse, tu le sais immédiatement.

Étape 3 : Ajoute de vrais tests (pas juste des snapshots) pour le nouveau code. Des tests qui expriment l'intention, pas juste la sortie actuelle.

Étape 4 : Passe au prochain morceau. Repete.

typescript// Etape 1 : test de caracterisation avant de toucher au code
it("returns missions sorted by date", async () => {
  await seedTestMissions();
  const res = await app.handle(new Request("http://localhost/api/missions"));
  const data = await res.json();
  expect(data).toMatchSnapshot();
});

// Etape 3 : apres refactoring, test qui exprime l'intention
it("returns missions sorted by startDate ascending", async () => {
  const m1 = await createMission({ startDate: "2026-04-01" });
  const m2 = await createMission({ startDate: "2026-03-15" });
  const m3 = await createMission({ startDate: "2026-04-10" });

  const res = await app.handle(new Request("http://localhost/api/missions"));
  const data = await res.json();

  expect(data.missions[0].id).toBe(m2.id);
  expect(data.missions[1].id).toBe(m1.id);
  expect(data.missions[2].id).toBe(m3.id);
});

Le test de caracterisation peut etre supprime une fois que tu as un vrai test. Il a rempli son rôle de filet de sécurité temporaire.

Le Strangler Fig pattern pour les tests

Le Strangler Fig est un pattern d'architecture ou tu remplaces progressivement un vieux système par un nouveau, en routant graduellement le trafic. Le meme principe s'applique aux tests.

Au lieu de tester le legacy de l'intérieur (ce qui est souvent impossible a cause du couplage), teste-le de l'extérieur. Par les endpoints HTTP, par les sorties observables.

typescript// Test "exterieur" : je ne connais pas les internals
// Mais je sais que POST /api/missions doit creer une mission
it("creates a mission and returns it", async () => {
  const res = await app.handle(
    new Request("http://localhost/api/missions", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        title: "Test legacy",
        startDate: "2026-04-01",
      }),
    })
  );

  expect(res.status).toBe(201);
  const mission = await res.json();
  expect(mission.title).toBe("Test legacy");

  // Verifier que c'est bien persiste
  const getRes = await app.handle(
    new Request(`http://localhost/api/missions/${mission.id}`)
  );
  expect(getRes.status).toBe(200);
});

Ces tests de l'extérieur sont resilients aux refactorings internes. Tu peux changer la base de donnees, le pattern d'acces, la structure du code. Tant que le contrat HTTP est respecte, les tests passent.

Progressivement, a mesure que tu refactorises les internals et que tu as des modules propres avec une architecture hexagonale, tu peux ajouter des tests unitaires sur le domaine et des tests d'intégration sur les adaptateurs.

Les erreurs a éviter

Ne reecris pas tout d'un coup. J'ai vu des équipes se lancer dans un "grand refactoring" qui a dure 3 mois et a introduit plus de bugs qu'il n'en a corrige. L'approche incrementale est plus lente mais infiniment plus sure.

Ne mock pas le legacy. Si tu mock les internals d'un code que tu ne comprends pas, tes mocks refletent ce que tu crois que le code fait, pas ce qu'il fait réellement. Teste de l'extérieur.

Ne confonds pas test de caracterisation et test de spécification. Le test de caracterisation dit "ca fait ca". Le test de spécification dit "ca doit faire ca". Commence par le premier, evolue vers le second.


Article précédent : 05 - TDD vs BDD vs test-after

Article suivant : 07 - Organiser ses tests dans un projet

Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.