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 :
- Red : ecris un test qui echoue (ou un characterization test qui passe)
- Green : fais passer le test avec le code existant
- 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
- Michael Feathers, "Working Effectively with Legacy Code" (2004) - https://www.oreilly.com/library/view/working-effectively-with/0131177052/
- Martin Fowler, "Refactoring: Improving the Design of Existing Code" (2018) - https://martinfowler.com/books/refactoring.html
- Kent Beck, "Test-Driven Development: By Example" (2002) - https://www.oreilly.com/library/view/test-driven-development/0321146530/