Files
_Assistant_Lead_Tech/knowledge/backend/patterns/tests.md
MaksTinyWorkshop b3417ad77b capitalisation: intégration ~60 entrées RL799_V2 (triage 2026-05-02)
Triage du 95_a_capitaliser.md (~75 propositions) :
- 60 entrées intégrées dans knowledge/ (backend, frontend, workflow)
- 4 nouveaux fichiers : backend/patterns/tests.md, backend/risques/tests.md,
  frontend/patterns/general.md, workflow/patterns/general.md
- 6 doublons rejetés
- Mise à jour des READMEs index pour refléter les nouvelles entrées
- 95_a_capitaliser.md restauré à sa structure initiale
- 40_decisions_et_archi.md : décision mono-tenant déployable vs SaaS multi-tenant
- 90_debug_et_postmortem.md : sub-agents Write indisponible, effet iceberg CI,
  prisma migrate diffs cosmétiques

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 22:12:44 +02:00

379 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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.
---
<a id="pattern-cleanup-track-tests-integration-db"></a>
## 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> | 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
---
<a id="pattern-globalsetup-vitest-purge-residus"></a>
## 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.<entity>.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
---
<a id="pattern-template-database-isolation-fichiers"></a>
## 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
---
<a id="pattern-helper-waitfor-fire-and-forget"></a>
## 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
---
<a id="pattern-test-atomicite-transaction"></a>
## 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`)
---
<a id="pattern-describe-convention-tests-integration"></a>
## 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
- 50150 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.
---
<a id="pattern-refactor-iteratif-tests-monolithe"></a>
## 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/<domaine>.ts` avec `setup<Domaine>Fixtures(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<Domaine>`.
- **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