--- title: Backend — Patterns : Tests domain: backend bucket: patterns tags: [tests, vitest, prisma, integration, isolation] applies_to: [implementation, review, debug] severity: high validated_on: 2026-05-02 source_projects: [RL799_V2] --- # Backend — Patterns : Tests > Extrait de la base de connaissance Lead_tech. Voir `knowledge/backend/patterns/README.md` pour l'index complet. --- ## Pattern : `cleanup.track()` LIFO pour tests d'intégration DB - Objectif : nettoyer proprement les artefacts créés par chaque test sans recourir à un `TRUNCATE CASCADE` qui casserait les transactions du SUT. - Contexte : tests qui écrivent dans une vraie base (pas mockée), en cohabitation avec un seed durable et des tables FK-liées. - Quand l'utiliser : dès qu'un test crée des rows à la volée et qu'on veut éviter la pollution inter-tests. - Quand l'éviter : si le projet utilise `transactions + rollback par test` (déjà isolé), ou si le SUT n'écrit jamais en DB. - Avantage : - ordre LIFO = ordre FK-safe (les enfants tombent avant les parents) - résistant aux throws intermédiaires (le `afterEach` tourne quand même) - pas de `TRUNCATE` qui casserait les transactions ouvertes par le SUT - Limites / vigilance : - convention "100 % via `cleanup.track`" : ne pas mélanger avec des `prisma.*.deleteMany` directs - la queue est pour le teardown, pas pour le setup : ne jamais tracker une création - Validé le : 24-04-2026 - Contexte technique : Vitest / Prisma / Postgres — RL799_V2 ### Implémentation (helper minimal) ```typescript // __tests__/helpers/db.ts type CleanupFn = () => Promise | void; export function createCleanup() { const queue: CleanupFn[] = []; return { track(fn: CleanupFn) { queue.push(fn); }, async run() { while (queue.length > 0) { const fn = queue.pop()!; try { await fn(); } catch (err) { console.warn('cleanup.run error:', err); } } }, }; } ``` ### Usage typique ```typescript const cleanup = createCleanup(); afterEach(async () => { await cleanup.run(); }); test('crée une tenue', async () => { const tenue = await prisma.tenue.create({ data: { /* … */ } }); cleanup.track(async () => { await prisma.tenue.deleteMany({ where: { id: tenue.id } }); }); // … assertions }); ``` ### Checklist - [ ] Ordre LIFO respecté (FK-safe) - [ ] Aucun `prisma.*.deleteMany` direct hors `cleanup.track` dans les fichiers concernés - [ ] Cleanup défensif (`try/catch + warn`) — pas de partial leak - [ ] Aucune création (seulement des suppressions/restores) dans la queue --- ## Pattern : `globalSetup` vitest pour purger les résidus DB inter-runs - Objectif : repartir d'une DB propre en début de suite quand des cleanups intra-test sont incomplets et que les artefacts s'accumulent entre sessions. - Contexte : projet vitest avec `maxWorkers: 1` (DB partagée), tables 100 % alimentées par les tests (notifications, audit logs, payments éphémères). - Quand l'utiliser : symptômes de "tests verts en isolation, rouges en suite complète après plusieurs jours" liés à des `findFirst({ type })` qui tombent sur des résidus. - Quand l'éviter : si la suite tourne déjà avec une stratégie `transactions + rollback` ou DB-per-worker. - Avantage : - hook standard vitest, pas de cron externe ni de `prisma:reset` manuel entre sessions - 1 seule passe au démarrage de la suite, coût négligeable - Limites / vigilance : - **NE PAS purger les tables seed** (users, soirees seed, profiles, etc.) — réservé aux tables 100 % "test-only" - ne corrige pas la flakiness intra-run entre fichiers consécutifs - Validé le : 25-04-2026 - Contexte technique : Vitest / Prisma — RL799_V2 ### Implémentation ```typescript // vitest.config.ts test: { maxWorkers: 1, globalSetup: ['./src/__tests__/globalSetup.ts'], } // src/__tests__/globalSetup.ts export default async function globalSetup() { await prisma.notification.deleteMany(); await prisma.auditLog.deleteMany(); await prisma.cotisationPayment.deleteMany(); await prisma.$disconnect(); } ``` ### Vérification avant d'ajouter une table ```bash # Tables référencées par un snapshot/seed → NE PAS purger grep -rln "prisma..findMany\|seedSnapshot" src/__tests__/ ``` ### Checklist - [ ] Aucune table seed dans la liste des `deleteMany` - [ ] Hook référencé dans `vitest.config.ts` - [ ] Justifier en commentaire pourquoi chaque table listée est test-only --- ## Pattern : Template database Postgres pour isoler les fichiers de tests - Objectif : éliminer la flakiness inter-fichiers en garantissant que chaque fichier de tests démarre avec une DB seedée pristine. - Contexte : suite vitest qui partage une DB Postgres unique (cas typique : `maxWorkers: 1` ou DB unique côté CI). - Quand l'utiliser : projet avec ≥50 fichiers de tests partageant une DB, présence de tests qui mutent les données seed sans restaurer systématiquement. - Quand l'éviter : si les tests utilisent transactions+rollback (déjà isolés), DB-per-worker, ou si le rôle Postgres n'a pas le droit `CREATEDB`. - Avantage : - flakiness inter-fichiers éliminée par construction - les tests ne polluent plus la DB de dev partagée - réutilisation entre sessions : ~27 s au 1er run, < 1 s aux suivants - Limites / vigilance : - surcoût ~50 s sur 134 fichiers (cycle `drop + create FROM TEMPLATE` ~380 ms par fichier) - les sub-processes (`prisma db seed`) doivent recevoir les envs critiques explicitement (`APP_BASE_URL`, `JWT_SECRET`, `ENCRYPTION_KEY`) - Validé le : 01-05-2026 - Contexte technique : Prisma 7 / pg 8 / Postgres 17 / vitest 4 — RL799_V2 ### Mécanique en 3 lots **Lot 1 — DSN + primitives admin SQL** (`dbUrls.ts`, `dbAdmin.ts`) : `getAdminDsn()`, `getTemplateDsn()`, `getTestDsn()`, `databaseExists`, `dropDatabase` (`pg_terminate_backend` + `DROP IF EXISTS`), `createDatabase(name, { template })`. Whitelist stricte des noms de DB (les SQL admin ne supportent pas `$1`). **Lot 2 — Bootstrap idempotent du template** (`bootstrapTemplate.ts`) : ```typescript export async function ensureTemplateReady() { if (await databaseExists(TEMPLATE)) return; // no-op si déjà là await createDatabase(TEMPLATE); spawnSync('pnpm', ['-C', 'apps/api', 'exec', 'prisma', 'migrate', 'deploy'], { env: { ...process.env, DB_URL: getTemplateDsn() }, }); spawnSync('pnpm', ['-C', 'apps/api', 'exec', 'prisma', 'db', 'seed'], { env: { ...process.env, DB_URL: getTemplateDsn() }, }); } ``` **Lot 3 — Câblage vitest** : ```typescript // globalSetup.ts export default async function globalSetup() { await ensureTemplateReady(); if (await databaseExists(TEST)) await dropDatabase(TEST); return async () => { if (await databaseExists(TEST)) await dropDatabase(TEST); }; } // setupFile.ts (avant chaque fichier) if (await databaseExists(TEST)) await dropDatabase(TEST); await createDatabase(TEST, { template: TEMPLATE }); ``` ### Pièges anticipés - **`resolveDbUrl()` qui force le pathname `/test`** : doit préserver le DSN s'il pointe déjà sur la template (sinon le seed sub-process écrit dans la mauvaise DB). - **`prisma db seed` sub-process** : les envs critiques chargées par `lib/prisma.ts` côté app doivent être propagées au sub-process (`loadEnv.ts` importé en tête de `globalSetup.ts`). - **`globalForPrisma` cache un PrismaClient** : c'est le **contenu** de la DB qui change (drop+create), pas le DSN — pas de `$disconnect()` nécessaire grâce à `pg_terminate_backend`. ### Checklist - [ ] Whitelist stricte des noms de DB autorisés (anti-injection) - [ ] Bootstrap idempotent (réutilise la template entre sessions de dev) - [ ] Sub-processes du bootstrap reçoivent les envs critiques - [ ] Drop + create FROM TEMPLATE au début de chaque fichier - [ ] Test de flakiness avant/après pour valider le gain --- ## Pattern : Helper `waitForX()` polling-borné pour les attentes fire-and-forget - Objectif : remplacer les `setTimeout(50) + findFirst` dispersés par un helper centralisé robuste à la charge CPU et auto-documenté. - Contexte : tests qui valident un side-effect async (audit log, notification, mail log) déclenché par `setImmediate` / `Promise.resolve().then(...)` côté SUT. - Quand l'utiliser : tout test qui attend qu'un side-effect non-awaited apparaisse en DB. - Quand l'éviter : pour vérifier l'**absence** d'un event — là il faut un délai fixe puis assertion d'absence (le polling jusqu'au timeout ne prouve rien). - Avantage : - robuste aux variations de charge (pas de durée arbitraire) - fail rapide si l'event n'arrive pas (timeout 1500 ms par défaut) - lisibilité : intention claire (`waitForX` vs `setTimeout` + `findFirst`) - Limites / vigilance : - le polling consomme des requêtes DB (1 toutes les 50 ms) — négligeable en `maxWorkers: 1` - **NE PAS** utiliser pour vérifier l'absence d'un event - Validé le : 25-04-2026 - Contexte technique : Vitest / Prisma — RL799_V2 ### Implémentation ```typescript // __tests__/helpers/asyncWait.ts export const waitForAudit = async ( query: { action: string; targetId?: string; userId?: string }, options: { timeoutMs?: number; intervalMs?: number } = {}, ) => { const timeout = options.timeoutMs ?? 1500; const interval = options.intervalMs ?? 50; const deadline = Date.now() + timeout; while (Date.now() < deadline) { const audit = await prisma.auditLog.findFirst({ where: query }); if (audit) return audit; await new Promise((r) => setTimeout(r, interval)); } return null; }; ``` ### Anti-pattern ```typescript // ❌ délai arbitraire et fragile await new Promise((r) => setTimeout(r, 50)); const notif = await prisma.notification.findFirst({ where: { ... } }); // ✅ polling borné, intention claire const notif = await waitForNotification({ type: 'X', recipientId: userId }); ``` ### Checklist - [ ] Filtre exhaustif (au minimum `recipientId` ou `targetId` pour éviter de matcher les artefacts d'un autre test) - [ ] Timeout par défaut court (1500 ms) - [ ] Migration progressive — pas tous les tests d'un coup --- ## Pattern : Tester l'atomicité d'une transaction (audit + mutation) - Objectif : prouver par un test que la promesse "X est garanti par une transaction" tient — sans ce test, un refactor "fire-and-forget" passe en review sans alarme. - Contexte : code de service qui revendique `mutation persistée ⇔ audit log existe` via `prisma.$transaction(async tx => { await tx.X.update(...); await logActionSync(tx, ...); })`. - Quand l'utiliser : sur tout chemin critique où l'audit ou un side-effect transactionnel est un livrable métier. - Quand l'éviter : pour les audits purement informatifs (statistiques d'usage) où le fire-and-forget est acceptable. - Avantage : - le test fait office de contrat exécutable du pattern transactionnel - une régression silencieuse (passer `prisma` au lieu de `tx`) casse le test immédiatement - Limites / vigilance : - le mock doit cibler le seul appel ciblé (pas un mock global qui fait tout throw) - Validé le : 27-04-2026 - Contexte technique : Vitest / Prisma — RL799_V2 ### Implémentation (recipe vitest + spyOn) ```typescript import * as auditRepository from '../repositories/admin/auditRepository'; test('mutation rollback si audit throw', async () => { vi.spyOn(auditRepository, 'createAuditLog').mockImplementation(async (data) => { if (data.action === 'X.cross_soiree_update') { throw new Error('audit-failure-simulee'); } return null as never; }); const res = await POST_HANDLER(...); assert.ok(res.status >= 500); assert.equal(await prisma.X.findFirst(...), null); // rollback assert.equal(await prisma.auditLog.count(...), 0); }); ``` ### Checklist - [ ] Mock chirurgical sur l'action ciblée, pas un mock global - [ ] Assertions sur **trois** invariants : status HTTP 5xx, donnée non persistée, audit non écrit - [ ] Restaurer le mock après le test (`vi.restoreAllMocks()` en `afterEach`) --- ## Pattern : Convention `describe()` minimale pour tests d'intégration - Objectif : rendre les fichiers de tests > 150 LOC navigables, regroupables par cause d'échec et lisibles dans le reporter. - Contexte : tests d'intégration API où les assertions s'enchaînent sans regroupement, ou fichiers historiquement plats. - Quand l'utiliser : tout fichier > 150 LOC ou > 5 tests. - Quand l'éviter : fichier court (< 50 LOC, < 3 tests) où le `describe` ajoute du bruit. - Avantage : - le reporter vitest groupe les échecs logiquement (toutes les erreurs d'auth ensemble) - navigation facilitée par recherche (`describe('Auth'`) - un groupe devient candidat naturel à l'extraction en fichier dédié - Limites / vigilance : - 2 niveaux maximum — au-delà ça devient illisible - Validé le : 24-04-2026 - Contexte technique : Vitest — RL799_V2 ### Convention recommandée (2 niveaux) ```typescript describe('POST /api/tenues', () => { describe('Auth', () => { test('refuse un user non-admin → 403', async () => { /* … */ }); test('refuse un token invalide → 401', async () => { /* … */ }); }); describe('Validation', () => { test('refuse un body sans date → 400', async () => { /* … */ }); }); describe('Happy path', () => { test('crée une tenue valide → 201', async () => { /* … */ }); }); }); ``` ### Seuils - < 50 LOC et < 3 tests : optionnel - 50–150 LOC : 1 niveau de contexte - \> 150 LOC ou > 5 tests : 2 niveaux obligatoires ### Niveau 2 — vocabulaire stable `Auth`, `Validation`, `Happy path`, `Edge cases`, `Error handling`, `Integration`. Ne pas inventer un nom local — la cohérence inter-fichiers vaut plus que l'expressivité ponctuelle. --- ## Pattern : Refactor itératif d'un fichier de tests monolithe - Objectif : découper un fichier > 1000 LOC mêlant plusieurs domaines métier en fichiers thématiques sans introduire de régression. - Contexte : fichier de tests historique qui partage un `beforeEach` lourd, n'a pas de `describe`, et accueille tous les nouveaux tests par défaut. - Quand l'utiliser : > 1000 LOC, plusieurs domaines mélangés, navigation pénible. - Quand l'éviter : fichier ciblé, déjà cohérent même volumineux. - Avantage : - 1 commit par domaine extrait → review possible commit par commit - helpers réutilisables émergent naturellement - reporter vitest groupe les échecs par domaine - Limites / vigilance : - flakiness inter-fichiers possible pendant la transition (cleanups incomplets temporaires) - tolérer 1-2 fails au 1er run pendant le chantier, investiguer après - Validé le : 25-04-2026 - Contexte technique : Vitest — RL799_V2 ### Étapes (ordre obligatoire) 1. **Analyse** : `grep -nE "^test|^// ---" fichier.test.ts` → carte des sections + repérage des dépendances closure. 2. **Extraction des helpers d'abord** : `helpers/.ts` avec `setupFixtures(cleanup)` qui retourne explicitement les IDs (pas de closure module-level). 3. **POC sur le domaine le plus simple** (4-7 tests, peu de dépendances). Commit + push dès que vert. 4. **Itération domaine par domaine** par ordre croissant de complexité. Une seule règle : ne jamais commencer un nouveau domaine sans avoir commit le précédent. 5. **Suppression finale du fichier monolithe** quand le dernier domaine est extrait. ### Pièges à éviter - **Ne jamais dupliquer le fichier** pour le scinder : toujours **déplacer** (extraction → suppression dans l'original → commit). - **Closure variables partagées** : si les tests utilisent `let X = ''` au niveau module, les ids doivent passer par le retour de `setup`. - **Snapshot/restore seed** : si le fichier original prenait un snapshot d'entries seed, **chaque** nouveau fichier doit le faire (sinon le 1er fichier qui tourne snapshot un état déjà pollué). ### Checklist - [ ] Helpers extraits **avant** le 1er domaine - [ ] 1 commit par domaine - [ ] `wc -l` du fichier original baisse à chaque commit (preuve de progrès) - [ ] Suite verte à chaque commit