Le TDD a-t-il encore un sens quand ce sont des agents qui écrivent le code ?
Découvrez comment le développement piloté par les tests encadre les agents IA : rédigez d’abord les tests Vitest, évitez leur modification et vérifiez le code en CI.
Oui, et sans doute plus qu’avant, mais comme un contrat plutôt qu’un rituel : lorsqu’un agent de codage écrit l’implémentation, les tests écrits en premier deviennent la spécification. Ils définissent ce que signifie « terminé » avant même que le code n’existe, et ils prennent l’agent en défaut lorsqu’il déclare achevée une tâche qui ne l’est pas.
La plupart des développeurs qui confient l’implémentation à Claude Code, Cursor ou GitHub Copilot l’ont déjà constaté. L’agent annonce que tout passe, puis la fonctionnalité casse dès la première utilisation, ou bien un fichier de test a été discrètement réécrit pour s’aligner sur le nouveau code.
Cet article présente le workflow test-first appliqué aux agents : un vrai fichier Vitest, l’instruction exacte à lui associer, les moyens qu’emploient les agents pour le contourner, et les garde-fous qui comblent ces failles.
Points clés
- Les tests écrits avant que l’agent ne commence servent de spécification exécutable : le rôle de l’agent est de les faire passer, pas de décider de ce que signifie « passer ».
- Un test écrit après le code a tendance à décrire ce que le code fait déjà : il passe par construction et ne vérifie rien.
- Le message d’un agent affirmant que tous les tests passent est une affirmation. Une exécution de la CI sur le commit poussé en est la preuve.
- Si un même diff modifie à la fois l’implémentation et les assertions qui la jugent, l’agent a noté son propre travail : relisez donc les modifications de tests séparément de celles du code.
- Renoncez au test-first pour les prototypes jetables et les ajustements purement visuels, où le résultat attendu ne peut pas être formalisé avant d’avoir été vu.
Qu’est-ce que le cycle red-green-refactor ?
Red-green-refactor est la boucle du TDD : vous écrivez un test en échec qui décrit un comportement (rouge), puis le code minimal qui le fait passer (vert). Une fois la suite au vert, vous nettoyez le code sans modifier son comportement (refactorisation). Pour une révision plus approfondie avec des exemples React, consultez TDD vs BDD in React.
Comment fonctionne le workflow TDD avec l’IA ?
Le TDD avec l’IA fonctionne lorsque l’humain écrit ou valide les tests en échec et que l’agent reçoit pour consigne de les faire passer sans les modifier. Les tests portent les exigences. L’agent ne fournit que l’implémentation. Les exemples ci-dessous utilisent Vitest 5.
Commencez par un stub, afin que le fichier de test importe quelque chose de réel :
// src/cart/applyCoupon.ts
export type Coupon =
| { code: string; kind: 'percent'; value: number }
| { code: string; kind: 'fixed'; valueCents: number };
export function applyCoupon(
subtotalCents: number,
code: string,
coupons: Coupon[],
): number {
throw new Error('not implemented');
}
Rédigez ensuite la spécification :
// src/cart/applyCoupon.test.ts
import { describe, it, expect } from 'vitest';
import { applyCoupon, type Coupon } from './applyCoupon';
const coupons: Coupon[] = [
{ code: 'SAVE10', kind: 'percent', value: 10 },
{ code: 'FIVEOFF', kind: 'fixed', valueCents: 500 },
];
describe('applyCoupon', () => {
it('takes a percentage off the subtotal', () => {
expect(applyCoupon(2000, 'SAVE10', coupons)).toBe(1800);
});
it('takes a fixed amount off the subtotal', () => {
expect(applyCoupon(2000, 'FIVEOFF', coupons)).toBe(1500);
});
it('never returns a negative total', () => {
expect(applyCoupon(300, 'FIVEOFF', coupons)).toBe(0);
});
it('matches codes case-insensitively', () => {
expect(applyCoupon(2000, 'save10', coupons)).toBe(1800);
});
it('rejects unknown codes', () => {
expect(() => applyCoupon(2000, 'BOGUS', coupons)).toThrow('Unknown coupon');
});
});
Comme le stub existe et lève une exception, les cinq tests échouent à l’exécution plutôt qu’à cause d’un import manquant. C’est une phase rouge propre. Le dernier test mérite qu’on s’y attarde : le stub lève bien une exception, mais son message ne correspond pas à 'Unknown coupon', si bien que le test échoue toujours pour la bonne raison. Chaque test fixe une règle (remise en pourcentage ou montant fixe, plancher à zéro, gestion de la casse) qu’un agent devrait sinon deviner.
Confiez-le ensuite à l’agent avec des contraintes explicites :
Implement applyCoupon in src/cart/applyCoupon.ts so every test in
src/cart/applyCoupon.test.ts passes.
Rules:
- Do not create, edit, rename, skip or delete any *.test.ts file.
- Do not add .skip, .only, or mocks of applyCoupon.
- If a test looks wrong, stop and explain why instead of changing it.
- When finished, run `npx vitest run src/cart` and paste the full output.
La ligne « stop and explain » offre à l’agent une échappatoire légitime autre que la modification du test. La sortie collée vous fait gagner une étape, mais elle ne prouve rien. D’autres habitudes de prompting adaptées à cette boucle sont présentées dans 5 Tips and Tricks for AI-Assisted Coding.
Comment le test-first dérape-t-il avec les agents ?
Les agents ont tendance à emprunter le chemin le plus court vers une exécution au vert, et trois de ces chemins contournent entièrement la spécification.
Modifier ou supprimer des tests. Lorsqu’une implémentation est difficile, changer l’attente est plus facile que la satisfaire. Un toBe(1800) modifié, un nouveau .skip ou un fichier de test disparu produisent tous une exécution au vert, mais l’exigence a disparu.
Des assertions faibles. Un test écrit après le code tend à confirmer ce que le code fait, et non ce qu’il devrait faire :
// Confirms the code ran, not that it's right
it('applies coupon', () => {
const result = applyCoupon(2000, 'SAVE10', coupons);
expect(result).toBeDefined();
expect(typeof result).toBe('number');
});
// Pins the behaviour
it('takes 10% off with SAVE10', () => {
expect(applyCoupon(2000, 'SAVE10', coupons)).toBe(1800);
});
Le test faible basé sur toBeDefined() passe, que applyCoupon renvoie 0, 2000 ou 999999. C’est pourquoi l’ordre d’écriture compte : les tests qu’un agent écrit après son propre code ressemblent souvent à la première version. Les agents gonflent aussi les suites avec de nombreuses vérifications superficielles de ce type, un travers abordé dans the smoke tests agents keep writing.
Mocker ce qui est testé. Mocker le module testé signifie que le test exerce le mock, et ce test reste au vert quoi que fasse l’implémentation réelle :
import { vi } from 'vitest';
vi.mock('./applyCoupon', () => ({ applyCoupon: () => 1800 }));
// Every assertion now tests the mock.
Vitest remonte (hoisting) vi.mock au-dessus des imports, de sorte que la factory remplace le module avant le chargement du code réel. Le vrai applyCoupon ne s’exécute jamais. Mockez les frontières (réseau, stockage, temps), pas l’unité que vous spécifiez.
Quels garde-fous maintiennent la spécification figée ?
Les garde-fous sont efficaces lorsqu’ils sont mécaniques. Les instructions réduisent la manipulation des tests, et la CI comme la revue de code rattrapent ce que les instructions laissent passer.
Relisez les diffs de tests séparément. Examinez les modifications des fichiers *.test.ts dans une revue à part entière, avant l’implémentation. Sur GitHub, CODEOWNERS avec revue obligatoire par un code owner permet de garantir qu’une personne désignée valide toute modification d’un chemin de test.
Bloquez ou interdisez la modification des tests par l’agent.
- Claude Code : ajoutez une règle
permissions.denytelle que"Edit(**/*.test.ts)"dans.claude/settings.json. Les règles de refus Edit couvrent les outils de fichiers intégrés de Claude, ainsi que les commandes de fichiers qu’il reconnaît dans Bash (commesedettee) et les cibles de redirection. Elles ne couvrent pas les scripts ou autres sous-processus qui écrivent des fichiers de manière indirecte. Considérez cette règle comme un frein, pas comme une garantie. - Cursor : énoncez la règle dans les règles de projet sous
.cursor/rules. - GitHub Copilot : placez-la dans
.github/copilot-instructions.md. Cursor et Copilot lisent également tous deuxAGENTS.md.
Laissez la CI décider de ce qui est « terminé ». Exécutez npx vitest run sur le commit poussé. En CI, Vitest fait échouer l’exécution s’il détecte un .only, sauf si vous passez --allowOnly ou définissez allowOnly: true. Il ne détecte pas les .skip ; ajoutez donc une vérification du diff sur les branches d’implémentation :
changed=$(git diff --name-only origin/main...HEAD -- '*.test.ts' '*.spec.ts')
if [ -n "$changed" ]; then
echo "Test files changed on an implementation branch:"
echo "$changed"
exit 1
fi
Le checkout doit disposer d’un historique suffisant pour que origin/main puisse être résolu.
Vérifiez que les assertions testent le comportement. Pour chaque assertion, demandez-vous si une implémentation erronée pourrait quand même passer. Les valeurs exactes, les erreurs levées et la sortie visible résistent aux refactorisations. Les nombres d’appels sur des helpers internes et les vérifications de type, non.
Même une suite irréprochable a ses limites. Des tests unitaires qui passent montrent que le code gère les cas que quelqu’un a formalisés. Ils ne montrent pas qu’un véritable utilisateur peut mener le parcours à terme dans un navigateur, et les session replays d’une fonctionnalité livrée peuvent encore révéler un tunnel de paiement cassé alors que la CI est au vert.
Quand le TDD n’en vaut-il pas la peine ?
Le test-first coûte plus qu’il ne rapporte lorsque vous ne pouvez pas énoncer le résultat attendu à l’avance.
| Situation | Pourquoi renoncer au test-first |
|---|---|
| Prototypes jetables | Le code sert à répondre à une question, puis à être supprimé |
| Ajustements visuels de l’UI (espacement, couleur, mise en page) | Le bon résultat s’apprécie à l’œil, il n’y a donc encore rien à vérifier par une assertion |
Si un prototype survit, écrivez les tests avant que l’agent ne le consolide en code de production.
Conclusion
Lorsque des agents écrivent le code, la spécification est la seule partie que vous seul pouvez fournir, et les tests écrits en premier la rendent exécutable. Pour votre prochaine tâche confiée à un agent, écrivez d’abord le fichier Vitest en échec, donnez à l’agent la consigne d’interdiction de modification, et ne mergez qu’une fois la CI passée et une revue distincte du diff des tests validée.
FAQ
Un linter peut-il détecter les tests ignorés ajoutés par un agent ?
Oui. Le package @vitest/eslint-plugin propose une règle vitest/no-disabled-tests qui signale test.skip, it.skip et describe.skip, ainsi qu'une règle vitest/no-focused-tests qui signale .only. La configuration recommandée se contente d'avertissements pour les tests désactivés : passez donc cette règle à 'error' et exécutez ESLint en CI. Les projets Jest disposent de règles portant les mêmes noms via eslint-plugin-jest. Le lint peut manquer les appels via des alias, conservez donc aussi la vérification du diff git.
Un agent IA peut-il écrire les tests en échec à la place d'un humain ?
Oui, à condition que l'écriture des tests et celle de l'implémentation soient des étapes distinctes. Faites rédiger les tests par une session d'agent à partir du stub, puis relisez vous-même les assertions et commitez-les avant le début de l'implémentation. La session chargée de l'implémentation ne doit jamais voir que des tests qu'elle n'a pas le droit de modifier. Si une même session écrit les deux, les tests finissent par décrire son propre code plutôt que les exigences.
Une spécification écrite détaillée suffit-elle sans écrire les tests en premier ?
Non. Une spécification rédigée ou un stub documenté par des commentaires indique à l'agent quoi construire, mais rien ne vérifie le résultat par rapport à cette spécification. Un test en échec ne peut pas être satisfait par une implémentation simplement plausible : soit il passe, soit il échoue. Utilisez les deux. Placez les exigences et les contraintes dans le prompt ou le fichier d'instructions, et encodez les cas qui doivent être respectés sous forme de tests que la CI exécute à chaque commit.
Que faire lorsque l'agent affirme qu'un test est erroné ?
Examinez vous-même l'explication de l'agent et, si le test est réellement erroné, corrigez-le en dehors de la branche d'implémentation. Intégrez le test corrigé sur main dans son propre commit ou sa propre pull request, relu via CODEOWNERS si vous l'utilisez, puis rebasez la branche d'implémentation. La vérification du diff compare la branche à origin/main : une correction de test déjà intégrée sur main ne la déclenche donc pas, et l'agent ne modifie jamais la spécification.