TDD
Rouge, vert, refactor, démontré de bout en bout sur un cas réel.
Vérifié en septembre 2026 · .NET 10 (SDK 10.0.300), C# 14 · environ 14 min
Le développement piloté par les tests consiste à écrire un test qui échoue avant d'écrire le code qui le fait passer, puis à nettoyer ce code, en boucles de quelques minutes. Il sert surtout là où la logique métier est faite de règles qui s'empilent — tarifs, remises, droits, calendriers — et où chaque règle nouvelle risque d'en casser une ancienne. Ce cours suit une seule base de code du début à la fin : un panier avec remises par paliers, construit en trois cycles puis restructuré sans qu'aucun test ne change. Chaque bloc reprend le précédent là où il s'était arrêté.
Ce que TDD n'est pas
TDD n'est pas une technique de test. Écrire toute la suite de tests avant le code, puis coder jusqu'à ce qu'elle passe, n'en est pas : c'est une spécification exécutable écrite d'avance, avec tous les paris de conception qu'on fait avant d'avoir rien essayé. TDD avance un test à la fois, et chaque test n'est écrit qu'après que le précédent est passé.
Ce n'est pas non plus une façon d'atteindre une couverture. La couverture en est un sous-produit, pas l'objectif, et un projet peut avoir cent pour cent de couverture sans qu'aucun test n'ait précédé le code.
C'est un outil de conception. Un test écrit avant le code est le premier appelant de ce code : il oblige à décider du nom d'une classe, de ce qu'on passe à son constructeur, du type d'un montant, du point de vue de celui qui s'en sert, et avant d'avoir une implémentation à défendre. Un test écrit après vient confirmer ce que le code fait déjà ; il est façonné par l'implémentation et en épouse les détours. Un test écrit avant dit ce que le code doit faire, et quand il est pénible à écrire, c'est l'interface qu'il décrit qui est pénible à utiliser. Cette difficulté est un renseignement, le plus précieux que la méthode rapporte.
Le cycle
Trois temps, toujours dans cet ordre. Rouge : écrire un seul test, le lancer, le voir échouer. Vert : écrire le code le plus simple qui le fait passer. Refactor : améliorer la structure, tests au vert, sans changer le comportement.
| Temps | Ce qu'on écrit | Ce qu'on s'interdit | Quand on en sort |
|---|---|---|---|
| Rouge | un test, un seul | tout code de production | le test échoue, pour la raison attendue |
| Vert | le minimum qui fait passer | généraliser, nettoyer | tous les tests passent |
| Refactor | une structure meilleure | un comportement nouveau, un test nouveau | le code est propre, tous les tests passent |
Le rouge se vérifie, il ne se suppose pas. Un test qu'on n'a jamais vu échouer peut ne rien tester du tout : une assertion sur la mauvaise variable, une méthode oubliée par le runner. Et il doit échouer pour la bonne raison — une exception inattendue n'est pas le rouge qu'on cherchait. Une erreur de compilation, en revanche, compte : un test qui appelle une classe inexistante est rouge.
La règle du minimum est la plus contre-intuitive. Au vert, on écrit ce qui fait passer le test, pas ce qu'on sait qu'il faudra un jour : une constante si une constante suffit, un if en dur plutôt qu'un mécanisme. La raison est que chaque ligne de production doit être justifiée par un test qui échouait sans elle. Un cas seul ne dit pas quelle forme prendra la généralisation ; c'est le second exemple qui la révèle, et le code écrit avant lui n'est qu'une supposition.
Premier cycle : le total
Le premier test porte sur ce que le panier fait avant toute remise : additionner ses lignes. L'écrire décide déjà de l'interface — une classe Panier, une méthode Ajouter qui prend une référence, un prix unitaire et une quantité, une propriété Total en decimal, parce qu'un montant en double n'additionne pas exactement les centimes. Les arguments nommés ne sont là que pour que le test se lise comme une phrase.
using Xunit;
namespace Boutique.Tests;
public class TotalTests
{
[Fact]
public void Le_total_est_la_somme_des_lignes()
{
var panier = new Panier();
// Un panier vide vaut zero. A elle seule, cette assertion passerait
// avec un return 0m : c'est la suivante qui oblige a calculer.
Assert.Equal(0m, panier.Total);
panier.Ajouter("clavier", prixUnitaire: 45m, quantite: 1);
panier.Ajouter("cable", prixUnitaire: 7.5m, quantite: 2);
Assert.Equal(60m, panier.Total);
}
}Panier n'existe pas. Le test est lancé quand même, et la sortie confirme deux choses : le fichier est bien compilé avec la suite, et l'échec est celui qu'on attendait, pas un autre.
$ dotnet test
Restauration terminée (3,2s)
Boutique net10.0 a réussi (0,2s) → Boutique\bin\Debug\net10.0\Boutique.dll
Boutique.Tests net10.0 a échoué avec 1 erreur(s) (0,5s)
C:\boutique\Boutique.Tests\TotalTests.cs(10,26): error CS0246: Le nom de type ou d'espace de noms 'Panier' est introuvable (vous manque-t-il une directive using ou une référence d'assembly ?)
Générer a échoué avec 1 erreur(s) dans 4,5s L'implémentation minimale suit. Le test fait deux constats sur le même objet, vide puis garni. Le premier seul se ferait passer par Total => 0m ; le second, avec deux lignes dont une de quantité deux, oblige à multiplier et à additionner. C'est la triangulation : un exemple de plus, choisi pour qu'aucune triche ne passe les deux. Découper ces deux constats en deux [Fact] serait tout aussi juste.
using System.Collections.Generic;
using System.Linq;
namespace Boutique;
public sealed class Panier
{
private readonly List<(decimal PrixUnitaire, int Quantite)> lignes = [];
// La reference est recue parce que le test la passe, et ignoree parce
// qu'aucun test ne la lit encore. La garder serait deviner.
public void Ajouter(string reference, decimal prixUnitaire, int quantite) =>
lignes.Add((prixUnitaire, quantite));
public decimal Total => lignes.Sum(ligne => ligne.PrixUnitaire * ligne.Quantite);
} La référence est reçue et jetée. Aucun test ne la relit : la stocker, ce serait écrire du code qu'aucun échec n'a demandé. dotnet test termine alors sur Récapitulatif du test : total : 1; échec : 0; réussi : 1, puis Générer a réussi.
Deuxième cycle : la remise par montant
La règle suivante : dix pour cent de remise à partir de cent euros. Le test place le panier exactement à cent euros, et ce choix n'est pas neutre. À cent vingt, une implémentation qui écrirait > au lieu de >= passerait aussi. La valeur prise sur la borne est ce qui transforme « à partir de » en une décision vérifiée.
using Xunit;
namespace Boutique.Tests;
public class RemiseParMontantTests
{
[Fact]
public void Dix_pour_cent_de_remise_a_partir_de_cent_euros()
{
var panier = new Panier();
// Cent euros tout rond, et pas cent vingt : a 120, un > passerait
// aussi bien qu'un >=. C'est la valeur choisie qui fixe la borne.
panier.Ajouter("ecran", prixUnitaire: 100m, quantite: 1);
Assert.Equal(90m, panier.Total);
}
} Cette fois le rouge est une vraie assertion : xUnit rapporte Assert.Equal() Failure: Values differ, attendu 90, obtenu 100. C'est exactement l'échec voulu — le code actuel ignore toute remise — et il prouve que le test sait distinguer un panier remisé d'un panier qui ne l'est pas.
L'implémentation est naïve, et c'est assumé. Un if, un seuil et un taux écrits en dur. Aucune interface de règle, aucun palier configurable : un seul exemple de remise ne dit pas ce que les remises ont en commun, et une abstraction tirée d'un seul cas est un pari.
using System.Collections.Generic;
using System.Linq;
namespace Boutique;
public sealed class Panier
{
private readonly List<(decimal PrixUnitaire, int Quantite)> lignes = [];
public void Ajouter(string reference, decimal prixUnitaire, int quantite) =>
lignes.Add((prixUnitaire, quantite));
public decimal Total
{
get
{
var brut = lignes.Sum(ligne => ligne.PrixUnitaire * ligne.Quantite);
// Le seuil et le taux sont ecrits en dur, dans un if. Aucun test
// ne demande encore de palier configurable : en inventer un
// maintenant, ce serait coder un besoin que personne n'a exprime.
if (brut >= 100m)
{
return brut * 0.90m;
}
return brut;
}
}
}Le premier test tourne toujours, et c'est lui qui garantit que la remise n'a rien cassé : son panier à soixante euros reste sous le seuil. Deux tests au vert, et le filet commence à servir.
Troisième cycle : le client fidèle
Un client fidèle a une remise différente : quinze pour cent dès cinquante euros. Pour l'écrire, le test doit dire comment un panier sait que son client est fidèle, et il choisit le moyen le plus direct, un argument de constructeur. Le panier est de nouveau posé sur la borne, à cinquante euros, où il vaut quarante-deux cinquante.
using Xunit;
namespace Boutique.Tests;
public class ClientFideleTests
{
[Fact]
public void Un_client_fidele_a_quinze_pour_cent_des_cinquante_euros()
{
// Le test a du decider comment un panier apprend que son client est
// fidele. Il l'a fait de la facon la plus directe : un argument.
var panier = new Panier(clientFidele: true);
panier.Ajouter("casque", prixUnitaire: 50m, quantite: 1);
Assert.Equal(42.5m, panier.Total);
}
} Le rouge est de nouveau une erreur de compilation, CS1739 : la meilleure surcharge de Panier n'a pas de paramètre nommé clientFidele. Pour le vert, le constructeur reçoit un booléen avec false par défaut, ce qui laisse les deux tests précédents compiler sans modification — et le code devient laid.
using System.Collections.Generic;
using System.Linq;
namespace Boutique;
public sealed class Panier
{
private readonly List<(decimal PrixUnitaire, int Quantite)> lignes = [];
private readonly bool clientFidele;
// Valeur par defaut a false : les deux tests precedents ecrivent
// new Panier() et compilent toujours sans qu'on y touche.
public Panier(bool clientFidele = false) => this.clientFidele = clientFidele;
public void Ajouter(string reference, decimal prixUnitaire, int quantite) =>
lignes.Add((prixUnitaire, quantite));
public decimal Total
{
get
{
var brut = lignes.Sum(ligne => ligne.PrixUnitaire * ligne.Quantite);
// Laid, et assume : deux branches de meme forme, un seuil et un
// taux dans chacune, deux return brut. Le refactor viendra une
// fois le vert obtenu, pas avant.
if (clientFidele)
{
if (brut >= 50m)
{
return brut * 0.85m;
}
return brut;
}
if (brut >= 100m)
{
return brut * 0.90m;
}
return brut;
}
}
} Deux branches de même forme, un seuil et un taux dans chacune, deux return brut, un if dans un if. On le voit, et on le laisse jusqu'au vert. Changer la structure pendant qu'on change le comportement, c'est perdre le moyen de savoir lequel des deux a cassé un test : si le vert n'arrive pas, la faute peut venir de la règle nouvelle ou du nettoyage, et plus rien ne le dit. Obtenir le vert d'abord, c'est se donner trois tests qui décrivent le comportement exact avant de toucher à la forme.
Il y a une seconde raison, plus profonde : la duplication ne devient lisible qu'à ce moment. Au cycle précédent, un seul if ne répétait rien. Avec deux branches côte à côte, la forme commune apparaît — un seuil, un taux, un montant qui passe ou non — et c'est elle que le refactor va nommer.
Le refactor
Le refactor extrait ce que les deux branches partageaient. Une interface IRegleRemise prend un montant brut et rend le montant à payer. Une seule classe, RemiseAPartirDe, la réalise avec un seuil et un taux ; les deux paliers deviennent deux instances au lieu de deux blocs de code. Le panier choisit sa règle une fois, à la construction, et Total se réduit à une ligne.
using System.Collections.Generic;
using System.Linq;
namespace Boutique;
// Ce que les deux branches du troisieme cycle avaient en commun, nomme :
// une regle recoit un montant brut et rend le montant a payer.
public interface IRegleRemise
{
decimal Appliquer(decimal brut);
}
// Les deux branches ne differaient que par deux nombres. Une seule classe les
// couvre, et chaque palier devient une instance au lieu d'un if.
public sealed class RemiseAPartirDe(decimal seuil, decimal taux) : IRegleRemise
{
public decimal Appliquer(decimal brut) => brut >= seuil ? brut * (1 - taux) : brut;
}
public sealed class Panier
{
private static readonly IRegleRemise RemiseStandard = new RemiseAPartirDe(100m, 0.10m);
private static readonly IRegleRemise RemiseFidelite = new RemiseAPartirDe(50m, 0.15m);
private readonly List<(decimal PrixUnitaire, int Quantite)> lignes = [];
private readonly IRegleRemise regle;
// La signature publique n'a pas bouge : c'est la condition pour que les
// trois tests restent tels quels. Le booleen ne sert plus qu'a choisir la
// regle, une fois, a la construction.
public Panier(bool clientFidele = false) =>
regle = clientFidele ? RemiseFidelite : RemiseStandard;
public void Ajouter(string reference, decimal prixUnitaire, int quantite) =>
lignes.Add((prixUnitaire, quantite));
public decimal Total =>
regle.Appliquer(lignes.Sum(ligne => ligne.PrixUnitaire * ligne.Quantite));
} Aucun test n'a changé. Ce n'est pas ce qui définit un refactor : renommer une méthode ou remplacer un argument booléen par un appel explicite en sont aussi, et les tests y suivent l'appel mécaniquement, sans rouge, sans rien changer de ce qu'ils vérifient. C'est le choix de cet exercice. La signature publique — le constructeur à booléen optionnel, Ajouter, Total — est restée identique, et les trois fichiers de test, réutilisés tels quels, prouvent que le comportement n'a pas bougé.
Les trois tests passent avant le refactor, et passent après. Sur une vraie base de code, on ne fait pas l'extraction d'un bloc : on crée l'interface, on relance, on déplace la première branche, on relance, puis la seconde. Chaque pas est assez petit pour qu'un rouge désigne la dernière modification.
$ dotnet test
Restauration terminée (3,1s)
Boutique net10.0 a réussi (0,2s) → Boutique\bin\Debug\net10.0\Boutique.dll
Boutique.Tests net10.0 a réussi (0,3s) → Boutique.Tests\bin\Debug\net10.0\Boutique.Tests.dll
[xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v3.1.4+50e68bbb8b (64-bit .NET 10.0.8)
[xUnit.net 00:00:00.12] Discovering: Boutique.Tests
[xUnit.net 00:00:00.17] Discovered: Boutique.Tests
[xUnit.net 00:00:00.20] Starting: Boutique.Tests
[xUnit.net 00:00:00.28] Finished: Boutique.Tests
Test de Boutique.Tests net10.0 : a réussi (1,4 s)
Récapitulatif du test : total : 3; échec : 0; réussi : 3; ignoré : 0; durée : 1,4s
Générer a réussi dans 5,4s Le récapitulatif compte les tests sans dire dans quel ordre ils ont tourné. Les trois tests sont dans trois classes, donc trois collections, qu'xUnit exécute en parallèle par défaut : leur ordre relatif peut changer d'une exécution à l'autre. Dans une même classe, l'ordre est stable mais ne suit pas forcément celui du fichier, comme le montre le cours static partagé. Les deux règles static du refactor n'en posent pas, parce qu'aucun code ne les modifie.
Ce que les tests ont dit de la conception
Chaque test a posé une question avant de recevoir une réponse. Le premier a fixé l'interface du panier depuis l'appelant. Le deuxième, en se plaçant sur la borne, a transformé une formule vague en décision : cent euros tout rond ouvre la remise. Ce sont des questions pour le métier, et le test les pose au moment où on peut encore y répondre à peu de frais.
Le troisième a été le plus difficile à écrire, et cette difficulté était le vrai signal. Pour dire « client fidèle », il a fallu ajouter un booléen au constructeur. Un booléen qui bascule un comportement indique qu'une classe fait deux métiers : le panier additionnait des lignes et décidait en plus d'une politique commerciale. Pour vérifier la règle de fidélité à quarante-neuf euros quatre-vingt-dix-neuf, il aurait fallu construire un panier, y ajouter une ligne, lire son total — tout un détour pour tester deux nombres. Ce détour désignait un concept manquant, la règle de remise, que le refactor a fini par nommer. Depuis, RemiseAPartirDe se teste seule, sans panier, sur les deux côtés de la borne.
using Xunit;
namespace Boutique.Tests;
public class RemiseAPartirDeTests
{
// Depuis l'extraction, la regle se teste sans panier : un montant en
// entree, un en sortie, et la borne se verifie des deux cotes. Un
// attribut ne peut pas porter de decimal : xUnit convertit ces double.
[Theory]
[InlineData(49.99, 49.99)]
[InlineData(50, 42.5)]
public void La_remise_ne_s_applique_qu_a_partir_du_seuil(decimal brut, decimal attendu)
{
var regle = new RemiseAPartirDe(seuil: 50m, taux: 0.15m);
Assert.Equal(attendu, regle.Appliquer(brut));
}
} Ces deux cas passent au premier lancement, puisque la règle existe déjà : ils ne pilotent rien, ils documentent. Pour leur faire confiance, on remplace un instant >= par > dans Appliquer : le cas à cinquante euros passe au rouge, avec les deux tests de panier posés sur une borne, et le test a prouvé qu'il sait échouer.
Les tests disent aussi ce qu'ils ne disent pas. Aucun ne fixe le sort d'un client fidèle à cent vingt euros : quinze pour cent, ou dix puis quinze ? Le code répond quinze, par un accident de sa structure et non par une décision. Le prochain test sera celui-là, après une question au métier. Et si une troisième règle arrive, la question suivante sera de laisser l'appelant fournir sa règle au panier plutôt qu'un booléen. Ce serait encore un refactor : les tests adapteraient la façon de construire le panier, sans rien changer de ce qu'ils vérifient.
Le même raisonnement vaut au-delà de cet exemple. Un test qui exige une base de données, une horloge ou un fichier pour vérifier un calcul signale une dépendance cachée dans le code testé. La réponse n'est pas un meilleur outillage de test, c'est une conception qui sépare le calcul de ce qui l'alimente.