Clean Code
Chapitre 9 / 14 · 13 min de lecture

Les tests unitaires

Les trois lois du TDD, des tests aussi propres que le code de production, et l'acronyme F.I.R.S.T.

Les frameworks de test sont entrés dans nos habitudes, et tout le monde s'accorde à dire que les tests automatisés sont une bonne chose. Pourtant, beaucoup d'équipes traitent encore leur code de test comme un citoyen de seconde zone : variables obscures, fonctions interminables, copier-coller à profusion. « Tant que ça passe, c'est bon. » Robert C. Martin renverse cette idée avec une affirmation qui dérange : un test sale est aussi nuisible, sinon pire, qu'une absence de test.

Car les tests ne sont pas un appendice du projet. Ils en sont le filet de sécurité — ce qui vous autorise à refactorer, à nettoyer, à faire évoluer le code de production sans peur. Le jour où vous perdez confiance en vos tests, vous perdez la liberté de toucher à votre code. Et un code qu'on n'ose plus toucher est un code qui pourrit. Ce chapitre explique comment écrire des tests qui restent un atout, et non une dette.

Les trois lois du TDD

Le Test-Driven Development (développement piloté par les tests) ne se résume pas à « écrire les tests d'abord ». C'est le sommet de l'iceberg. Sous la surface, trois lois régissent un cycle extrêmement serré, d'une trentaine de secondes à peine :

LoiÉnoncé
Première loiN'écrivez aucun code de production tant qu'un test unitaire qui échoue ne l'exige.
Deuxième loiN'écrivez qu'autant de test qu'il faut pour échouer — et ne pas compiler, c'est échouer.
Troisième loiN'écrivez qu'autant de code de production qu'il faut pour faire passer le test qui échoue.

Ces trois lois vous enferment dans une boucle. Test et code de production grandissent ensemble, le test gardant quelques secondes d'avance. Concrètement, vous écrivez à peine deux lignes de test — au point qu'il ne compile même pas — puis vous écrivez juste assez de code pour qu'il compile et échoue, puis juste assez pour qu'il passe. Et vous recommencez.

// 1. On écrit le test AVANT que `Panier` ne sache calculer.
//    Il ne compile pas : la méthode `total()` n'existe pas. C'est un échec.
describe("Panier", () => {
	it("retourne 0 pour un panier vide", () => {
		const panier = new Panier();
		expect(panier.total()).toBe(0);
	});
});
// 2. On écrit JUSTE assez de code de production pour passer.
class Panier {
	total(): number {
		return 0;
	}
}

Le return 0 paraît absurde, mais c'est le strict minimum pour satisfaire le test courant. Le test suivant — « un panier avec un article à 30 € retourne 30 » — forcera alors une vraie implémentation. C'est le test qui tire le code, pas l'inverse.

Note

Le livre est écrit en Java avec JUnit ; nous transposons ses exemples en TypeScript avec une API de type describe / it / expect (façon Jest ou Vitest), idiomatique dans l'écosystème JavaScript.

En travaillant ainsi, on produit des dizaines de tests par jour, des milliers par an. Leur volume finit par rivaliser avec celui du code de production. D'où une conséquence directe : si ces tests sont mal écrits, ils deviennent un poids colossal.

Garder ses tests propres

Martin raconte avoir coaché une équipe qui avait délibérément décidé que son code de test n'avait pas à respecter les standards de qualité du code de production. « Quick and dirty » était le mot d'ordre : noms approximatifs, fonctions longues, aucune attention au design. Tant que ça couvrait le code de production, c'était jugé suffisant.

Ce que cette équipe n'avait pas compris, c'est que les tests doivent évoluer en même temps que le code de production. Plus les tests sont sales, plus ils sont difficiles à modifier. Plus le code de test est emmêlé, plus il est probable qu'on passe plus de temps à y caser un nouveau cas qu'à écrire le code de production correspondant. À chaque modification du code de production, d'anciens tests cassaient, et le désordre rendait leur réparation pénible.

Attention

De version en version, le coût de maintenance de la suite de tests de cette équipe a augmenté, jusqu'à devenir la première source de plaintes des développeurs. Ils ont fini par jeter toute la suite de tests. Sans filet, leur taux de défauts a explosé ; ils ont cessé de nettoyer leur code de production par peur de tout casser. Le code a pourri.

La morale est limpide :

Le code de test est aussi important que le code de production. Ce n'est pas un citoyen de seconde zone. Il exige réflexion, design et soin.

Les tests rendent possibles les « -ilités »

Si vous ne gardez pas vos tests propres, vous finirez par les perdre. Et en les perdant, vous perdez la chose même qui maintient votre code de production flexible, maintenable et réutilisable.

La raison est simple : avec des tests, vous ne craignez plus de modifier le code. Sans tests, chaque changement est un bug potentiel. Peu importe l'élégance de votre architecture : sans tests, vous hésiterez à toucher au code par peur d'introduire des défauts invisibles.

Avec des tests, cette peur disparaît presque entièrement. Plus la couverture est élevée, moins la peur est grande. Vous pouvez alors améliorer une architecture médiocre sans crainte. Voilà pourquoi les tests sont la clé d'un design qui reste propre dans le temps : les tests rendent possible le changement, et le changement rend possibles toutes les -ilités.

Ce qui fait un test propre

Qu'est-ce qui rend un test propre ? Trois choses, dit Martin : la lisibilité, la lisibilité et la lisibilité. Elle est peut-être encore plus cruciale dans un test que dans le code de production. Un bon test en dit beaucoup avec le moins d'expressions possible : clarté, simplicité, densité.

Voici un test inspiré de l'exemple du livre (tiré de FitNesse), réécrit dans notre domaine e-commerce. Observez à quel point les détails techniques noient l'intention.

// ❌ Avant : un fardeau de détails masque ce qui est testé.
it("renvoie le catalogue en JSON", async () => {
	catalogue.ajouter(
		new Produit(SkuParser.parse("SKU-001"), "Chaise")
	);
	catalogue.ajouter(
		new Produit(SkuParser.parse("SKU-002"), "Table")
	);

	requete.setRessource("catalogue");
	requete.ajouterParam("format", "json");
	const handler = new CatalogueHandler();
	const reponse = (await handler.repondre(
		new Contexte(catalogue),
		requete
	)) as ReponseHttp;
	const corps = reponse.contenu();

	expect(reponse.typeContenu()).toBe("application/json");
	expect(corps).toContain('"nom":"Chaise"');
	expect(corps).toContain('"nom":"Table"');
});

Les appels à SkuParser.parse, la construction manuelle de la requête, l'instanciation du handler, le cast de la réponse… tout cela est du bruit. Ces détails sont complètement étrangers à ce que le test veut vérifier. Le lecteur doit les digérer avant de comprendre l'essentiel.

Maintenant, la version refactorée. Elle fait exactement la même chose, mais elle va droit au but.

// ✅ Après : on ne voit plus que l'intention.
it("renvoie le catalogue en JSON", async () => {
	ajouterProduits("Chaise", "Table");

	await envoyerRequete("catalogue", "format:json");

	verifierQueLaReponseEstDuJson();
	verifierQueLaReponseContient("Chaise", "Table");
});

Le patron BUILD-OPERATE-CHECK

La structure de la version propre saute aux yeux : chaque test se découpe en trois parties. C'est le patron BUILD-OPERATE-CHECK (aussi appelé Arrange-Act-Assert ou, en BDD, Given-When-Then).

ÉtapeRôleDans l'exemple
Build (Arrange)Construire les données du testajouterProduits(...)
Operate (Act)Agir sur ces donnéesawait envoyerRequete(...)
Check (Assert)Vérifier le résultatverifierQueLaReponse...

L'essentiel du détail agaçant a disparu. Le test ne manipule que les types et fonctions dont il a réellement besoin. N'importe quel lecteur en comprend l'objet en quelques secondes, sans être noyé.

Astuce

Visez systématiquement ces trois blocs, séparés par une ligne vide. Un test qui mélange préparation, action et vérification est un test qu'on relit trois fois avant de comprendre.

Un langage de test propre au domaine

D'où viennent ces jolies fonctions ajouterProduits, envoyerRequete ou verifierQueLaReponseContient ? Elles forment un langage spécifique au domaine (DSL) construit pour les tests. Plutôt que d'utiliser directement les API que les développeurs manipulent dans le code de production, on bâtit une couche d'utilitaires par-dessus, conçue pour rendre les tests plus faciles à écrire et à lire.

Cette API de test n'est pas conçue à l'avance, sur le papier. Elle émerge du refactoring continu : on écrit un test, on constate qu'il croule sous les détails, on extrait des helpers, et le langage de test s'enrichit progressivement.

// Les briques du langage de test, extraites au fil du refactoring.
function ajouterProduits(...noms: string[]): void {
	for (const nom of noms) {
		catalogue.ajouter(new Produit(Sku.suivant(), nom));
	}
}

async function envoyerRequete(
	ressource: string,
	params: string
): Promise<void> {
	derniereReponse = await handler.repondre(
		contexteAvec(catalogue),
		construireRequete(ressource, params)
	);
}

function verifierQueLaReponseContient(...fragments: string[]): void {
	for (const fragment of fragments) {
		expect(derniereReponse.contenu()).toContain(fragment);
	}
}

Le double standard

L'équipe coachée par Martin avait raison sur un point, un seul : le code des helpers de test obéit à un jeu de standards différent de celui du code de production. Il doit rester simple, concis et expressif — mais il n'a pas besoin d'être aussi efficace que le code de production. Il tourne dans un environnement de test, pas en production, et ces deux environnements ont des contraintes très différentes.

L'exemple emblématique du livre est un système de contrôle d'environnement. Le test brut force l'œil à faire des allers-retours entre l'état vérifié et le sens de la vérification.

// ❌ Avant : l'œil saute entre l'état et le `expect(...).toBe(...)`.
it("déclenche l'alarme froid au seuil bas", () => {
	hw.reglerTemperature(BIEN_TROP_FROID);
	controleur.tic();

	expect(hw.chauffageActif()).toBe(true);
	expect(hw.souffleurActif()).toBe(true);
	expect(hw.refroidisseurActif()).toBe(false);
	expect(hw.alarmeChaudActive()).toBe(false);
	expect(hw.alarmeFroidActive()).toBe(true);
});

On peut considérablement améliorer la lecture en encodant l'état complet du matériel dans une simple chaîne : majuscule = « allumé », minuscule = « éteint », dans un ordre fixe {chauffage, souffleur, refroidisseur, alarme-chaud, alarme-froid}.

// ✅ Après : on lit l'état d'un coup d'œil.
it("déclenche l'alarme froid au seuil bas", () => {
	bienTropFroid();
	expect(hw.etat()).toBe("CSraL"); // C, S, L : on ; r, a : off
});

it("déclenche refroidisseur et souffleur s'il fait trop chaud", () => {
	tropChaud();
	expect(hw.etat()).toBe("cSCal");
});

C'est ici qu'intervient le double standard. Voici le helper qui produit cette chaîne :

// Peu efficace (concaténations répétées), mais parfait pour un test.
etat(): string {
	return (
		(this.chauffage ? "C" : "c") +
		(this.souffleur ? "S" : "s") +
		(this.refroidisseur ? "R" : "r") +
		(this.alarmeChaud ? "A" : "a") +
		(this.alarmeFroid ? "L" : "l")
	);
}

En code de production embarqué, à mémoire contrainte, on éviterait peut-être ces concaténations successives. Mais dans l'environnement de test, qui n'a aucune contrainte de ressources, c'est parfaitement acceptable.

À retenir

Le double standard touche l'efficacité (mémoire, CPU), jamais la propreté. On peut écrire en test des choses qu'on bannirait en production pour des raisons de performance — jamais pour des raisons de lisibilité ou de soin.

Un seul assert, un seul concept

Un seul assert par test

Une école de pensée recommande qu'une fonction de test ne contienne qu'une seule assertion. La règle paraît draconienne, mais l'avantage est réel : chaque test aboutit à une conclusion unique, immédiate à comprendre. On peut souvent y parvenir en scindant un test en deux.

// ✅ Un seul assert par test, avec la convention given-when-then.
it("renvoie une réponse JSON", async () => {
	donneLesProduits("Chaise", "Table");
	quandOnEnvoieLaRequete("catalogue", "format:json");
	alorsLaReponseEstDuJson();
});

it("contient le nom de chaque produit", async () => {
	donneLesProduits("Chaise", "Table");
	quandOnEnvoieLaRequete("catalogue", "format:json");
	alorsLaReponseContient("Chaise", "Table");
});

L'inconvénient saute aux yeux : la duplication des parties given et when. On pourrait l'éliminer (en remontant la préparation dans un beforeEach, par exemple), mais c'est parfois beaucoup de mécanique pour un gain mineur. Martin conclut avec nuance :

La règle de l'assertion unique est un bon repère. Le mieux qu'on puisse dire, c'est que le nombre d'assertions dans un test devrait être minimisé.

Un seul concept par test

La règle plus juste est sans doute celle-ci : tester un seul concept par fonction de test. On ne veut pas de longues fonctions qui vérifient un truc, puis un autre, puis un troisième sans rapport. Reprenons l'exemple du livre sur l'ajout de mois à une date.

// ❌ Avant : trois concepts distincts entassés dans un test.
it("teste addMonths", () => {
	const d1 = SerialDate.creer(31, 5, 2004);

	const d2 = SerialDate.ajouterMois(1, d1);
	expect(d2.jour()).toBe(30); // mai (31j) + 1 mois -> 30 juin
	expect(d2.mois()).toBe(6);

	const d3 = SerialDate.ajouterMois(2, d1);
	expect(d3.jour()).toBe(31); // mai + 2 mois -> 31 juillet
	expect(d3.mois()).toBe(7);

	const d4 = SerialDate.ajouterMois(1, SerialDate.ajouterMois(1, d1));
	expect(d4.jour()).toBe(30); // juin (30j) + 1 mois -> 30 juillet
	expect(d4.mois()).toBe(7);
});

Ce test devrait être scindé, car il mêle plusieurs concepts indépendants. En les séparant, une règle générale apparaît : quand on incrémente le mois, le jour ne peut pas dépasser le dernier jour du mois cible. Cette clarté révèle même un cas de test manquant (le 28 février + 1 mois → 28 mars).

// ✅ Après : un concept par test, et la règle devient lisible.
it("plafonne au dernier jour du mois cible (31 mai -> 30 juin)", () => {
	const debut = SerialDate.creer(31, 5, 2004);
	const resultat = SerialDate.ajouterMois(1, debut);
	expect(resultat.jour()).toBe(30);
	expect(resultat.mois()).toBe(6);
});

it("retrouve le 31 quand le mois cible a 31 jours (mai -> juillet)", () => {
	const debut = SerialDate.creer(31, 5, 2004);
	const resultat = SerialDate.ajouterMois(2, debut);
	expect(resultat.jour()).toBe(31);
});

Ce ne sont pas les assertions multiples par bloc qui posaient problème, mais le fait de tester plusieurs concepts. La bonne règle : minimiser le nombre d'assertions par concept, et tester un seul concept par fonction.

F.I.R.S.T. : les cinq règles d'un bon test

Au-delà de la lisibilité, un test propre respecte cinq règles résumées par l'acronyme F.I.R.S.T.

LettrePrincipeCe que ça impose
F — FastRapideLes tests doivent courir vite, sinon vous cesserez de les lancer souvent.
I — IndependentIndépendantAucun test ne doit dépendre d'un autre ; ordre libre.
R — RepeatableReproductibleIdentiques partout : sur le CI, en QA, dans le train sans réseau.
S — Self-validatingAuto-validantSortie booléenne : ça passe ou ça échoue, sans lecture de logs.
T — TimelyAu bon momentÉcrits juste avant le code de production qu'ils valident.
  • Fast. Des tests lents ne sont pas lancés fréquemment. S'ils ne sont pas lancés fréquemment, on ne détecte plus les problèmes tôt, on n'ose plus nettoyer le code, et le code se met à pourrir.
  • Independent. Un test ne doit pas mettre en place les conditions du suivant. Sinon, le premier échec déclenche une cascade de pannes en aval qui masque les vrais défauts et rend le diagnostic pénible.
  • Repeatable. Un test qui ne tourne pas partout vous offre toujours une excuse pour expliquer ses échecs — et vous empêche de le lancer quand l'environnement manque.
  • Self-validating. Un test ne doit jamais exiger une comparaison manuelle de deux fichiers ou la lecture d'un journal. Sinon, l'échec devient subjectif.
  • Timely. Écrits après le code de production, vos tests risquent de révéler un code difficile à tester — ou vous pousseront à concevoir un code qui ne l'est pas du tout.

Piège courant

Un piège courant : laisser un test dépendre de la date système, d'un ordre d'exécution ou d'un appel réseau réel. Le test « passe sur ma machine » puis échoue aléatoirement sur le CI. Un test instable (flaky) érode la confiance aussi sûrement qu'un test absent — on finit par l'ignorer, puis par l'effacer.

À retenir

  • Les trois lois du TDD enferment test et code dans un cycle de quelques secondes : pas de code de production sans test rouge, juste assez de test pour échouer, juste assez de code pour passer.
  • Un test sale est aussi nuisible qu'une absence de test. Le code de test mérite le même soin que le code de production — il n'est pas de seconde zone.
  • Les tests rendent possibles les -ilités : ils sont le filet qui vous autorise à refactorer sans peur. Les perdre, c'est figer puis pourrir le code.
  • La qualité numéro un d'un test, c'est la lisibilité : structurez-le en BUILD-OPERATE-CHECK et construisez un langage de test propre au domaine.
  • Le double standard autorise un test à privilégier la lisibilité sur l'efficacité — jamais sur la propreté.
  • Minimisez les assertions, testez un seul concept par test, et respectez F.I.R.S.T. (Fast, Independent, Repeatable, Self-validating, Timely).