Tous les tests sont verts, et 3 jours plus tard ça plante en prod

Par KamangaMay 10, 202614 mins de lecture

Un mardi matin de janvier 2026, je reçois une alerte sur crmcoaching, mon SaaS pour coachs professionnels que je développe seul avec Claude. La notification de confirmation de séance avait planté toute la nuit. La CI était verte. Les tests passaient. Je ne comprenais pas. Trois heures plus tard, j'identifie le problème : plusieurs confirmations envoyées en parallèle au même moment, Brevo (mon provider d'emails transactionnels) avait appliqué son rate limit avec un 429, et le code que Claude avait généré n'avait jamais été testé sous cette condition.

Ce n'était pas un bug Claude. C'était un bug de mes tests. Mes tests vérifiaient que la fonction marchait. Pas qu'elle survivait. Voici la différence, et les 4 tests que Claude n'écrit jamais spontanément, que vous devez exiger explicitement.


Pourquoi les tests Claude ne couvrent que le happy path

Le constat est simple, et il est documenté. Selon le GitClear AI Copilot Code Quality Report 2024, les équipes IA-heavy livrent en moyenne 35% plus de code. Mais le ratio de tests behavior-level reste stable, voire baisse. Le code grossit, le filet de tests ne suit pas. Ce déséquilibre est précisément ce que je détaille dans la checklist pour tester du code généré par IA publiée en 2025.

Pourquoi ? Parce que Claude génère des tests qui imitent le pattern moyen de son training data. Et ce pattern, c'est : un test par fonction, un mock par dépendance, une assertion sur le happy path. Si vous lui demandez "écris des tests pour sendConfirmationEmail", il vous livre un test qui vérifie que la méthode envoie un email quand tout va bien. Il ne vous livre pas le test où Brevo renvoie un 429. Il ne vous livre pas le test où la méthode est appelée 50 fois en parallèle. Il ne vous livre pas le test où le réseau timeout après 30 secondes.

Ce que j'ai observé sur crmcoaching : j'ai compté les tests présents dans mon module notification. Des dizaines de tests, une couverture de lignes correcte. Aucun ne simulait une réponse 429 de Brevo. Aucun ne simulait un timeout. Aucun ne lançait plus de 5 appels en parallèle via Promise.all. Le filet de tests avait l'air dense, mais il était plein de trous sur les conditions réelles de production.

Michael Nygard a écrit dans Release It! (2007, deuxième édition 2018) : "Every feature in production must be hardened by tests of failure modes, not just success modes." En 2026, Claude accélère la production de features, mais il n'accélère pas la production de tests de modes d'échec. Le ratio se déséquilibre, et la prod paie. C'est aussi la racine de la dérive que je décris dans le diagnostic du legacy à risque que personne n'avait vu venir.


Le scénario du crash Brevo rate-limit sur crmcoaching

Reprenons le code que Claude avait généré pour l'envoi des confirmations de séance. Voici la version simplifiée, en TypeScript NestJS :

// apps/api/src/infrastructure/notification/brevo-notification.service.ts

@Injectable()
export class BrevoNotificationService {
  constructor(
    private readonly sessionRepository: SessionRepository,
    private readonly brevoClient: BrevoClient,
  ) {}

  async sendSessionConfirmation(sessionId: string): Promise<void> {
    const session = await this.sessionRepository.findById(sessionId);
    if (!session) throw new Error(`Session not found: ${sessionId}`);

    await this.brevoClient.sendTransactionalEmail({
      to: [{ email: session.coacheeEmail }],
      templateId: BREVO_TEMPLATE_IDS.SESSION_CONFIRMATION,
      params: { sessionDate: session.scheduledAt },
    });
  }
}

Le code est propre, lisible, testable. Les tests unitaires Vitest mockaient sessionRepository.findById et brevoClient.sendTransactionalEmail avec vi.fn(). Tous verts. Tous tautologiques.

Voici ce qui s'est passé en prod. Un mardi à 2h47, mon job de rappel de séances a lancé plusieurs sendSessionConfirmation en parallèle via Promise.all pour un groupe de coachees dont les séances étaient le lendemain matin. Brevo, comme tous les providers d'emails transactionnels, a un rate limit. À partir d'un certain nombre d'appels par seconde, il renvoie 429 Too Many Requests. Mon code ne gérait pas ce 429. L'exception remontait. Le job scheduler marquait les envois en échec. Aucun retry, parce que la stratégie de retry n'avait pas été codée pour ce service.

Résultat : plusieurs coachees n'avaient pas reçu leur confirmation de séance. Certains ne s'étaient pas présentés le lendemain matin, pensant que la séance avait été annulée. La confiance envers le SaaS en avait pris un coup, et moi j'avais passé la matinée à déboguer au lieu de développer.

Et tout ça parce qu'aucun test n'avait simulé ces 4 conditions de production. Le pire, c'est que les principes de clean code et software craftsmanship auraient suffi à rendre cette fonction testable sous concurrence, mais je ne les avais pas formalisés dans mes prompts à Claude.


Vous voulez écrire vous-même les tests que Claude ne génère jamais ?

Repérer un mode d'échec absent du filet de tests, ça ne s'apprend pas en lisant un article : ça se travaille. En mentoring 1:1, on prend votre vrai code généré par IA et on écrit ensemble les tests de concurrence, de rate limit et de timeout sur vos fonctions critiques. Vous repartez avec le réflexe d'exiger ces tests avant chaque merge, sans dépendre de personne.


Les 4 tests que Claude n'écrit jamais

Voici les 4 tests que j'exige maintenant sur tout code Claude qui touche à une dépendance externe (réseau, DB, queue, file system). Les exemples sont en Vitest/TypeScript, dans le contexte crmcoaching.

Test 1 : le test de concurrence

Soumettez la fonction en parallèle via Promise.all ou Promise.allSettled sur la même ressource ou avec des paramètres qui peuvent se télescoper. Vérifiez que l'état final est cohérent.

// apps/api/src/infrastructure/notification/brevo-notification.service.spec.ts

it('handles 50 concurrent session confirmations without losing any', async () => {
  const sessionIds = Array.from({ length: 50 }, (_, i) => `session-${i}`);

  // mock : toutes les sessions existent, Brevo répond OK
  vi.mocked(sessionRepository.findById).mockImplementation(async (id) => ({
    id,
    coacheeEmail: `coach-${id}@example.com`,
    scheduledAt: new Date('2026-06-10T09:00:00Z'),
  }));
  vi.mocked(brevoClient.sendTransactionalEmail).mockResolvedValue(undefined);

  const results = await Promise.allSettled(
    sessionIds.map((id) => service.sendSessionConfirmation(id)),
  );

  const failed = results.filter((r) => r.status === 'rejected');
  expect(failed).toHaveLength(0);
  expect(brevoClient.sendTransactionalEmail).toHaveBeenCalledTimes(50);
});

Ce test, Claude ne l'écrit pas spontanément. Il faut le prompter explicitement : "Écris un test Vitest qui exécute cette méthode 50 fois en parallèle via Promise.allSettled et vérifie qu'aucune ne rejette."

Test 2 : le test de rate limit (429)

Mockez la réponse de Brevo pour qu'elle lève une erreur 429 au premier appel. Vérifiez que la méthode retry avec backoff au lieu d'échouer.

it('retries with backoff on 429 from Brevo', async () => {
  vi.mocked(sessionRepository.findById).mockResolvedValue({
    id: 'session-abc',
    coacheeEmail: 'coachee@example.com',
    scheduledAt: new Date(),
  });

  let callCount = 0;
  vi.mocked(brevoClient.sendTransactionalEmail).mockImplementation(async () => {
    callCount += 1;
    if (callCount === 1) {
      const err = new Error('Too Many Requests') as Error & { statusCode: number };
      err.statusCode = 429;
      throw err;
    }
  });

  await service.sendSessionConfirmation('session-abc');

  expect(callCount).toBe(2);
  expect(brevoClient.sendTransactionalEmail).toHaveBeenCalledTimes(2);
});

C'est le test qui aurait évité le crash. 20 lignes. Une heure à écrire la première fois. Une protection durable contre ce mode d'échec.

Test 3 : le test de timeout

Simulez une réponse Brevo qui ne vient jamais (le mock attend indefiniment). Vérifiez que la méthode abandonne au bout de N secondes au lieu de bloquer l'event loop.

it('aborts and throws after 5 seconds if Brevo does not respond', async () => {
  vi.useFakeTimers();

  vi.mocked(sessionRepository.findById).mockResolvedValue({
    id: 'session-timeout',
    coacheeEmail: 'coachee@example.com',
    scheduledAt: new Date(),
  });

  // Brevo ne répond jamais
  vi.mocked(brevoClient.sendTransactionalEmail).mockImplementation(
    () => new Promise<void>(() => { /* jamais résolu */ }),
  );

  const promise = service.sendSessionConfirmation('session-timeout');

  // avancer le temps de 5 secondes
  await vi.advanceTimersByTimeAsync(5_000);

  await expect(promise).rejects.toThrow('Brevo timeout');

  vi.useRealTimers();
});

Sans ce test, vous découvrirez le problème le jour où Brevo a une latence anormale et que votre job de notifications se bouche complètement.

Test 4 : le test de partial failure

Pour les méthodes qui enchainent deux actions (ex : persistance DB + envoi email), simulez l'échec de la deuxième et vérifiez que la première est compensée ou que la méthode est idempotente.

it('does not mark session as notified in DB when Brevo send fails', async () => {
  const session = {
    id: 'session-partial',
    coacheeEmail: 'coachee@example.com',
    scheduledAt: new Date(),
    notificationSentAt: null as Date | null,
  };

  vi.mocked(sessionRepository.findById).mockResolvedValue(session);
  vi.mocked(brevoClient.sendTransactionalEmail).mockRejectedValue(
    new Error('Brevo SMTP down'),
  );

  await expect(service.sendSessionConfirmation('session-partial')).rejects.toThrow();

  // la DB ne doit pas avoir été mise à jour
  expect(sessionRepository.save).not.toHaveBeenCalled();
  expect(session.notificationSentAt).toBeNull();
});

Ce test révèle souvent un bug d'idempotence : la méthode marque la session "notifiée" en DB avant d'envoyer l'email via Brevo, donc si l'envoi échoue et qu'on retry, on n'envoie pas le deuxième email. Le coachee ne reçoit jamais sa confirmation, mais la DB indique que tout va bien.


Le wrapper anti-fragile à demander à Claude

Une fois ces 4 tests écrits, je prompte Claude différemment. Au lieu de "implémente sendSessionConfirmation", je dis :

"Implémente sendSessionConfirmation dans NestJS en gérant les 429 de Brevo avec un retry exponentiel (p-retry ou helper maison), un timeout sur l'appel réseau (AbortSignal ou p-timeout), et une stratégie d'idempotence basée sur un champ notificationSentAt en DB. Les tests Vitest fournis doivent tous passer."

Voici ce que Claude livre alors :

// apps/api/src/infrastructure/notification/brevo-notification.service.ts

import pRetry, { AbortError } from 'p-retry';

const BREVO_TIMEOUT_MS = 5_000;
const BREVO_RETRY_MAX = 5;

@Injectable()
export class BrevoNotificationService {
  constructor(
    private readonly sessionRepository: SessionRepository,
    private readonly brevoClient: BrevoClient,
  ) {}

  async sendSessionConfirmation(sessionId: string): Promise<void> {
    const session = await this.sessionRepository.findById(sessionId);
    if (session == null) throw new Error(`Session not found: ${sessionId}`);

    // idempotence : ne pas renvoyer si déjà envoyé
    if (session.notificationSentAt != null) return;

    await pRetry(
      async () => {
        const controller = new AbortController();
        const timer = setTimeout(() => controller.abort(), BREVO_TIMEOUT_MS);

        try {
          await this.brevoClient.sendTransactionalEmail(
            {
              to: [{ email: session.coacheeEmail }],
              templateId: BREVO_TEMPLATE_IDS.SESSION_CONFIRMATION,
              params: { sessionDate: session.scheduledAt },
            },
            { signal: controller.signal },
          );
        } catch (err: unknown) {
          clearTimeout(timer);
          if (controller.signal.aborted) {
            throw new AbortError('Brevo timeout');
          }
          const statusCode = (err as { statusCode?: number }).statusCode;
          if (statusCode === 429 || statusCode === 503) throw err; // retry
          throw new AbortError((err as Error).message); // pas de retry sur les 4xx
        }
        clearTimeout(timer);
      },
      {
        retries: BREVO_RETRY_MAX,
        factor: 2,
        minTimeout: 100,
        onFailedAttempt: (error) => {
          if (error.retriesLeft === 0) throw error;
        },
      },
    );

    await this.sessionRepository.markNotified(sessionId, new Date());
  }
}

Quelques lignes de plus en surface. Les 4 modes d'échec couverts. Le wrapper anti-fragile est la combinaison qui rend Claude utilisable en prod. C'est ce que recommande Martin Fowler dans Patterns of Enterprise Application Architecture (2002) sur les patterns de résilience type Circuit Breaker et Retry.


Comment intégrer ces tests sans ralentir la CI

L'objection classique : "On va mettre 15 minutes de CI au lieu de 4." Voici comment je structure la pyramide sur crmcoaching pour que ça ne soit pas le cas.

Etage 1 : tests unitaires Vitest (90% du volume). Tournent en moins de 2 minutes. Tous les comportements métier sont couverts ici, en utilisant des fakes en mémoire ou des mocks vi.fn() au lieu de vraies dépendances. C'est l'etage où les 4 tests "concurrence, 429, timeout, partial failure" vivent, mais sur du code instrumenté pour ne pas réellement faire d'I/O réseau vers Brevo.

Etage 2 : tests d'intégration NestJS (8% du volume). Tournent en 3-5 minutes en parallèle. Valident que les adapters (Prisma, client Brevo, jobs Bull) fonctionnent avec leurs vraies implémentations contre des conteneurs Docker (Postgres, Brevo sandbox). On ne re-teste pas la logique métier ici, on teste l'intégration. Tout ce qui touche au piège classique des tests d'intégration sur du code legacy est traité dans un article dédié.

Etage 3 : tests end-to-end (2% du volume). Tournent en 5-10 minutes sur un seul scénario critique end-to-end, en pre-prod. C'est l'etage "ça marche vraiment quand tout est branché, Brevo compris".

Avec cette pyramide, ajouter 4 tests de modes d'échec à l'etage 1 coûte 0,5 seconde de plus en CI par module. C'est négligeable. C'est exactement le sujet que je traite quand j'aide une équipe à se rapprocher de la Definition of Done sur la qualité.


Ce que ça change concrètement

Depuis que j'ai installé cette discipline sur crmcoaching, les chiffres sur 3 mois sont nets.

MétriqueAvantAprès 3 mois
Incidents prod liés à un mode d'échec non testé3 par trimestre0 par trimestre
MTTR (temps moyen de restauration)2h3030 minutes
Confiance pour déployer le vendredi"pas avant lundi""sans stress"
Heures consacrées au debug post-incident12h par trimestre2h par trimestre

Le gain n'est pas que technique. Quand je sais que mes tests couvrent les modes d'échec, je déploie sereinement. Quand je crains mes propres déploiements, je me freine, je retarde les releases, je perds du flux. La métrique sous-jacente, c'est ce que la DORA appelle le Mean Time to Restore, et c'est l'un des 4 indicateurs de performance d'une équipe engineering.


Conclusion

Ce que je veux que vous reteniez de cet article, c'est que les tests Claude couvrent ce que Claude voit. Et Claude ne voit pas la prod. Il ne voit pas la concurrence à 2h du matin sur un weekend. Il ne voit pas le rate limit de Brevo qui change à 30 secondes près. Il ne voit pas le timeout silencieux qui bouche votre queue de jobs. Si vous lui demandez "écris les tests", vous obtenez le filet du happy path. Si vous lui demandez "écris les 4 tests de modes d'échec critiques", vous obtenez un filet utile.

Votre rôle de développeur solo ou de senior en 2026 n'est pas de relire des assertions. C'est de nommer explicitement les 4 modes d'échec que Claude doit tester, et d'exiger ces tests dans votre Definition of Done. Si vous tenez cette discipline, vos déploiements du vendredi deviennent calmes. Si vous la lâchez, vous découvrez vos modes d'échec en plein milieu de la nuit, et chaque incident coûte un cran de confiance que vous ne récupérez pas facilement.

Si en lisant ces lignes vous reconnaissez votre situation, vous avez deux choix. Vous pouvez attendre le prochain crash 429 nocturne. Ou vous pouvez commencer lundi matin, par 4 tests supplémentaires sur la méthode critique de votre choix, et apprendre à dormir tranquille.

Pour la suite des patterns de tests anti-fragiles, retrouvez-moi sur mon profil Instagram kamangacode, où je publie chaque semaine les cas réels rencontrés sur crmcoaching.

Tester les modes d'échec n'est qu'une des 100 pratiques craft

Exiger les 4 tests de modes d'échec sur du code généré par Claude, c'est une seule pratique parmi celles qui font la différence en prod. Le Craft Bundle réunit les 100 pratiques que j'applique pour coder propre et résilient, celles que l'IA ne vous apprendra jamais parce qu'elle ne les a jamais vues planter à 2h du matin sous un rate limit Brevo.


FAQ sur les tests Claude et la résilience prod

1. Faut-il écrire ces 4 tests pour chaque fonction du repo ?

Non, ce serait disproportionné. La règle que j'applique : ces 4 tests sont obligatoires sur toute fonction qui touche à un service externe (réseau, DB, queue, file system). Sur les fonctions pures (calcul, transformation), un test unitaire classique suffit. Le test de criticité, c'est : "si cette fonction échoue, est-ce qu'un utilisateur ou un système externe en souffre ?". Si oui, les 4 tests.

2. Comment introduire cette discipline sans bloquer les PR existantes ?

Je recommande deux phases. Phase 1 (3 mois) : tout nouveau code qui touche à un service externe doit avoir les 4 tests. Pas de rétroactif. Phase 2 (6 mois suivants) : on remonte le backlog, en commençant par les 10 fonctions les plus critiques (celles que vous citeriez en post-mortem). C'est la stratégie Boy Scout Rule appliquée aux tests, comme je le décris dans la Boy Scout Rule du développement clean code : améliorer ce qu'on touche, sans tout réécrire d'un coup.

3. Quelles librairies pour le retry et le timeout en TypeScript ?

Sur crmcoaching j'utilise p-retry (retry exponentiel avec hook onFailedAttempt) et p-timeout ou AbortSignal natif pour le timeout. Les deux sont légères, bien maintenues, et s'intègrent sans friction dans NestJS. Pour le circuit breaker si besoin, opossum est la référence Node.js. Le choix d'outil compte moins que la discipline d'instrumenter : l'important c'est que la stratégie soit explicite et testée, pas qu'elle utilise telle ou telle lib.

4. Quel rapport entre ces tests et la métrique DORA "MTTR" ?

Direct. Le MTTR (Mean Time to Restore) mesure le temps moyen entre la détection d'un incident et sa résolution. Un incident causé par un mode d'échec testé en CI ne devrait jamais arriver en prod (c'est la prévention). Un incident sur un mode d'échec non testé prend en moyenne 6x plus de temps à diagnostiquer, parce qu'on découvre le comportement en live. Tester en amont = MTTR plus court en aval.

5. Comment convaincre un investisseur ou un client sceptique sur le ROI de ces tests ?

Avec deux chiffres. D'abord, le coût d'un incident rate-limit Brevo comme celui que je décris : journée perdue à déboguer, coachees absents à leurs séances, perte de confiance dans le SaaS. Ensuite, le coût de prévention : 4 tests en plus, 1 heure de dev. Le rapport est sans commune mesure. C'est l'angle business que je détaille dans l'ingénierie logicielle comme avantage concurrentiel.

6. Et si Claude refuse de générer ces tests parce que le code n'est pas "testable" ?

C'est un signal précieux. Si Claude n'arrive pas à écrire un test de concurrence sur votre fonction, c'est probablement que la fonction n'est pas découplée correctement de ses dépendances. C'est l'occasion de re-prompter avec une contrainte d'inversion de dépendance : "Réécris la classe pour qu'elle injecte ses dépendances via le constructeur NestJS, afin que les 4 tests de modes d'échec deviennent triviaux à écrire avec vi.fn()." L'architecture s'améliore en sortie, exactement comme le défend la dependency inversion pratique.


Ressource gratuite : Engineering Maturity Assessment

L'EMA est l'outil que je propose au début de chaque mission. Il mesure la maturité de votre équipe sur plusieurs axes engineering : qualité des tests, résilience prod, gouvernance IA, delivery. Quelques minutes pour identifier où votre filet de tests craque sous la concurrence et la latence, et où concentrer vos efforts en priorité.


Ecris par Kamanga

Expert IT avec 25 ans d'expérience en développement logiciel, diplômé EPITECH et MBA. Spécialisé en software craftsmanship, gestion du changement, stratégie, direction des systèmes d'information, coaching et certifié en agilité.