Chapitre 828 minIntermédiaire
Refactoring : repérer les code smells et améliorer le code
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- définir le refactoring et ce qui le distingue d'une correction ;
- repérer les principaux code smells dans un code existant ;
- sécuriser un refactoring avec des tests de caractérisation ;
- appliquer les refactorings de base avec les outils de VS Code.
3 exercices corrigés
Le refactoring (en français, refactorisation ou réusinage) consiste à modifier la structure interne d'un code sans changer son comportement visible, pour le rendre plus lisible et plus facile à faire évoluer. Il avance par petites transformations sûres (renommer, extraire une fonction, remplacer un nombre magique par une constante), chacune vérifiée par les tests. Ce chapitre présente les code smells, ces signes qui indiquent qu'un code mérite d'être retravaillé, les tests de caractérisation qui sécurisent le travail, puis un refactoring complet pas à pas, en JavaScript et en PHP.
Refactoring, correction, évolution : trois gestes à ne pas mélanger
Martin Fowler, qui a popularisé le terme, parle de « deux casquettes » : quand tu ajoutes une fonctionnalité ou corriges un bug, tu changes le comportement ; quand tu refactorises, tu ne le changes pas. Tu peux passer de l'une à l'autre plusieurs fois dans la journée, mais jamais porter les deux en même temps.
| Geste | Le comportement change ? | Les tests existants | Type de commit |
|---|---|---|---|
| Correction | Oui, il redevient conforme | Un nouveau test, d'abord rouge | fix |
| Évolution | Oui, il s'enrichit | De nouveaux tests | feat |
| Refactoring | Non | Les mêmes tests, qui restent verts du début à la fin | refactor |
La règle a une raison pratique. Si un commit mélange un refactoring et une correction, et qu'un test casse, tu ne sais plus lequel des deux en est la cause. Et le relecteur doit démêler, ligne par ligne, ce qui change le comportement de ce qui ne fait que déplacer du code. Refactorise avant une correction pour rendre le code plus simple à corriger, ou après pour nettoyer, mais dans un commit à part.
Les code smells
Un code smell (« mauvaise odeur ») n'est pas un bug : c'est un signe qu'un code sera pénible à comprendre ou à modifier, et qu'un défaut s'y cache peut-être. L'expression vient de Kent Beck, et le catalogue de Fowler est devenu la référence. Voici les odeurs que tu rencontreras le plus en maintenance, avec le remède habituel.
| Code smell | À quoi on le reconnaît | Remède habituel |
|---|---|---|
| Nom obscur | calc, l, u, tmp, data2, traiter() | Renommer |
| Fonction trop longue | Plus d'un écran, plusieurs responsabilités, des commentaires qui séparent des « parties » | Extraire des fonctions |
| Code dupliqué | Le même bloc copié-collé à plusieurs endroits, avec de petites variantes | Extraire une fonction commune |
| Nombre magique | 0.3, 20, 120 écrits en dur, sans nom | Remplacer par une constante nommée |
| Conditions imbriquées | Des if dans des if sur quatre niveaux | Clauses de garde, ou fusion des conditions |
| Longue liste de paramètres | creer(nom, prenom, email, tel, rue, cp, ville, service, role) | Regrouper dans un objet |
| Switch répété | Le même switch sur le type de ligne dans cinq fichiers | Une table de correspondance, ou le polymorphisme |
| Code mort | Une fonction jamais appelée, du code commenté « au cas où » | Supprimer : Git le garde dans l'historique |
| Commentaire déodorant | Un long commentaire qui explique un code confus | Rendre le code clair, puis supprimer le commentaire devenu inutile |
| Classe fourre-tout | Une classe Utils ou Manager de 2 000 lignes qui fait tout | Découper par responsabilité |
| Obsession des types primitifs | Un montant en nombre à virgule, une date en chaîne, partout | Introduire un type dédié (une classe Montant, des centimes) |
| Envie de fonctionnalité | Une fonction qui manipule surtout les données d'un autre objet | Déplacer la fonction vers cet objet |
Avant de refactoriser : les tests de caractérisation
Un refactoring sans tests n'est pas un refactoring, c'est une modification à l'aveugle. Or le code qui en a le plus besoin est souvent celui qui n'a pas de tests. La solution : écrire d'abord des tests de caractérisation. Ils ne vérifient pas que le code est juste ; ils figent ce qu'il fait aujourd'hui, résultats faux compris.
Voici la fonction de calcul des remboursements de Sorbier Frais, celle que le linter signalait au chapitre 7. Elle est modifiée à chaque changement de barème, et personne n'aime y toucher :
// services/remboursement.js — avant
function calc(l, u) {
let r = 0;
if (l.cat == 'km') {
if (u.cv <= 4) {
r = l.qte * 0.3;
} else {
r = l.qte * 0.4;
}
} else if (l.cat == 'repas') {
if (l.mt > 20) {
r = 20;
} else {
r = l.mt;
}
} else if (l.cat == 'hotel') {
if (u.ville == 'Paris') {
if (l.mt > 120) {
r = 120;
} else {
r = l.mt;
}
} else {
if (l.mt > 90) {
r = 90;
} else {
r = l.mt;
}
}
} else {
r = l.mt;
}
return Math.round(r * 100) / 100;
}
module.exports = { calc };
Le barème interne (fictif) se devine : 0,30 € du kilomètre pour un véhicule de 4 CV ou moins, 0,40 € au-delà ; repas plafonnés à 20 € ; nuit d'hôtel plafonnée à 120 € à Paris et 90 € ailleurs ; les autres frais remboursés au réel. Tu comptes les odeurs : noms obscurs (calc, l, u, r), nombres magiques, conditions imbriquées sur trois niveaux, == au lieu de ===. Complexité cyclomatique : 9.
Pour écrire les tests de caractérisation, tu appelles la fonction avec des entrées qui passent par chaque branche, en ajoutant les valeurs limites (20 € pile, 4 CV pile), et tu notes ce qu'elle renvoie :
// tests/remboursement.test.js — tests de caractérisation, écrits AVANT le refactoring
const { calc } = require('../services/remboursement');
const cas = [
// [ligne, salarié, montant remboursé constaté]
[{ cat: 'km', qte: 100 }, { cv: 4 }, 30],
[{ cat: 'km', qte: 100 }, { cv: 5 }, 40],
[{ cat: 'km', qte: 37 }, { cv: 3 }, 11.1],
[{ cat: 'repas', mt: 18.4 }, {}, 18.4],
[{ cat: 'repas', mt: 20 }, {}, 20],
[{ cat: 'repas', mt: 27.5 }, {}, 20],
[{ cat: 'hotel', mt: 135 }, { ville: 'Paris' }, 120],
[{ cat: 'hotel', mt: 110 }, { ville: 'Paris' }, 110],
[{ cat: 'hotel', mt: 110 }, { ville: 'Lyon' }, 90],
[{ cat: 'hotel', mt: 75 }, { ville: 'Lyon' }, 75],
[{ cat: 'autre', mt: 12.3 }, {}, 12.3],
];
describe('montant remboursé', () => {
for (const [ligne, salarie, attendu] of cas) {
test(`${ligne.cat} ${ligne.qte ?? ligne.mt} → ${attendu}`, () => {
expect(calc(ligne, salarie)).toBe(attendu);
});
}
});
Les onze tests passent, par construction. Ils sont désormais ton filet : à chaque étape du refactoring, tu les relances, et ils doivent rester verts.
Un refactoring pas à pas
On avance par petites étapes. Après chacune, on relance les tests ; s'ils restent verts, on commite (ou au moins on garde un point de retour) et on passe à la suivante. S'ils passent au rouge, on annule la dernière étape, qui est petite, au lieu de chercher l'erreur dans un gros changement.
Étape 1 : renommer. calc devient montantRembourse, l devient ligne, u devient salarie, r devient montant. Dans VS Code, place le curseur sur le nom et appuie sur F2 (Renommer le symbole) : l'éditeur renomme toutes les occurrences, y compris dans les autres fichiers et dans les tests, sans toucher aux autres variables qui s'appelleraient aussi l.
Étape 2 : comparer avec ===. Les catégories et les villes sont toujours des chaînes, donc === donne les mêmes résultats que ==, sans conversion implicite. Les tests le confirment.
Étape 3 : nommer les nombres magiques. Chaque valeur du barème devient une constante, avec son unité en commentaire :
const TAUX_KM_PETITE_CYLINDREE = 0.3; // €/km, véhicule de 4 CV ou moins
const TAUX_KM_GRANDE_CYLINDREE = 0.4; // €/km, au-delà de 4 CV
const PUISSANCE_MAX_PETITE_CYLINDREE = 4; // CV
const PLAFOND_REPAS = 20; // €
const PLAFOND_HOTEL_PARIS = 120; // € par nuit
const PLAFOND_HOTEL_PROVINCE = 90; // € par nuit
Le prochain changement de barème se fera en une ligne, sans chercher où traîne le 20.
Étape 4 : remplacer les plafonds imbriqués par Math.min. Le motif « si le montant dépasse le plafond, prendre le plafond, sinon le montant » s'écrit Math.min(montant, plafond). Trois blocs de cinq lignes deviennent trois expressions.
Étape 5 : extraire des fonctions. Le calcul kilométrique et le choix du plafond d'hôtel deviennent deux fonctions qui portent un nom métier. Sélectionne le bloc, puis Ctrl+. (ou l'ampoule) et Extraire vers une fonction dans la portée du module : VS Code crée la fonction et remplace le bloc par son appel.
Étape 6 : simplifier l'aiguillage. La cascade de else if sur la catégorie devient un switch, et chaque branche renvoie directement son résultat.
Le résultat, toujours vérifié par les mêmes onze tests :
// services/remboursement.js — après
const TAUX_KM_PETITE_CYLINDREE = 0.3; // €/km, véhicule de 4 CV ou moins
const TAUX_KM_GRANDE_CYLINDREE = 0.4; // €/km, au-delà de 4 CV
const PUISSANCE_MAX_PETITE_CYLINDREE = 4; // CV
const PLAFOND_REPAS = 20; // €
const PLAFOND_HOTEL_PARIS = 120; // € par nuit
const PLAFOND_HOTEL_PROVINCE = 90; // € par nuit
const arrondirAuCentime = (montant) => Math.round(montant * 100) / 100;
function indemniteKilometrique(kilometres, puissanceFiscale) {
const taux = puissanceFiscale <= PUISSANCE_MAX_PETITE_CYLINDREE
? TAUX_KM_PETITE_CYLINDREE
: TAUX_KM_GRANDE_CYLINDREE;
return kilometres * taux;
}
function plafondHotel(ville) {
return ville === 'Paris' ? PLAFOND_HOTEL_PARIS : PLAFOND_HOTEL_PROVINCE;
}
function montantRembourse(ligne, salarie) {
switch (ligne.cat) {
case 'km':
return arrondirAuCentime(indemniteKilometrique(ligne.qte, salarie.cv));
case 'repas':
return arrondirAuCentime(Math.min(ligne.mt, PLAFOND_REPAS));
case 'hotel':
return arrondirAuCentime(Math.min(ligne.mt, plafondHotel(salarie.ville)));
default:
return arrondirAuCentime(ligne.mt);
}
}
module.exports = { montantRembourse };
Le code est plus long en lignes, mais chaque fonction se lit d'un coup d'œil, et la complexité de montantRembourse tombe de 9 à 4. Surtout, il dit ce qu'il fait : on lit « le montant remboursé d'un repas est le montant, plafonné au plafond repas ». Au-delà des onze tests, les deux versions ont été comparées sur plus de 16 000 combinaisons d'entrées : elles renvoient exactement les mêmes résultats.
Les refactorings de base
Une dizaine de transformations couvrent l'essentiel du travail quotidien.
| Refactoring | Ce qu'il fait | Raccourci ou outil dans VS Code |
|---|---|---|
| Renommer | Donne un nom qui dit l'intention, partout à la fois | F2 |
| Extraire une fonction | Transforme un bloc en fonction nommée | Ctrl+. sur la sélection |
| Extraire une variable | Nomme une expression compliquée (const estWeekEnd = …) | Ctrl+. sur l'expression |
| Extraire une constante | Remplace un nombre magique par un nom | Ctrl+. sur la valeur |
| Intégrer (inline) | L'inverse : remplace une variable ou une fonction triviale par son contenu | Ctrl+. |
| Clauses de garde | Traite les cas particuliers en premier, avec un return, pour aplatir les if | À la main |
| Introduire un objet paramètre | Regroupe des paramètres qui voyagent ensemble | À la main |
| Supprimer le code mort | Retire ce qui n'est jamais exécuté | Le linter le signale (no-unused-vars) |
| Déplacer une fonction | La range dans le module ou la classe à qui elle appartient | Ctrl+. (Move to a new file) |
| Remplacer une condition par une table | Un objet de correspondance au lieu d'une cascade de if | À la main |
Les clauses de garde méritent un exemple, car elles transforment la lisibilité d'un code imbriqué. En PHP, sur Sorbier Véhicules :
<?php
// Avant : la règle principale est enfouie au troisième niveau
public function peutAnnuler(Reservation $reservation, Utilisateur $utilisateur): bool
{
if ($reservation->statut() !== 'annulee') {
if ($reservation->debut() > new DateTimeImmutable()) {
if ($utilisateur->estResponsableParc() || $reservation->demandeur()->id() === $utilisateur->id()) {
return true;
}
}
}
return false;
}
// Après : les cas qui interdisent l'annulation sortent tout de suite
public function peutAnnuler(Reservation $reservation, Utilisateur $utilisateur): bool
{
if ($reservation->statut() === 'annulee') {
return false;
}
if ($reservation->debut() <= new DateTimeImmutable()) {
return false; // la réservation a déjà commencé
}
return $utilisateur->estResponsableParc()
|| $reservation->demandeur()->id() === $utilisateur->id();
}
Chaque condition se lit seule, et la règle principale (le responsable du parc ou le demandeur peuvent annuler) apparaît en dernière ligne, sans indentation. Attention à inverser correctement les conditions : le contraire de > est <=, pas <.
Supprimer la duplication, en PHP
La duplication est l'odeur la plus coûteuse en maintenance : un bug corrigé dans une copie reste dans les autres. Sur Sorbier Véhicules, deux fonctions formatent une période :
<?php
// Avant
function libelleReservation(array $r): string
{
$debut = (new DateTimeImmutable($r['debut']))->format('d/m/Y H:i');
$fin = (new DateTimeImmutable($r['fin']))->format('d/m/Y H:i');
return $r['immatriculation'] . ' du ' . $debut . ' au ' . $fin;
}
function libelleEntretien(array $e): string
{
$debut = (new DateTimeImmutable($e['debut']))->format('d/m/Y H:i');
$fin = (new DateTimeImmutable($e['fin']))->format('d/m/Y H:i');
return 'Entretien ' . $e['garage'] . ' du ' . $debut . ' au ' . $fin;
}
On extrait la partie commune dans une fonction nommée, et les deux fonctions l'appellent :
<?php
// Après
const FORMAT_DATE_HEURE = 'd/m/Y H:i';
function periode(string $debut, string $fin): string
{
$format = fn (string $date): string => (new DateTimeImmutable($date))->format(FORMAT_DATE_HEURE);
return 'du ' . $format($debut) . ' au ' . $format($fin);
}
function libelleReservation(array $r): string
{
return $r['immatriculation'] . ' ' . periode($r['debut'], $r['fin']);
}
function libelleEntretien(array $e): string
{
return 'Entretien ' . $e['garage'] . ' ' . periode($e['debut'], $e['fin']);
}
Le jour où l'on voudra afficher « du 19/11 au 20/11 » sans l'année, ou en toutes lettres, on modifiera un seul endroit. Les deux libellés produisent exactement les mêmes chaînes qu'avant : un test de caractérisation sur chacun le prouve.
Quand ne pas refactoriser
Le refactoring n'est pas toujours le bon moment.
- Quand tu ne peux pas tester. Sans tests existants ni possibilité d'en écrire, commence par rendre le code testable (en extrayant une fonction pure, par exemple), ou abstiens-toi.
- Pendant une correction urgente. Le hotfix contient le correctif, et seulement lui. Le nettoyage viendra après, à tête reposée.
- Sur un code qui va disparaître, ou que personne ne modifiera plus : ses intérêts de dette sont nuls.
- En un seul gros commit. Un refactoring de 2 000 lignes ne se relit pas et ne s'annule pas proprement. Si le chantier est grand, découpe-le en étapes livrables.
- Pour le plaisir. Refactoriser un code qui marche, qu'on ne touchera pas, juste parce qu'il ne correspond pas à tes goûts, c'est prendre un risque sans gain.
À l'E6 SLAM, la commission apprécie un refactoring ciblé et justifié (une fonction dupliquée remplacée par une seule, avec ses tests), c'est même un indicateur de la sous-compétence « évaluer la qualité ». Mais l'expression des besoins passe d'abord.
Exercices
Exercice 1 sur 3 Nommer les odeurs Réussi À revoir
Relève au moins cinq code smells dans cette fonction de Sorbier Véhicules, et propose un remède pour chacun.
// calcule
function f(d, tab, x, y, z) {
let res = [];
for (let i = 0; i < tab.length; i++) {
if (tab[i].s != 'annulee') {
if (tab[i].v == x) {
if (new Date(tab[i].d) >= d) {
res.push(tab[i]);
}
}
}
}
// ancien filtre, ne plus utiliser
// res = res.filter(r => r.km > 0);
return res;
}
Voir la réponseMasquer la réponse de l'exercice 1
- Noms obscurs :
f,d,tab,x,res, et les propriétéss,v,d. Renommer :reservationsAVenir(depuis, reservations, idVehicule),statut,idVehicule,debut. Les paramètres et variables se renomment sans risque avecF2; les propriétés viennent des données, donc les renommer oblige à modifier aussi le code qui construit ces objets (la requête SQL, le dépôt). On peut le faire dans une deuxième étape. - Paramètres inutilisés :
yetzne servent à rien. Les supprimer (après avoir vérifié avecMaj+F12que les appelants peuvent être adaptés). - Conditions imbriquées sur trois niveaux : les fusionner avec
&&, ou utiliserfilter. - Comparaisons
!=et==: passer à!==et===. - Code mort commenté : le supprimer, Git le garde.
- Commentaire inutile (
// calcule) : il ne dit rien ; un bon nom de fonction le remplace.
Une version refactorisée :
function reservationsAVenir(depuis, reservations, idVehicule) {
return reservations.filter((r) =>
r.statut !== 'annulee'
&& r.idVehicule === idVehicule
&& new Date(r.debut) >= depuis);
}
Attention au passage de == à === sur idVehicule : si l'appelant passe un identifiant en chaîne ('12', lu dans une URL) et que les réservations ont un nombre (12), l'ancien code les trouvait égaux, le nouveau non. Un test de caractérisation avec un identifiant en chaîne le révélerait, et dirait s'il faut convertir avec Number(idVehicule).
Tu avais trouvé ?
Exercice 2 sur 3 Des clauses de garde Réussi À revoir
Réécris cette fonction de Sorbier Frais avec des clauses de garde, sans changer son comportement, puis écris les tests de caractérisation qui le prouvent.
function peutEtreValidee(note, utilisateur) {
if (note) {
if (note.etat === 'cloturee') {
if (utilisateur.role === 'comptable') {
if (note.idUtilisateur !== utilisateur.id) {
return true;
} else {
return false;
}
} else {
return false;
}
} else {
return false;
}
} else {
return false;
}
}
Voir la réponseMasquer la réponse de l'exercice 2
function peutEtreValidee(note, utilisateur) {
if (!note) return false;
if (note.etat !== 'cloturee') return false;
if (utilisateur.role !== 'comptable') return false;
return note.idUtilisateur !== utilisateur.id; // un comptable ne valide pas sa propre note
}
Les tests de caractérisation passent par chaque chemin de l'ancienne version, et s'appliquent aux deux :
const comptable = { id: 1, role: 'comptable' };
const salarie = { id: 2, role: 'salarie' };
test('pas de note', () => {
expect(peutEtreValidee(null, comptable)).toBe(false);
});
test('note pas encore clôturée', () => {
expect(peutEtreValidee({ etat: 'saisie', idUtilisateur: 2 }, comptable)).toBe(false);
});
test('un salarié ne valide pas', () => {
expect(peutEtreValidee({ etat: 'cloturee', idUtilisateur: 2 }, salarie)).toBe(false);
});
test('un comptable ne valide pas sa propre note', () => {
expect(peutEtreValidee({ etat: 'cloturee', idUtilisateur: 1 }, comptable)).toBe(false);
});
test('un comptable valide la note clôturée d\'un autre', () => {
expect(peutEtreValidee({ etat: 'cloturee', idUtilisateur: 2 }, comptable)).toBe(true);
});
Cinq tests pour cinq chemins : la complexité cyclomatique de la fonction est 5 (quatre décisions, plus un).
Tu avais trouvé ?
Exercice 3 sur 3 Refactoriser ou pas ? Réussi À revoir
Pour chaque situation, dis si tu refactorises maintenant, plus tard, ou jamais, et pourquoi.
- En corrigeant un bug urgent en production, tu remarques une fonction dupliquée juste à côté.
- La fonction de calcul des indemnités sera modifiée la semaine prochaine pour un nouveau barème ; elle est confuse mais testée.
- Un script d'import utilisé une seule fois en 2023, mal écrit, est toujours dans le dépôt.
- Un module sans aucun test, que tu dois faire évoluer demain.
Voir la réponseMasquer la réponse de l'exercice 3
- Plus tard. Le hotfix ne contient que la correction. Crée un ticket pour la duplication et traite-la dans un commit
refactorséparé, une fois la production réparée. - Maintenant, avant l'évolution : « rendre le changement facile, puis faire le changement facile ». Les tests existants sécurisent le refactoring, et l'évolution sera plus simple et plus sûre sur un code clair.
- Jamais, mais on le supprime s'il ne sert plus (Git le conserve dans l'historique). Refactoriser du code mort est du temps perdu.
- D'abord des tests de caractérisation sur le comportement que l'évolution va toucher, puis un refactoring minimal si c'est nécessaire pour rendre l'évolution possible. Sans filet, on limite les transformations au strict nécessaire.
Tu avais trouvé ?