mirror of
https://github.com/MaksTinyWorkshop/_Assistant_Lead_Tech
synced 2026-05-18 08:18:15 +02:00
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>
16 KiB
16 KiB
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]
Pattern :
Pattern :
Pattern : Helper
Pattern : Convention
Backend — Patterns : Tests
Extrait de la base de connaissance Lead_tech. Voir
knowledge/backend/patterns/README.mdpour 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 CASCADEqui 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
afterEachtourne quand même) - pas de
TRUNCATEqui casserait les transactions ouvertes par le SUT
- Limites / vigilance :
- convention "100 % via
cleanup.track" : ne pas mélanger avec desprisma.*.deleteManydirects - la queue est pour le teardown, pas pour le setup : ne jamais tracker une création
- convention "100 % via
- Validé le : 24-04-2026
- Contexte technique : Vitest / Prisma / Postgres — RL799_V2
Implémentation (helper minimal)
// __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
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.*.deleteManydirect horscleanup.trackdans 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 + rollbackou DB-per-worker. - Avantage :
- hook standard vitest, pas de cron externe ni de
prisma:resetmanuel entre sessions - 1 seule passe au démarrage de la suite, coût négligeable
- hook standard vitest, pas de cron externe ni de
- 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
// 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
# 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
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: 1ou 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)
- surcoût ~50 s sur 134 fichiers (cycle
- 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) :
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 :
// 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 seedsub-process : les envs critiques chargées parlib/prisma.tscôté app doivent être propagées au sub-process (loadEnv.tsimporté en tête deglobalSetup.ts).globalForPrismacache 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) + findFirstdispersé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 (
waitForXvssetTimeout+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
- le polling consomme des requêtes DB (1 toutes les 50 ms) — négligeable en
- Validé le : 25-04-2026
- Contexte technique : Vitest / Prisma — RL799_V2
Implémentation
// __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
// ❌ 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
recipientIdoutargetIdpour é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 existeviaprisma.$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
prismaau lieu detx) 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)
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()enafterEach)
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
describeajoute 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)
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
beforeEachlourd, n'a pas dedescribe, 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)
- Analyse :
grep -nE "^test|^// ---" fichier.test.ts→ carte des sections + repérage des dépendances closure. - Extraction des helpers d'abord :
helpers/<domaine>.tsavecsetup<Domaine>Fixtures(cleanup)qui retourne explicitement les IDs (pas de closure module-level). - POC sur le domaine le plus simple (4-7 tests, peu de dépendances). Commit + push dès que vert.
- 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.
- 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 desetup<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 -ldu fichier original baisse à chaque commit (preuve de progrès)- Suite verte à chaque commit