Test-Driven Development (TDD)
Aperçu
Écrivez le test en premier. Regardez-le échouer. Écrivez le code minimal pour 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 bugs
- Refactorisation
- Changements de comportement
Exceptions (consultez votre partenaire humain) :
- Prototypes jetables
- Code généré
- Fichiers de configuration
Vous pensez « sauter TDD juste cette fois » ? Arrêtez. C'est de la rationalisation.
La Loi de fer
AUCUN CODE DE PRODUCTION SANS UN TEST ÉCHOUANT D'ABORD
Écrire du code avant le test ? Supprimez-le. Recommencez.
Sans exceptions :
- 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 à nouveau à partir des tests. Point.
Red-Green-Refactor
digraph tdd_cycle {
rankdir=LR;
red [label="RED\nÉcrire un test échouant", shape=box, style=filled, fillcolor="#ffcccc"];
verify_red [label="Vérifier que\ncela échoue", shape=diamond];
green [label="GREEN\nCode minimal", shape=box, style=filled, fillcolor="#ccffcc"];
verify_green [label="Vérifier que\nc'est au vert", shape=diamond];
refactor [label="REFACTOR\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\nau vert"];
verify_green -> next;
next -> red;
}
RED - Écrire un test échouant
Écrivez un 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 le 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 RED - 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 de typos)
Le test passe ? Vous testez un comportement existant. Corrigez le test.
Le test génère une erreur ? Corrigez l'erreur, relancez jusqu'à ce qu'il échoue correctement.
GREEN - Code minimal
Écrivez le code le plus simple pour 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énierie </Bad>
N'ajoutez pas de fonctionnalités, ne refactorisez pas d'autre code, et ne « améliorez » pas au-delà du test.
Vérifier GREEN - Regardez-le passer
OBLIGATOIRE.
npm test path/to/test.test.ts
Confirmez :
- Le test passe
- Les autres tests passent toujours
- Résultat pristine (pas d'erreurs, d'avertissements)
Le test échoue ? Corrigez le code, pas le test.
D'autres tests échouent ? Corrigez maintenant.
REFACTOR - Nettoyage
Après le vert seulement :
- Supprimez la duplication
- Améliorez les noms
- Extrayez les helpers
Gardez les tests au vert. N'ajoutez pas de comportement.
Répétez
Test échouant suivant pour la fonctionnalité suivante.
Bons tests
| Qualité | Bon | Mauvais |
|---|---|---|
| Minimal | Une seule 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 | Obscurcit 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 réussissent immédiatement. Réussir immédiatement ne prouve rien :
- Peut tester la mauvaise chose
- Peut tester l'implémentation, pas le comportement
- Peut manquer les cas limites que vous avez oubliés
- Vous ne l'avez jamais vu attraper le bug
Test-first vous force à voir le test échouer, prouvant qu'il teste réellement quelque chose.
« J'ai déjà testé manuellement tous les cas limites »
Les tests manuels sont ad-hoc. Vous pensez avoir tout testé, mais :
- Aucune trace de ce que vous avez testé
- Impossible de relancer quand le code change
- Facile d'oublier les cas sous pression
- « Ça a fonctionné quand j'ai essayé » ≠ complet
Les tests automatisés sont systématiques. Ils s'exécutent de la même façon à chaque fois.
« 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 :
- Supprimez et réécrivez avec TDD (X heures de plus, haute confiance)
- Gardez-le et ajoutez les tests après (30 minutes, faible confiance, bugs probables)
Le « gaspillage » consiste à garder du code en lequel vous ne pouvez pas avoir confiance. Du code fonctionnel sans vrais tests est de la dette technique.
« TDD est dogmatique, être pragmatique signifie s'adapter »
TDD EST pragmatique :
- Trouve les bugs avant le 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 (modifiez librement, les tests attrapent les ruptures)
Les raccourcis « pragmatiques » = déboguer en production = plus lent.
« Les tests après atteignent les mêmes objectifs - c'est l'esprit et non le rituel »
Non. Les tests-après répondent « Qu'est-ce que cela fait ? » Les tests-first répondent « Qu'est-ce que cela 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 avez découverts.
Les tests-first forcent la découverte des cas limites avant l'implémentation. Les tests-après vérifient que vous avez tout retenu (vous ne l'avez pas).
30 minutes de tests après ≠ TDD. Vous obtenez de la couverture, perdez la preuve que les tests fonctionnent.
Rationalisations courantes
| Excuse | Réalité |
|---|---|
| « Trop simple pour tester » | Le code simple casse. Un test prend 30 secondes. |
| « Je vais tester 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 cela fait ? » Tests-first = « Qu'est-ce que cela devrait faire ? » |
| « Déjà testé manuellement » | Ad-hoc ≠ systématique. Aucune trace, impossible de relancer. |
| « Supprimer X heures, c'est du gaspillage » | Sophisme du coût irrécupérable. Garder du code non vérifié est de la dette technique. |
| « Garder comme référence, écrire les tests en premier » | Vous l'adapterez. C'est tester après. Supprimer signifie supprimer. |
| « Besoin d'explorer d'abord » | D'accord. Jetez l'exploration, commencez avec TDD. |
| « Test difficile = design peu clair » | Écoutez le test. Difficile à tester = difficile à utiliser. |
| « TDD va me ralentir » | TDD plus rapide que déboguer. Pragmatique = test-first. |
| « Test manuel plus rapide » | Manuel ne prouve pas les cas limites. Vous testerez à nouveau à 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 »
- Rationaliser « juste cette fois »
- « J'ai déjà testé manuellement »
- « Les tests après atteignent le même objectif »
- « C'est une question d'esprit, pas de rituel »
- « Garder comme référence » ou « adapter du code existant »
- « Déjà dépensé X heures, supprimer c'est du gaspillage »
- « TDD est dogmatique, je suis pragmatique »
- « C'est différent parce que... »
Tout cela signifie : Supprimez le code. Recommencez avec TDD.
Exemple : Correction de bug
Bug : Email vide accepté
RED
test('rejects empty email', async () => {
const result = await submitForm({ email: '' });
expect(result.error).toBe('Email required');
});
Vérifier RED
$ npm test
FAIL: expected 'Email required', got undefined
GREEN
function submitForm(data: FormData) {
if (!data.email?.trim()) {
return { error: 'Email required' };
}
// ...
}
Vérifier GREEN
$ npm test
PASS
REFACTOR Extrayez la validation pour plusieurs champs si nécessaire.
Liste de vérification de vérification
Avant de marquer le travail comme terminé :
- [ ] Chaque nouvelle fonction/méthode a un test
- [ ] Regardé chaque test échouer avant d'implémenter
- [ ] Chaque test a échoué pour la raison attendue (fonctionnalité manquante, pas typo)
- [ ] Écrit le code minimal pour passer chaque test
- [ ] Tous les tests passent
- [ ] Résultat pristine (pas d'erreurs, d'avertissements)
- [ ] Les tests utilisent du code réel (mocks seulement si inévitable)
- [ ] Cas limites et erreurs couverts
Impossible de 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 d'abord l'assertion. Consultez votre partenaire humain. |
| Test trop compliqué | Design trop compliqué. Simplifiez l'interface. |
| Dois mocker tout | Code trop couplé. Utilisez l'injection de dépendances. |
| Configuration de test énorme | Extrayez les helpers. Toujours complexe ? Simplifiez la conception. |
Intégration du débogage
Bug 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 bugs sans un test.
Anti-modèles de test
Lors de l'ajout de mocks ou d'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 test-uniquement aux classes de production
- Mocker sans comprendre les dépendances
Règle finale
Code de production → le test existe et a échoué en premier
Sinon → pas TDD
Aucune exception sans la permission de votre partenaire humain.