Files
_Assistant_Lead_Tech/knowledge/frontend/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

521 lines
21 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: Frontend — Patterns : Tests
domain: frontend
bucket: patterns
tags: [tests, react-native, jest, styles, ui]
applies_to: [implementation, review]
severity: medium
validated_on: 2026-04-07
source_projects: [app-alexandrie, RL799_V2]
---
# Frontend — Patterns : Tests
> Extrait de la base de connaissance Lead_tech. Voir `knowledge/frontend/patterns/README.md` pour l'index complet.
---
<a id="pattern-tests-styles-sans-renderer"></a>
## Pattern : Tests de styles React Native sans renderer JSX
### Synthèse
- **Objectif** : tester les tokens et styles de composants React Native dans un environnement Jest `testEnvironment: node` sans renderer JSX.
- **Contexte** : config Jest avec `transform: { '^.+\\.ts$': 'ts-jest' }` — les `.tsx` ne sont pas transformés.
- **Quand l'utiliser** : tokens de thème, logique pure, valeurs de style exportées.
- **Quand l'éviter** : rendu conditionnel (styles dynamiques inline) — nécessite `@testing-library/react-native`.
### Analyse
- **Avantages** :
- teste que le composant utilise les bons tokens, pas seulement que les tokens ont des valeurs
- détecte les régressions de style sans renderer
- rapide, aucune config Jest supplémentaire
- **Limites / vigilance** :
- ne teste pas le style calculé au runtime (style conditionnel dynamique)
### Validation
- Validé le : 19-03-2026
- Contexte technique : React Native / Jest / ts-jest — app-alexandrie story 0.2
### Implémentation
```typescript
// Button.tsx — exporter le StyleSheet avec un nom préfixé
export const buttonStyles = StyleSheet.create({
base: { borderRadius: 20, height: 57 },
primary: { backgroundColor: colors.primary },
});
export function Button(...) { ... }
// ui-components.spec.ts — importer et vérifier les tokens
import { buttonStyles } from './Button';
import { colors } from '@/theme';
it('variante primary utilise colors.primary', () => {
expect(buttonStyles.primary.backgroundColor).toBe(colors.primary);
});
```
### Deux niveaux de tests UI recommandés
1. `.spec.ts` (node) : tokens, valeurs, logique pure
2. `.spec.tsx` (config séparée avec renderer) : rendu visuel, interactions
---
<a id="pattern-niveaux-test-frontend-vue"></a>
## Pattern : Niveaux de test frontend Vue
### Synthèse
- **Objectif** : clarifier quand utiliser chaque niveau de test frontend Vue (structurel, composant monté, E2E).
- **Contexte** : les tests frontend du projet sont du string-matching sur le source `.vue` (`readFileSync` + `includes`). Ce pattern est rapide mais ne valide pas le comportement réel.
- **Quand l'utiliser** : à chaque choix de stratégie de test sur un composant Vue.
### Niveaux
| Niveau | Outil | Quand l'utiliser |
|--------|-------|-----------------|
| Structurel (string-matching) | `node:test` + `readFileSync` | Smoke tests : vérifier qu'un composant contient les imports, props, slots attendus. Acceptable pour MVP/sprint rapide. |
| Composant monté | `@vue/test-utils` + `vitest` | Valider le comportement interactif (toggle, emit, slots conditionnels). Obligatoire dès qu'il y a de la logique UI. |
| E2E | Playwright | Parcours critiques multi-pages. |
### Règle
Si un test vérifie un *comportement* (ex: "le menu se ferme après clic"), il doit monter le composant, pas chercher une string dans le source.
### Validation
- Validé le : 03-04-2026
- Contexte technique : Vue 3 / node:test — RL799_V2 story 6A.8
---
<a id="pattern-ordre-dom-comparedocumentposition"></a>
## Pattern : Vérifier l'ordre DOM avec `compareDocumentPosition`, pas `boundingBox`
### Synthèse
- **Objectif** : valider l'ordre réel des éléments dans le DOM, indépendamment du rendu CSS.
- **Contexte** : tests E2E Playwright qui doivent vérifier l'ordre d'affichage de sections ou d'éléments.
- **Quand l'utiliser** : toute assertion d'ordre dans un test E2E.
- **Quand l'éviter** : si on veut explicitement tester la position visuelle CSS (rare).
### Analyse
- **Avantages** :
- vérifie l'ordre DOM réel, insensible aux propriétés CSS (`flex-order`, `position`, `transform`)
- pas de `null` en retour (contrairement à `boundingBox()` hors viewport)
- déterministe
- **Limites / vigilance** :
- ne vérifie pas la position visuelle — si le test doit valider un rendu CSS spécifique, `boundingBox` reste pertinent
### Validation
- Validé le : 08-04-2026
- Contexte technique : Playwright / E2E — RL799_V2 story 17-5
### Implémentation
```ts
const aBeforeB = await page.evaluate(() => {
const a = document.querySelector('[data-testid="section-a"]');
const b = document.querySelector('[data-testid="section-b"]');
if (!a || !b) return false;
return (a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0;
});
expect(aBeforeB).toBe(true);
```
### Risque associé
`boundingBox().y` vérifie la position visuelle rendue par CSS, pas l'ordre dans le DOM. De plus `boundingBox()` retourne `null` pour les éléments hors viewport → crash non déterministe.
---
<a id="pattern-tests-selecteurs-e2e-stables"></a>
## Pattern : Sélecteurs E2E stables orientés intention
### Synthèse
Les tests E2E doivent cibler des sélecteurs stables (`data-testid`, role/name) et non la structure CSS/XPath.
### Analyse
Les classes et la structure DOM changent fréquemment sans régression fonctionnelle.
### Validation
- Validé le : 14-04-2026
- Contexte technique : Playwright / sélecteurs robustes — RL799_V2
- Ce pattern s'applique à Playwright/Cypress sur toutes les UIs réactives.
### Implémentation
- Préférer `data-testid` paramétré par identifiant métier stable.
- Éviter `locator.first()` si l'ordre peut muter.
- Isoler les tests mutateurs avec stratégie de remise à l'état (snapshot/restore).
---
<a id="pattern-tests-statiques-vitest-smoke-checks"></a>
## Pattern : Tests statiques `readFileSync` annotés comme smoke checks
### Synthèse
- **Objectif** : utiliser les tests `readFileSync + includes()` pour leur force réelle (présence de signaux structurels) tout en évitant la fausse confiance qu'ils donnent sur le comportement runtime.
- **Contexte** : projets Vue où le pattern `readFileSync` est largement utilisé pour vérifier la présence de testid, rôle ARIA, appel de service.
- **Quand l'utiliser** : assertions sur **présence** d'un pattern (testid, import, regex interdite). À doubler par un mount pour les comportements interactifs.
- **Quand l'éviter** : composants interactifs (focus trap, submit delegation, navigation clavier) — utiliser un test mount.
### Analyse
- **Avantages** :
- rapide à écrire et lire, résistant aux refactors de structure
- bon pour vérifier la présence de comportements clés sans monter
- **Limites / vigilance** :
- **ne valide PAS** que le focus trap cycle correctement, que le composant émet les bons événements, que la navigation clavier Escape ferme la modal
- **ne distingue pas** si une référence est dans le `<script>` ou le `<template>` (variable supprimée du script mais référencée dans le template → string-match passe, crash runtime)
### Validation
- Validé le : 21-04-2026
- Contexte technique : Vue 3 / Vitest — RL799_V2
### Annotation honnête recommandée
```typescript
// Note honnête : ces assertions sont des smoke checks structurels, pas une
// vérification runtime du focus trap. Le comportement réel (Tab cycle, Escape,
// focus restauré) se valide manuellement en QA ou via un test happy-dom dédié.
test('AppDialog : pattern previousActiveElement présent', () => {
expect(content.includes('previousActiveElement')).toBeTruthy();
});
```
### Règle
Si un comportement runtime est critique (focus trap, submit delegation, validation conditionnelle), écrire un test happy-dom séparé avec `@vue/test-utils`. Le smoke check ne dispense pas du test comportemental — il documente juste la présence d'un signal.
---
<a id="pattern-asserter-classe-css-modifier-vs-texte"></a>
## Pattern : Asserter classe CSS modifier vs texte (E2E robuste aux refactors visuels)
### Synthèse
- **Objectif** : rendre les tests E2E robustes aux refactors visuels (texte → icône SVG, label v1 → label v2, i18n future).
- **Contexte** : tests E2E qui valident un état rendu d'un composant.
- **Quand l'utiliser** : composant utilisant une convention BEM stricte avec modifier sémantique (`.grade-badge--apprenti`, `.status-badge--published`).
- **Quand l'éviter** : framework CSS-in-JS qui génère des classes hashées (`_grade_x4f3z`) — la classe modifier n'est pas accessible.
### Analyse
- **Avantages** :
- la classe modifier porte la **sémantique** et change beaucoup moins souvent que le texte
- typiquement liée à la prop ou au state, pas au rendu
- **Limites / vigilance** :
- ne remplace pas la validation visuelle (snapshot, screenshot) si le besoin est de protéger le rendu pixel-perfect
- reste pertinent : validation de contenu utilisateur (notes, prix) où le rendu textuel **est** la spec
### Validation
- Validé le : 25-04-2026
- Contexte technique : Playwright / Vue 3 — RL799_V2
### Exemple
```typescript
// ❌ Fragile : casse au refactor texte → SVG ou label v1 → v2
await expect(badge).toHaveText('A∴');
// ✅ Robuste : casse uniquement si le grade change ou si BEM est rompu
await expect(badge).toHaveClass(/grade-badge--apprenti/);
```
### Cas combinable
- Regex tolérante sur le texte (`/convoqu/i`) pour les cas où le texte est la seule prise (badges sans classe modifier)
- Validation visuelle (snapshot) en complément quand le rendu pixel-perfect est protégé
---
<a id="pattern-cleanup-e2e-best-effort"></a>
## Pattern : Cleanup E2E best-effort (try/catch + timeout court)
### Synthèse
- **Objectif** : empêcher qu'un cleanup post-test (réinitialisation d'un statut, suppression d'une fixture) fasse échouer un scénario métier qui a déjà passé.
- **Contexte** : tests Playwright avec `finally { await cleanup() }` qui font des PATCH/DELETE/POST sur l'API.
- **Quand l'utiliser** : tout cleanup post-test non critique pour les tests suivants (parce qu'on a un seed ou un autre cleanup global).
- **Quand l'éviter** : si le cleanup est critique pour l'isolation (UNIQUE constraint au prochain test) — monter le timeout à 60 s plutôt que silencer.
### Analyse
- **Avantages** :
- une lenteur transitoire de l'API de cleanup (audit log, notif fanout, lock DB) ne fait plus passer le test du vert au rouge
- la dette d'état après échec de cleanup est mineure (le test suivant restaure souvent ou un seed le fera)
- **Limites / vigilance** :
- setup pré-test (`beforeEach`) : un setup qui échoue **doit** faire échouer le test, sinon on teste un état inconnu
### Validation
- Validé le : 25-04-2026
- Contexte technique : Playwright — RL799_V2
### Pattern
```typescript
async function restoreEntry(page: Page, snap: Snapshot): Promise<void> {
try {
await page.request.patch(`/api/.../entries/${snap.entryId}/status`, {
data: { status: snap.status },
headers: { 'Content-Type': 'application/json' },
timeout: 10_000,
});
} catch (err) {
// eslint-disable-next-line no-console
console.warn('[spec-name] restoreEntry échoué (cleanup best-effort):', err);
}
}
test('mon scénario métier', async ({ page }) => {
const snap = await snapshotEntry(page, USER_ID);
try {
// … scénario métier …
} finally {
await restoreEntry(page, snap); // best-effort
}
});
```
---
<a id="pattern-helpers-sw-purs-extraits"></a>
## Pattern : Helpers Service Worker purs extraits pour tests Vitest
### Synthèse
- **Objectif** : permettre des tests unitaires Vitest fiables sur la logique du Service Worker, sans monter de stubs élaborés pour `self` / `registration` / `caches` / `clients`.
- **Contexte** : SW custom (mode `injectManifest` ou similaire) qui contient de la logique non triviale (parsing payload push, validation linkUrl anti open-redirect, sélection client focused).
- **Quand l'utiliser** : dès que le SW dépasse 50 lignes ou contient une regex / une condition métier.
- **Quand l'éviter** : SW trivial (juste un `precacheAndRoute(self.__WB_MANIFEST)`).
### Analyse
- **Avantages** :
- tests Vitest standards, pas de mock `self`/`registration`
- logique partageable avec d'autres parties du frontend (regex linkUrl partagée serveur ↔ SW ↔ store)
- le `sw.ts` final reste lisible : précache + routes + thin event handlers
- **Limites / vigilance** :
- les listeners `addEventListener('push'|...)` restent non testés unitairement → couverture par E2E + checklist DevTools manuelle
- bien isoler les imports : pas de dépendance Vue/Pinia dans `sw-helpers.ts` (le SW n'a pas d'accès au DOM)
- regex dupliquée serveur ↔ SW : extraire dans `@<scope>/shared` quand possible (cf. `pattern-regex-critique-partagee-anti-divergence` dans `backend/patterns/contracts.md`)
### Validation
- Validé le : 28-04-2026
- Contexte technique : Vite + vite-plugin-pwa `injectManifest` / Vitest — RL799_V2
### Implémentation
```typescript
// apps/frontend/src/sw-helpers.ts (testable pure)
const INTERNAL_PATH_REGEX = /^\/(?!\/)[a-zA-Z0-9/_\-?&=%.]*$/;
export const isInternalPath = (url: string): boolean =>
INTERNAL_PATH_REGEX.test(url);
export const parsePushPayload = (
eventData: { json: () => unknown } | null | undefined,
): SwPushPayload => {
try {
const raw = eventData?.json() as Record<string, unknown> | null;
if (!raw || typeof raw.title !== 'string') throw new Error('invalid');
return {
title: raw.title.slice(0, 80),
body: typeof raw.body === 'string' ? raw.body.slice(0, 160) : undefined,
linkUrl: typeof raw.linkUrl === 'string' && isInternalPath(raw.linkUrl) ? raw.linkUrl : '/',
};
} catch {
return { title: 'AppDefault', body: 'Notification', linkUrl: '/' };
}
};
```
```typescript
// apps/frontend/src/sw.ts (thin wrappers)
/// <reference lib="webworker" />
import { isInternalPath, parsePushPayload, selectFocusedClient } from './sw-helpers';
self.addEventListener('push', (event) => {
event.waitUntil((async () => {
const payload = parsePushPayload(event.data ?? undefined);
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
const focused = selectFocusedClient(clients);
if (focused) {
focused.postMessage({ type: 'push:received', payload });
return;
}
await self.registration.showNotification(payload.title, {
body: payload.body,
data: { url: payload.linkUrl },
});
})());
});
```
### Checklist
- [ ] Toute logique conditionnelle / regex / parsing extraite dans `sw-helpers.ts`
- [ ] `sw.ts` ne contient que precache, routes runtime, thin `addEventListener` qui appellent les helpers
- [ ] Helpers testés avec Vitest standard (pas de mock `self`)
- [ ] Listeners SW couverts par E2E + checklist manuelle DevTools post-build
---
<a id="pattern-test-mount-mock-composable-controllable"></a>
## Pattern : Test mount + mock composable contrôlable
### Synthèse
- **Objectif** : tester un composant Vue qui consomme un composable réactif en contrôlant l'état du composable depuis le test, sans monter de fixtures lourdes.
- **Contexte** : composant `<script setup>` qui appelle `const x = useFooBar()` et utilise `x.isReady.value` / `x.action()` dans le template.
- **Quand l'utiliser** : composant interactif avec branches conditionnelles dépendant du composable (5 états du footer push, modes admin vs user).
- **Quand l'éviter** : composant trivial sans logique conditionnelle (wrapper de markup).
### Analyse
- **Avantages** :
- couvre la cohérence script ↔ template (un attribut renommé dans le composable casse le test)
- test indépendant de la VRAIE implémentation (pas de duplication des stubs DOM `Notification`, `serviceWorker`, etc.)
- chaque scénario UI testable isolément en mutant l'état du mock entre tests
- **Limites / vigilance** :
- le mock doit retourner exactement la même SHAPE que le composable réel — un test peut passer alors que le composable a évolué et casse en runtime
- mitigation : déclarer un type `ReturnType<typeof useFooBar>` ou `export type UseFooBar` exporté par le composable, et typer le mock dessus
- `vi.mock` est hoisté → définir l'état du mock dans `beforeEach`, pas en module-level
### Validation
- Validé le : 28-04-2026
- Contexte technique : Vitest + @vue/test-utils + Vue 3 — RL799_V2
### Implémentation
```typescript
// composable
export type UsePushNotifications = {
isSupported: ComputedRef<boolean>;
isSubscribed: Ref<boolean>;
subscribe: () => Promise<void>;
};
export const usePushNotifications = (): UsePushNotifications => { /* … */ };
```
```typescript
// Test du composant
import { computed, ref } from 'vue';
import type { UsePushNotifications } from '@/composables/usePushNotifications';
let mockState: { isSupported: boolean; isSubscribed: boolean };
const subscribeSpy = vi.fn();
vi.mock('@/composables/usePushNotifications', () => ({
usePushNotifications: (): UsePushNotifications => ({
isSupported: computed(() => mockState.isSupported),
isSubscribed: ref(mockState.isSubscribed),
subscribe: subscribeSpy,
}),
}));
import MyComponent from '@/components/MyComponent.vue';
beforeEach(() => {
subscribeSpy.mockReset();
mockState = { isSupported: true, isSubscribed: false };
});
test('CTA visible quand !isSubscribed', () => {
mount(MyComponent);
expect(document.querySelector('[data-testid="cta"]')).not.toBeNull();
});
```
### Checklist
- [ ] Composable expose un type `Use<Name>` réutilisable dans les tests
- [ ] Mock retourne une shape conforme à ce type
- [ ] État du mock défini dans `beforeEach` (réinitialisé entre tests)
- [ ] Tests utilisent `data-testid` (pas de couplage CSS class)
- [ ] Couvre TOUS les états observables (pas que le happy path)
---
<a id="pattern-assertions-html-react-email"></a>
## Pattern : Assertions sur le HTML rendu par React Email — pièges à éviter
### Synthèse
- **Objectif** : écrire des tests Vitest fiables sur le HTML produit par un template React Email sans buter sur les commentaires JSX, les glyphes Unicode bruts, et les `<link rel="preload">` injectés automatiquement.
- **Contexte** : tests qui vérifient la présence ou l'absence de patterns dans le HTML rendu (côté mail Resend ou côté PDF Puppeteer en `previewOnly`).
- **Quand l'utiliser** : assertions sur le HTML rendu par `@react-email/components`.
- **Quand l'éviter** : tests qui valident uniquement la présence d'un composant React (snapshot par exemple).
### Validation
- Validé le : 29-04-2026
- Contexte technique : Vitest / React Email — RL799_V2
### Piège 1 — Commentaires React entre fragments JSX
React Email insère `<!-- -->` entre 2 fragments `{var}` adjacents :
```html
<p>V∴M∴ <!-- -->Pierre Vénérable</p>
```
Une regex `/V∴M∴ Pierre/` échoue. Helper utile :
```typescript
const matchAroundComments = (_source: string, ...parts: string[]): RegExp => {
const escaped = parts.map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
return new RegExp(escaped.join('\\s*(?:<!--[^>]*-->\\s*)*'), 'i');
};
assert.match(html, matchAroundComments(html, 'V∴M∴ ', 'Pierre Vénérable'));
```
### Piège 2 — Caractères Unicode bruts dans les constantes shared
Une constante avec `` (U+2019) brut est rendue **telle quelle** dans le HTML par React Email (pas d'encodage). Une regex avec `'` ASCII ou `&apos;` échoue.
```typescript
assert.match(html, /Dans l(?:'||&#x27;|&apos;)attente/);
```
### Piège 3 — `<link rel="preload">` injecté automatiquement par `<Img>`
Le composant `<Img>` de `@react-email/components` injecte `<link rel="preload" as="image" href="…">` dans le `<head>` en mode mail (pas en mode `previewOnly`). Une assertion `expect(html).not.toMatch(/<link\s+rel/i)` échoue sur le mail.
Solution : autoriser les `<link rel>` qui pointent vers `appBaseUrl` (URL maîtrisée), bloquer les autres :
```typescript
const linkMatches = html.match(/<link\s+[^>]*href=["']([^"']+)["']/gi) ?? [];
for (const linkTag of linkMatches) {
const href = /href=["']([^"']+)["']/i.exec(linkTag)?.[1];
assert.ok(href.startsWith(appBaseUrl) || href.startsWith('data:'));
}
```
### Piège 4 — Assertions trop génériques sur des termes ambigus
Une regex `/V∴M∴/` pour vérifier "ligne signature V∴M∴ vacante absente" matche aussi le footer "Le V∴M∴ et les officiers..." qui contient le même token. Solution : cibler un pattern plus précis du contexte (la classe CSS du style `vmSignature` : `font-weight:600`).
### Garde-fou Puppeteer mode PDF
En mode `previewOnly`, le HTML doit être strictement offline-safe :
```typescript
assert.doesNotMatch(html, /<link\s+rel/i); // pas de preload (≠ mode mail)
assert.doesNotMatch(html, /<script\s+src/i);
assert.doesNotMatch(html, /<img[^>]+src=["'](?!data:)/i); // pas de http(s)
```