Développement piloté par les tests (TDD)
Aperçu
Écrivez le test en premier. Regardez-le échouer. Écrivez le code minimal pour le faire passer.
Principe fondamental : Si vous n'avez pas regardé le test échouer, vous ne savez pas s'il teste la bonne chose.
Violer la lettre des règles, c'est violer l'esprit des règles.
Quand l'utiliser
Toujours :
- Nouvelles fonctionnalités
- Corrections de bogues
- Refactorisation
- Changements de comportement
Exceptions (à discuter avec votre partenaire humain) :
- Prototypes jetables
- Code généré
- Fichiers de configuration
Vous pensez « sauter le TDD juste cette fois » ? Stop. C'est de la rationalisation.
La loi de fer
PAS DE CODE DE PRODUCTION SANS UN TEST ÉCHOUANT EN PREMIER
Écrire du code avant le test ? Supprimez-le. Recommencez.
Aucune exception :
- Ne le gardez pas comme « référence »
- Ne l'« adaptez » pas en écrivant les tests
- Ne le regardez pas
- Supprimer signifie supprimer
Implémentez à partir des zéro en partant des tests. Point final.
Rouge-Vert-Refactoriser
digraph tdd_cycle {
rankdir=LR;
red [label="ROUGE\nÉcrire un test échouant", shape=box, style=filled, fillcolor="#ffcccc"];
verify_red [label="Vérifier l'échec\ncorrect", shape=diamond];
green [label="VERT\nCode minimal", shape=box, style=filled, fillcolor="#ccffcc"];
verify_green [label="Vérifier le passage\nTout au vert", shape=diamond];
refactor [label="REFACTORISER\nNettoyage", shape=box, style=filled, fillcolor="#ccccff"];
next [label="Suivant", shape=ellipse];
red -> verify_red;
verify_red -> green [label="oui"];
verify_red -> red [label="mauvais\néchec"];
green -> verify_green;
verify_green -> refactor [label="oui"];
verify_green -> green [label="non"];
refactor -> verify_green [label="rester\nauvert"];
verify_green -> next;
next -> red;
}
ROUGE - Écrire un test échouant
Écrivez un seul test minimal montrant ce qui devrait se passer.
<Good>
test('retries failed operations 3 times', async () => {
let attempts = 0;
const operation = () => {
attempts++;
if (attempts < 3) throw new Error('fail');
return 'success';
};
const result = await retryOperation(operation);
expect(result).toBe('success');
expect(attempts).toBe(3);
});
Nom clair, teste un vrai comportement, une seule chose </Good>
<Bad>
test('retry works', async () => {
const mock = jest.fn()
.mockRejectedValueOnce(new Error())
.mockRejectedValueOnce(new Error())
.mockResolvedValueOnce('success');
await retryOperation(mock);
expect(mock).toHaveBeenCalledTimes(3);
});
Nom vague, teste le mock et non le code </Bad>
Exigences :
- Un comportement
- Nom clair
- Code réel (pas de mocks sauf si inévitable)
Vérifier ROUGE - Regardez-le échouer
OBLIGATOIRE. Ne sautez jamais.
npm test path/to/test.test.ts
Confirmez :
- Le test échoue (pas d'erreurs)
- Le message d'échec est attendu
- Échoue parce que la fonctionnalité manque (pas des typos)
Le test passe ? Vous testez un comportement existant. Corrigez le test.
Le test errore ? Corrigez l'erreur, réexécutez jusqu'à ce qu'il échoue correctement.
VERT - Code minimal
Écrivez le code le plus simple pour faire passer le test.
<Good>
async function retryOperation<T>(fn: () => Promise<T>): Promise<T> {
for (let i = 0; i < 3; i++) {
try {
return await fn();
} catch (e) {
if (i === 2) throw e;
}
}
throw new Error('unreachable');
}
Juste assez pour passer </Good>
<Bad>
async function retryOperation<T>(
fn: () => Promise<T>,
options?: {
maxRetries?: number;
backoff?: 'linear' | 'exponential';
onRetry?: (attempt: number) => void;
}
): Promise<T> {
// YAGNI
}
Sur-ingéniérisé </Bad>
N'ajoutez pas de fonctionnalités, ne refactorisez pas d'autre code, et n'« améliorez » rien au-delà du test.
Vérifier VERT - Regardez-le passer
OBLIGATOIRE.
npm test path/to/test.test.ts
Confirmez :
- Le test passe
- Les autres tests passent toujours
- La sortie propre (pas d'erreurs, d'avertissements)
Le test échoue ? Corrigez le code, pas le test.
D'autres tests échouent ? Corrigez immédiatement.
REFACTORISER - Nettoyer
Après le vert seulement :
- Supprimez la duplication
- Améliorez les noms
- Extrayez des helpers
Gardez les tests au vert. N'ajoutez pas de comportement.
Répéter
Test échouant suivant pour la fonctionnalité suivante.
Bons tests
| Qualité | Bon | Mauvais |
|---|---|---|
| Minimal | Une chose. « et » dans le nom ? Divisez-le. | test('validates email and domain and whitespace') |
| Clair | Le nom décrit le comportement | test('test1') |
| Montre l'intention | Démontre l'API souhaitée | Cache ce que le code devrait faire |
Pourquoi l'ordre compte
« J'écrirai les tests après pour vérifier que ça fonctionne »
Les tests écrits après le code passent immédiatement. Passer immédiatement ne prouve rien :
- Peut tester la mauvaise chose
- Peut tester l'implémentation, pas le comportement
- Peut rater les cas limites que vous avez oubliés
- Vous ne l'avez jamais vu attraper le bogue
Écrire le test en premier vous oblige à voir le test échouer, prouvant qu'il teste vraiment quelque chose.
« J'ai déjà testé manuellement tous les cas limites »
Les tests manuels sont ad-hoc. Vous pensez avoir tout testé mais :
- Pas de trace de ce que vous avez testé
- Impossible de réexécuter quand le code change
- Facile d'oublier des cas sous pression
- « Ça a marché quand j'ai essayé » ≠ complet
Les tests automatisés sont systématiques. Ils fonctionnent toujours de la même façon.
« Supprimer X heures de travail, c'est du gaspillage »
Sophisme du coût irrécupérable. Le temps est déjà parti. Votre choix maintenant :
- Supprimer et réécrire avec TDD (X heures de plus, haute confiance)
- Le garder et ajouter des tests après (30 min, faible confiance, bugs probables)
Le « gaspillage » est de garder du code en qui vous ne pouvez pas faire confiance. Du code qui fonctionne sans vrais tests est une dette technique.
« TDD est dogmatique, être pragmatique signifie s'adapter »
TDD EST pragmatique :
- Trouve les bogues avant commit (plus rapide que déboguer après)
- Prévient les régressions (les tests attrapent les ruptures immédiatement)
- Documente le comportement (les tests montrent comment utiliser le code)
- Permet la refactorisation (changez librement, les tests attrapent les ruptures)
Les raccourcis « pragmatiques » = débogage en production = plus lent.
« Les tests après atteignent les mêmes objectifs - c'est l'esprit pas le rituel »
Non. Les tests-après répondent « Qu'est-ce que ça fait ? » Les tests-d'abord répondent « Qu'est-ce que ça devrait faire ? »
Les tests-après sont biaisés par votre implémentation. Vous testez ce que vous avez construit, pas ce qui est requis. Vous vérifiez les cas limites dont vous vous souvenez, pas ceux que vous découvrez.
Les tests-d'abord forcent la découverte des cas limites avant d'implémenter. Les tests-après vérifient que vous vous souvenez de tout (vous ne vous en souvenez pas).
30 minutes de tests après ≠ TDD. Vous obtenez la couverture, vous perdez la preuve que les tests fonctionnent.
Rationalisations courantes
| Excuse | Réalité |
|---|---|
| « Trop simple à tester » | Le code simple se casse. Le test prend 30 secondes. |
| « Je testerai après » | Les tests passant immédiatement ne prouvent rien. |
| « Les tests après atteignent les mêmes objectifs » | Tests-après = « qu'est-ce que ça fait ? » Tests-d'abord = « qu'est-ce que ça devrait faire ? » |
| « Déjà testé manuellement » | Ad-hoc ≠ systématique. Pas de trace, impossible de réexécuter. |
| « Supprimer X heures est du gaspillage » | Sophisme du coût irrécupérable. Garder du code non vérifié est une dette technique. |
| « Garder comme référence, écrire les tests d'abord » | Vous l'adapterez. C'est tester après. Supprimer signifie supprimer. |
| « Besoin d'explorer d'abord » | Bien. Jetez l'exploration, démarrez avec TDD. |
| « Test difficile = design peu clair » | Écoutez le test. Difficile à tester = difficile à utiliser. |
| « TDD me ralentira » | TDD plus rapide que déboguer. Pragmatique = tester d'abord. |
| « Test manuel plus rapide » | Manual ne prouve pas les cas limites. Vous retesterez à chaque changement. |
| « Le code existant n'a pas de tests » | Vous l'améliorez. Ajoutez des tests pour le code existant. |
Signaux d'alerte - ARRÊTEZ et recommencez
- Code avant test
- Test après implémentation
- Le test passe immédiatement
- Impossible d'expliquer pourquoi le test a échoué
- Tests ajoutés « plus tard »
- Rationalisation « juste cette fois »
- « Je l'ai déjà testé manuellement »
- « Les tests après atteignent le même objectif »
- « C'est à propos de l'esprit pas du rituel »
- « Garder comme référence » ou « adapter le code existant »
- « J'ai déjà passé X heures, supprimer est du gaspillage »
- « TDD est dogmatique, je suis pragmatique »
- « C'est différent parce que... »
Tous ces points signifient : Supprimez le code. Recommencez avec TDD.
Exemple : Correction de bogue
Bogue : Email vide accepté
ROUGE
test('rejects empty email', async () => {
const result = await submitForm({ email: '' });
expect(result.error).toBe('Email required');
});
Vérifier ROUGE
$ npm test
FAIL: expected 'Email required', got undefined
VERT
function submitForm(data: FormData) {
if (!data.email?.trim()) {
return { error: 'Email required' };
}
// ...
}
Vérifier VERT
$ npm test
PASS
REFACTORISER Extrayez la validation pour plusieurs champs si nécessaire.
Liste de vérification de la vérification
Avant de marquer le travail comme terminé :
- [ ] Chaque nouvelle fonction/méthode a un test
- [ ] Vous avez regardé chaque test échouer avant d'implémenter
- [ ] Chaque test a échoué pour la raison attendue (fonctionnalité manquante, pas typo)
- [ ] Vous avez écrit le code minimal pour faire passer chaque test
- [ ] Tous les tests passent
- [ ] La sortie est propre (pas d'erreurs, d'avertissements)
- [ ] Les tests utilisent du code réel (mocks seulement si inévitable)
- [ ] Les cas limites et erreurs sont couverts
Vous ne pouvez pas cocher toutes les cases ? Vous avez sauté TDD. Recommencez.
Quand vous êtes bloqué
| Problème | Solution |
|---|---|
| Ne sais pas comment tester | Écrivez l'API souhaitée. Écrivez l'assertion d'abord. Demandez à votre partenaire humain. |
| Test trop compliqué | Design trop compliqué. Simplifiez l'interface. |
| Dois tout mockeriser | Code trop couplé. Utilisez l'injection de dépendances. |
| Configuration des tests énorme | Extrayez des helpers. Toujours complexe ? Simplifiez le design. |
Intégration du débogage
Bogue trouvé ? Écrivez un test échouant le reproduisant. Suivez le cycle TDD. Le test prouve la correction et prévient la régression.
Ne corrigez jamais les bogues sans un test.
Anti-motifs de test
Lors de l'ajout de mocks ou utilitaires de test, lisez @testing-anti-patterns.md pour éviter les pièges courants :
- Tester le comportement du mock au lieu du comportement réel
- Ajouter des méthodes de test uniquement aux classes de production
- Mockeriser sans comprendre les dépendances
Règle finale
Code de production → test existe et a échoué d'abord
Sinon → pas TDD
Aucune exception sans la permission de votre partenaire humain.