Clean code et refactoring - 16 - Tests comme filet de sécurité pour le refactoring

Refactoriser sans tests, c'est jouer a la roulette russe. Voici comment écrire les bons tests avant de toucher au code.

  1. 01 Clean code et refactoring - 00 - Pourquoi le clean code est un investissement, pas un luxe
  2. 02 Clean code et refactoring - 01 - Nommage : la competence la plus sous-estimee
  3. 03 Clean code et refactoring - 02 - Fonctions : courtes, claires, responsables
  4. 04 Clean code et refactoring - 03 - Conditions et lisibilité : sortir de la pyramide
  5. 05 Clean code et refactoring - 04 - Commentaires et documentation : quand le code ne suffit pas
  6. 06 Clean code et refactoring - 05 - Immutabilite et effets de bord : moins de surprises, moins de bugs
  7. 07 Clean code et refactoring - 06 - Gestion des erreurs propre : fail fast, fail loud
  8. 08 Clean code et refactoring - 07 - Programmation defensive vs offensive : valider aux frontieres, faire confiance a l'intérieur
  9. 09 Clean code et refactoring - 08 - SOLID en pratique avec TypeScript
  10. 10 Clean code et refactoring - 09 - DRY, KISS, YAGNI
  11. 11 Clean code et refactoring - 10 - Couplage et cohesion
  12. 12 Clean code et refactoring - 11 - Complexite cyclomatique
  13. 13 Clean code et refactoring - 12 - Abstractions prematurees vs tardives
  14. 14 Clean code et refactoring - 13 - Code smells
  15. 15 Clean code et refactoring - 14 - Techniques de refactoring
  16. 16 Clean code et refactoring - 15 - Refactoring legacy sans tout casser
  17. 17 Clean code et refactoring - 16 - Tests comme filet de sécurité pour le refactoring
  18. 18 Clean code et refactoring - 17 - Structurer un projet — feature-based vs layer-based
  19. 19 Clean code et refactoring - 18 - Constantes, configuration et magic numbers
  20. 20 Clean code et refactoring - 19 - Linting et formatting — ESLint, Biome, automatiser la qualité
  21. 21 Clean code et refactoring - 20 - Conventions d'équipe et ADR
  22. 22 Clean code et refactoring - 21 - Dette technique — quand elle est acceptable, quand elle tue le projet
  23. 23 Clean code et refactoring - 22 - Code review — donner et recevoir du feedback
  24. 24 Clean code et refactoring - 23 - Glossaire — tous les termes de la serie

16 - Tests comme filet de sécurité pour le refactoring

Ce que tu vas apprendre

  • Pourquoi refactoriser sans tests est un pari dangereux
  • Ce que sont les characterization tests et quand les utiliser
  • Le cycle red-green-refactor en pratique
  • Quels tests écrire avant de refactoriser du legacy
  • Pourquoi la couverture de tests est un indicateur, pas un objectif
  • Quand les tests bloquent le refactoring au lieu de l'aider

Prerequisites

Article précédent : 15 - Refactoring legacy


L'annee dernière, un collegue a refactorise un module de calcul de prix. Il avait change la structure des fonctions, renomme des variables, reorganise la logique. Le code etait plus lisible. Le problème : les prix affiches etaient faux pour les clients avec des remises en cascade. Personne ne s'en est rendu compte pendant deux semaines. Il n'y avait aucun test sur ce module.

Refactoriser sans tests, c'est comme faire de l'escalade sans corde. Tant que tout va bien, tu ne vois pas le problème. Quand ca part en vrille, il est trop tard.

Pourquoi les tests sont non-negociables avant un refactoring

Un refactoring, par définition, ne change pas le comportement observable du code. Il change la structure interne. Mais comment vérifier que le comportement n'a pas change si tu n'as aucun moyen de le mesurer ?

Le compilateur TypeScript attrape les erreurs de type. Mais il ne détecté pas les bugs logiques. Si tu changes l'ordre de deux conditions dans un if, TypeScript ne dira rien. Tes utilisateurs, eux, verront la différence.

Les tests sont le seul mecanisme fiable pour vérifier que ton refactoring n'a rien casse. C'est un fait, pas une opinion.

Les characterization tests : tester ce qui existe

Quand tu arrives sur du code legacy sans tests, tu ne sais pas toujours ce que le code est cense faire. Tu sais juste ce qu'il fait. C'est la que les characterization tests entrent en jeu.

Le principe est simple : tu ecris des tests qui capturent le comportement actuel du code. Pas le comportement souhaite. Le comportement réel.

typescript// Module legacy sans tests - calcul de remise
function calculateDiscount(price: number, customerType: string, quantity: number): number {
  let discount = 0;
  if (customerType === "premium") {
    discount = 0.15;
  }
  if (quantity > 100) {
    discount += 0.05;
  }
  if (quantity > 100 && customerType === "premium") {
    discount = 0.25; // Bug subtil : ecrase au lieu d'additionner
  }
  return price * (1 - discount);
}

// Characterization tests : on capture le comportement ACTUEL
describe("calculateDiscount - characterization", () => {
  it("retourne le prix sans remise pour un client standard", () => {
    expect(calculateDiscount(100, "standard", 10)).toBe(100);
  });

  it("applique 15% pour un client premium", () => {
    expect(calculateDiscount(100, "premium", 10)).toBe(85);
  });

  it("applique 5% pour quantite > 100", () => {
    expect(calculateDiscount(100, "standard", 150)).toBe(95);
  });

  it("applique 25% pour premium + quantite > 100", () => {
    // C'est peut-etre un bug, mais c'est le comportement actuel
    expect(calculateDiscount(100, "premium", 150)).toBe(75);
  });
});

Le troisieme if est probablement un bug (il devrait additionner, pas ecraser). Mais le characterization test capture ce comportement tel quel. Tu decides ensuite, avec le product owner, si c'est un bug a corriger ou un comportement voulu.

Red-green-refactor : le cycle qui protégé

Le cycle TDD classique s'applique directement au refactoring :

  1. Red : ecris un test qui echoue (ou un characterization test qui passe)
  2. Green : fais passer le test avec le code existant
  3. Refactor : modifie la structure sans casser les tests

En pratique, pour du refactoring de code existant, tu commences souvent par l'étape 2 : écrire des tests qui passent avec le code actuel. Ensuite tu refactorises.

typescript// Etape 1 : les tests passent avec le code original
// Etape 2 : refactoring
type CustomerType = "premium" | "standard";

interface DiscountRule {
  applies: (customerType: CustomerType, quantity: number) => boolean;
  discount: number;
}

const discountRules: DiscountRule[] = [
  { applies: (type, qty) => type === "premium" && qty > 100, discount: 0.25 },
  { applies: (type) => type === "premium", discount: 0.15 },
  { applies: (_, qty) => qty > 100, discount: 0.05 },
];

function calculateDiscount(price: number, customerType: CustomerType, quantity: number): number {
  const rule = discountRules.find((r) => r.applies(customerType, quantity));
  const discount = rule?.discount ?? 0;
  return price * (1 - discount);
}
// Etape 3 : relancer les tests - ils doivent toujours passer

Apres ce refactoring, les quatre characterization tests passent toujours. Le comportement est identique, meme le "bug" du remplacement au lieu de l'addition. La structure est meilleure, et les regles de remise sont maintenant configurables.

Intégration tests > unit tests pour le legacy

Quand tu prepares un refactoring sur du legacy, commence par les tests d'intégration. Pas les tests unitaires. Ca semble contre-intuitif, mais il y a une bonne raison.

Les tests unitaires sont couples a l'implementation. Si tu testes chaque fonction interne séparément, tu devras reecrire tous tes tests des que tu changes la structure. Ca fait beaucoup de travail pour un filet de sécurité qui disparaît au moment ou tu en as besoin.

Les tests d'intégration, eux, testent le comportement de bout en bout. Ils survivent au refactoring.

typescript// Test d'integration : teste le comportement observable
describe("Order processing", () => {
  it("envoie un email de confirmation apres une commande", async () => {
    const emailSpy = vi.spyOn(emailService, "send");

    await processOrder({
      items: [{ id: "prod-1", quantity: 2, price: 50 }],
      customer: { id: "cust-1", email: "test@example.com" },
    });

    expect(emailSpy).toHaveBeenCalledWith(
      expect.objectContaining({
        to: "test@example.com",
        subject: expect.stringContaining("Confirmation"),
      })
    );
  });
});

// Ce test survit meme si tu reorganises completement
// les fonctions internes de processOrder

Pour le legacy, vise les tests d'intégration en priorité. Tu ajouteras des tests unitaires apres le refactoring, quand la structure sera plus claire.

La couverture : un indicateur, pas un objectif

J'ai travaille dans une équipe ou l'objectif etait 90% de couverture. Le résultat : des tests qui testaient des getters et des setters. Des tests qui verifiaient que true === true. La couverture etait atteinte, mais les vrais bugs passaient quand meme.

La couverture de code te dit quelles lignes sont exécutées pendant les tests. Elle ne te dit pas si ces lignes sont correctement testees. C'est une metrique de confiance, pas de qualité. Tu peux découvrir d'autres reflexions sur le sujet technique et pratique sur paltemps.fr.

Utilise la couverture pour identifier les zones non testees, pas pour valider la qualité des tests. Si tu refactorises un module a 0% de couverture, monte-le a 70-80% avec des tests pertinents. C'est largement suffisant pour refactoriser en confiance.

Quand les tests bloquent le refactoring

Parfois, les tests sont l'obstacle. Pas l'absence de tests : les mauvais tests.

Des tests trop couples a l'implementation verifient comment le code fait quelque chose au lieu de ce qu'il fait. Ils cassent a chaque changement de structure, meme quand le comportement est intact.

typescript// Mauvais test : couple a l'implementation
it("appelle validateEmail puis checkDuplicates puis saveToDb", () => {
  const spy1 = vi.spyOn(service, "validateEmail");
  const spy2 = vi.spyOn(service, "checkDuplicates");
  const spy3 = vi.spyOn(service, "saveToDb");

  service.createUser({ email: "a@b.com" });

  expect(spy1).toHaveBeenCalledBefore(spy2);
  expect(spy2).toHaveBeenCalledBefore(spy3);
});

// Bon test : verifie le comportement
it("cree un utilisateur avec un email valide", async () => {
  const result = await service.createUser({ email: "a@b.com" });

  expect(result.success).toBe(true);
  const user = await db.findByEmail("a@b.com");
  expect(user).toBeDefined();
});

Le premier test casse si tu reorganises les fonctions internes. Le second test survit a n'importe quel refactoring tant que le résultat est le meme.

Si tu herites de tests comme le premier, tu as deux options : les reecrire avant de refactoriser, ou les supprimer et les remplacer par des characterization tests. Dans les deux cas, ca demande du travail. Mais c'est un investissement qui rend le refactoring possible.

Résumé

  • Refactoriser sans tests est un pari que tu finiras par perdre
  • Les characterization tests capturent le comportement actuel, pas le comportement ideal
  • Le cycle red-green-refactor protégé chaque étape du refactoring
  • Les tests d'intégration survivent mieux au refactoring que les tests unitaires
  • La couverture de code est un indicateur utile mais pas un objectif en soi
  • Les tests trop couples a l'implementation bloquent le refactoring au lieu de l'aider

Article précédent : 15 - Refactoring legacy

Article suivant : 17 - Structurer un projet

Sources

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