perf(tests): porte de qualité 17 s → 6 s, mutation complète 5 min → 3 min 40
Some checks are pending
CI / javascript (push) Waiting to run
CI / python (push) Waiting to run
CI / mutation (push) Waiting to run

Passe de performance guidée par la mesure, pas par l'intuition.

Porte de qualité (`npm run check`) — la commande qui tourne des dizaines de
fois par jour :
- Les 5 vérifications sont indépendantes : exécutées en parallèle via
  scripts/check.mjs au lieu d'un enchaînement `&&` qui imposait la somme des
  durées ET 7 démarrages npm imbriqués.        14 s → 6 s
- Cache ESLint (--cache) et compilation incrémentale tsc.  16 s → 14 s
- Chemin critique restant : vitest 4,9 s, dont ~2 s de mise en place jsdom.

Tests de mutation :
- Config Vitest dédiée en pool `threads` sans ré-isolation. Mesuré sur le
  dry-run Stryker : overhead d'amorçage 12 844 ms → 1 592 ms, soit ~90 % du
  cycle par mutant.                             5 min 02 → 3 min 40
- Le mode incrémental ramène le profil complet à 12 s après une édition.

Pistes mesurées puis REJETÉES (documentées dans README-CI.md pour éviter
qu'on les retente) :
- Découper la mutation en 5 processus Stryker parallèles : 262 s contre 235 s.
  Chaque processus repaie son bac à sable et son dry-run.
- Monter `concurrency` de 6 à 16 : aucun effet (~8,5 mutants/s partout), le
  débit est borné par l'orchestration mono-processus de Stryker.
- Séparer vitest API/frontend en deux processus : aucun gain, jsdom domine.
- Pool `threads` sur la suite complète : 18,8 s contre 3,5 s. Le bon réglage
  dépend du jeu de fichiers (jsdom ou non) — d'où deux configs distinctes.

Le périmètre muté est inchangé (1511 mutants) : un score obtenu en retirant
les mutants gênants vaudrait moins que pas de score.

Corrections au passage :
- scripts/*.mjs n'était couvert par aucune section ESLint
- Les deux suites pytest ont chacune un paquet `src` : réunies dans un seul
  processus, le premier importé masquait l'autre. Séparées (et parallèles).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Nicolas Fryder 2026-08-18 13:49:54 +02:00
parent 60074fb015
commit 366bb8630d
9 changed files with 201 additions and 67 deletions

View file

@ -11,7 +11,7 @@
# #
set -e set -e
echo "▶ pre-push : porte de qualité (lint, types, tests)…" echo "▶ pre-push : porte de qualité (~6 s)…"
start=$(date +%s) start=$(date +%s)

3
.gitignore vendored
View file

@ -22,3 +22,6 @@ reports/
# Session Claude Code (contient des secrets) # Session Claude Code (contient des secrets)
.claude/ .claude/
CLAUDE.md CLAUDE.md
# Caches d outillage (lint, tsc)
node_modules/.cache/

View file

@ -8,7 +8,7 @@ Coolify redéploie sur webhook à **chaque** push (`dev` → dev.metalfrom.eu,
``` ```
git push ──▶ [hook pre-push : npm run check] ──▶ Forgejo ──▶ webhook ──▶ Coolify redeploy git push ──▶ [hook pre-push : npm run check] ──▶ Forgejo ──▶ webhook ──▶ Coolify redeploy
~15 s, bloquant 6 s, bloquant
``` ```
## Installation (une fois par clone) ## Installation (une fois par clone)
@ -23,36 +23,47 @@ pip install -r requirements-dev.txt
| Commande | Ce que ça fait | Froid | Incrémental | | Commande | Ce que ça fait | Froid | Incrémental |
|---|---|---|---| |---|---|---|---|
| `npm run check` | **La porte** : ESLint + Ruff + tsc + tous les tests | ~15 s | — | | `npm run check` | **La porte** : ESLint + tsc + Ruff + 444 tests | **6 s** | — |
| `npm test` | 401 tests JS | 3,5 s | — | | `npm run check:sequential` | Idem, en série (pour isoler un échec) | 14 s | — |
| `npm run test:py` | 43 tests Python | 1 s | — | | `npm run test:mutation` | Mutation, logique pure (352 mutants) | 33 s | ~5 s |
| `npm run lint` / `lint:py` | ESLint / Ruff | 3 s / 1 s | — | | `npm run test:mutation:full` | Mutation, toute l'API (1511 mutants) | 3 min 40 | **12 s** |
| `npm run typecheck` | `tsc --checkJs` sur l'API | 6 s | — | | `npm run check:full` | `check` + audits de dépendances + mutation complète | — | ~40 s |
| `npm run test:mutation` | Mutation, logique pure (352 mutants) | 34 s | ~5 s | | `npm run check:clean` | Purge les caches ESLint / tsc | — | — |
| `npm run test:mutation:full` | Mutation, toute l'API (1511 mutants) | 5 min | 13 s |
| `npm run check:full` | `check` + audits de dépendances + mutation complète | — | ~1 min |
### Où passe le temps, et pourquoi c'est acceptable ### Où passe le temps
La commande de la boucle de développement, c'est `npm run check` : **17 s**, La commande de la boucle de développement, c'est `npm run check` : **6 s**,
et elle inclut déjà les 401 tests, ESLint, Ruff et tsc. C'est elle qui tourne tests compris. C'est elle qui tourne cent fois par jour, et elle est passée de
cent fois par jour. 17 s à 6 s par trois mesures successives :
La mutation n'est pas une commande de boucle courte. Son coût se lit ainsi : | Levier | Gain |
|---|---|
| Les 5 vérifications en parallèle au lieu de `&&` (elles sont indépendantes, et l'enchaînement npm coûtait 7 démarrages) | 14 s → 6 s |
| Cache ESLint (`--cache`) et compilation incrémentale tsc | 16 s → 14 s |
| Chemin critique restant : vitest 4,9 s, dont ~2 s de mise en place jsdom | — |
- `test:mutation` (352 mutants) : 34 s à froid, ~5 s ensuite. Assez rapide pour La mutation n'est pas une commande de boucle courte :
être lancée avant chaque commit qui touche la validation ou l'auth.
- `test:mutation:full` (1511 mutants) : 5 min **une seule fois** (clone neuf ou
après avoir modifié les quatre fichiers mutés d'un coup). Après une édition
normale d'un seul fichier : **13 s**, mesuré.
Le fichier incrémental vit dans `reports/`, qui est gitignoré : un clone neuf - `test:mutation` (352 mutants) : 33 s à froid, ~5 s ensuite.
paie donc les 5 minutes une fois. C'est assumé — c'est un audit, pas un test. - `test:mutation:full` (1511 mutants) : 3 min 40 **une seule fois**, puis
**12 s** après une édition. Le fichier incrémental vit dans `reports/`, qui
est gitignoré : un clone neuf paie donc le run complet une fois.
**Ce qui n'a délibérément pas été fait pour aller plus vite** : réduire encore **Ce qui a été essayé et rejeté sur mesure** (les intuitions se sont trompées
le périmètre muté. Descendre sous ~1500 mutants sur l'API reviendrait à ne plus plus d'une fois — tout est re-mesurable) :
mesurer grand-chose, et un score de mutation flatteur obtenu en retirant les
mutants gênants est pire qu'une absence de score. | Piste | Résultat |
|---|---|
| Découper la mutation en 5 processus Stryker parallèles | **262 s contre 235 s** : chaque processus repaie son bac à sable et son dry-run. Retiré. |
| Monter `concurrency` de 6 à 16 | Aucun effet : ~8,5 mutants/s dans tous les cas. Le débit est limité par l'orchestration mono-processus de Stryker, pas par le CPU. |
| Séparer les tests API / frontend en deux processus vitest | Aucun gain : 4,9 s dans les deux cas, jsdom domine. |
| Pool vitest `threads` sur la **suite complète** | 18,8 s contre 3,5 s (jsdom pénalisé). Défaut conservé. |
| Pool vitest `threads` sur la **config de mutation** (sans jsdom) | Overhead d'amorçage 12 844 ms → **1 592 ms**. Adopté. |
**Ce qui n'a délibérément pas été fait** : réduire le périmètre muté. Descendre
sous ~1500 mutants sur l'API reviendrait à ne plus mesurer grand-chose, et un
score flatteur obtenu en retirant les mutants gênants est pire que pas de score.
Le plancher de ~3 min à froid est celui de Stryker sur 1511 mutants, assumé.
## Ce que couvrent les tests ## Ce que couvrent les tests
@ -68,38 +79,21 @@ mutants gênants est pire qu'une absence de score.
| **Python** | `apps/geocoder/tests/`, `apps/crawler/tests/` | Parseur de localisations, annulation | | **Python** | `apps/geocoder/tests/`, `apps/crawler/tests/` | Parseur de localisations, annulation |
| **Mutation** | `stryker*.config.json` | 82 % (logique pure) / 65 % (API complète) | | **Mutation** | `stryker*.config.json` | 82 % (logique pure) / 65 % (API complète) |
## Performance : les décisions et leurs mesures ## Principe des tests
Le principe : **aucun test ne monte de conteneur, ne compile, ni ne touche le réseau.** **Aucun test ne monte de conteneur, ne compile, ni ne touche le réseau.**
Quatre optimisations, toutes mesurées (les intuitions non vérifiées se sont Deux choix structurants côté API :
révélées fausses au moins une fois) :
1. **Une app Fastify par fichier de test, pas par cas.** 1. **Une app Fastify par fichier de test, pas par cas.** `buildServer()` coûte
`buildServer()` coûte 14 ms. À raison d'une construction par `it()`, ces 14 ms ; à raison d'une construction par `it()`, ces 14 ms étaient multipliés
14 ms étaient multipliés par ~12 000 exécutions pendant un run de mutation. par des milliers d'exécutions en mutation. Les tests partagent une app et
Les tests partagent une app et reprogramment un pool via `pool.reset()` reprogramment un faux pool via `pool.reset()` (`test/helpers/testApp.js`).
(`test/helpers/testApp.js`).
2. **Config Vitest dédiée à la mutation.** 2. **Plafonds de débit paramétrables.** Conséquence du point 1 : une app
Stryker recharge les fichiers de test à chaque mutant. En ne déclarant que partagée accumule l'état du limiteur. Les valeurs réelles de production sont
ceux qui couvrent le code muté (et surtout pas le test a11y, qui initialise donc vérifiées séparément dans `rateLimit.test.js`, pour que cette
jsdom en ~2 s), le profil pur est passé de **1 min 23 à 34 s**. paramétrisation ne crée pas d'angle mort sur une protection de sécurité.
3. **Mutants `StringLiteral` exclus.**
Les handlers sont à ~80 % du SQL en template literals. Muter le contenu
d'une chaîne SQL ne mesure rien. Les retirer a fait passer le profil complet
de 7 min 50 à 5 min — et le score de 42 % à 65 %, parce que le bruit
disparaissait du dénominateur.
4. **Plafonds de débit paramétrables.**
Conséquence du point 1 : une app partagée accumule l'état du limiteur. Les
valeurs réelles de production sont vérifiées séparément dans
`rateLimit.test.js`, pour que la paramétrisation ne crée pas d'angle mort.
Ce qui a été **essayé et rejeté sur mesure** : le pool Vitest `threads` avec
`isolate: false`, réputé plus rapide, donne **18,8 s contre 3,5 s** ici (la
mise en place de jsdom est pénalisée). Le défaut (`forks`) est conservé.
## Pourquoi deux profils de mutation ## Pourquoi deux profils de mutation

View file

@ -91,13 +91,17 @@ export default [
rules: { "no-irregular-whitespace": "off" }, rules: { "no-irregular-whitespace": "off" },
}, },
// ---- Config outillage à la racine ---- // ---- Config et scripts d'outillage (racine + scripts/) ----
{ {
files: ["*.js", "*.config.js"], files: ["*.js", "*.config.js", "scripts/**/*.{js,mjs}"],
languageOptions: { languageOptions: {
ecmaVersion: 2023, ecmaVersion: 2023,
sourceType: "module", sourceType: "module",
globals: { ...globals.node }, globals: { ...globals.node },
}, },
rules: {
"no-unused-vars": ["error", { argsIgnorePattern: "^_", caughtErrors: "none" }],
"no-console": "off",
},
}, },
]; ];

View file

@ -13,7 +13,9 @@
"skipLibCheck": true, "skipLibCheck": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"types": ["node"], "types": ["node"],
"maxNodeModuleJsDepth": 0 "maxNodeModuleJsDepth": 0,
"incremental": true,
"tsBuildInfoFile": "node_modules/.cache/tsc/jsconfig.tsbuildinfo"
}, },
"include": ["apps/api/src/**/*.js", "apps/api/test/**/*.js"], "include": ["apps/api/src/**/*.js", "apps/api/test/**/*.js"],
"exclude": ["node_modules", "**/node_modules"] "exclude": ["node_modules", "**/node_modules"]

View file

@ -8,8 +8,8 @@
"apps/api" "apps/api"
], ],
"scripts": { "scripts": {
"lint": "eslint .", "lint": "eslint . --cache --cache-location node_modules/.cache/eslint/",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix --cache --cache-location node_modules/.cache/eslint/",
"typecheck": "tsc -p jsconfig.json", "typecheck": "tsc -p jsconfig.json",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
@ -23,10 +23,13 @@
"test:py:crawler": "cd apps/crawler && python -m pytest tests -q", "test:py:crawler": "cd apps/crawler && python -m pytest tests -q",
"audit:js": "npm audit --audit-level=high", "audit:js": "npm audit --audit-level=high",
"audit:py": "pip-audit -r apps/api/../crawler/requirements.txt -r apps/geocoder/requirements.txt", "audit:py": "pip-audit -r apps/api/../crawler/requirements.txt -r apps/geocoder/requirements.txt",
"_comment_check": "`check` = la porte de qualité lancée avant chaque push (~30s). Les tests de mutation et les audits de dépendances en sont exclus : trop lents / dépendants du réseau. Lancer `npm run check:full` avant une release.", "_comment_check": "`check` = la porte de qualite lancee avant chaque push. Les cinq verifications tournent en parallele (voir scripts/check.mjs). Mutation et audits de dependances en sont exclus : trop lents / dependants du reseau — voir `check:full`.",
"check": "npm run lint && npm run lint:py && npm run typecheck && npm run test && npm run test:py", "check": "node scripts/check.mjs",
"check:full": "npm run check && npm run audit:js && npm run audit:py && npm run test:mutation:full", "check:full": "npm run check && npm run audit:js && npm run audit:py && npm run test:mutation:full",
"hooks:install": "git config core.hooksPath .githooks" "hooks:install": "git config core.hooksPath .githooks",
"_comment_cache": "lint et typecheck utilisent un cache dans node_modules/.cache : `check` tourne des dizaines de fois par jour, refaire l'analyse complete de fichiers inchanges a chaque fois n'apporte rien. Les caches sont invalides par mtime+contenu ; `npm run check:clean` les purge.",
"check:clean": "node -e \"require('fs').rmSync('node_modules/.cache',{recursive:true,force:true})\"",
"check:sequential": "node scripts/check.mjs --sequential"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.17.0", "@eslint/js": "^9.17.0",

101
scripts/check.mjs Normal file
View file

@ -0,0 +1,101 @@
#!/usr/bin/env node
/**
* Porte de qualité exécute les vérifications EN PARALLÈLE.
*
* Les cinq vérifications sont indépendantes (aucune ne lit la sortie d'une
* autre). Les enchaîner avec `&&` imposait deux coûts inutiles :
* - la somme des durées au lieu du maximum ;
* - sept invocations `npm run` imbriquées, chacune avec son propre coût de
* démarrage npm (~300 ms).
*
* On les lance donc toutes d'un coup, on tamponne les sorties, et on les
* restitue dans un ordre stable à la fin. Le détail par tâche reste affiché
* pour que le goulot d'étranglement demeure visible.
*
* `--sequential` rétablit l'exécution en série (utile pour isoler un échec
* quand deux tâches écrivent des messages entremêlés).
*/
import { spawn } from "node:child_process";
const isWin = process.platform === "win32";
const sequential = process.argv.includes("--sequential");
/** bin = binaire de node_modules/.bin (résolu par npm via PATH). */
const TASKS = [
{
name: "eslint",
cmd: "eslint",
args: [".", "--cache", "--cache-location", "node_modules/.cache/eslint/"],
},
{
name: "tsc",
cmd: "tsc",
args: ["-p", "jsconfig.json"],
},
// Séparer les tests API (node) et frontend (jsdom) en deux processus a été
// mesuré : aucun gain (4,9 s dans les deux cas — c'est la mise en place de
// jsdom qui domine, et les deux processus se disputent les cœurs). Gardé en
// un seul.
{ name: "vitest", cmd: "vitest", args: ["run"] },
{
name: "ruff",
cmd: "python",
args: ["-m", "ruff", "check", "."],
},
// Les deux suites Python tournent dans des processus distincts : chaque app a
// son propre paquet `src`, et les réunir dans un seul pytest fait que le
// premier `src` importé masque l'autre (ModuleNotFoundError: src.parser).
// Séparées, elles s'exécutent aussi en parallèle.
{ name: "pytest:geocoder", cmd: "python", args: ["-m", "pytest", "tests", "-q"], cwd: "apps/geocoder" },
{ name: "pytest:crawler", cmd: "python", args: ["-m", "pytest", "tests", "-q"], cwd: "apps/crawler" },
];
function run(task) {
return new Promise((resolve) => {
const started = Date.now();
const child = spawn(task.cmd, task.args, {
shell: isWin,
cwd: task.cwd ?? process.cwd(),
});
let out = "";
child.stdout.on("data", (b) => (out += b));
child.stderr.on("data", (b) => (out += b));
child.on("error", (err) => {
resolve({ ...task, code: 1, out: `${err.message}\n`, ms: Date.now() - started });
});
child.on("close", (code) => {
resolve({ ...task, code, out, ms: Date.now() - started });
});
});
}
const started = Date.now();
let results;
if (sequential) {
results = [];
for (const t of TASKS) results.push(await run(t));
} else {
results = await Promise.all(TASKS.map(run));
}
const failed = results.filter((r) => r.code !== 0);
for (const r of results) {
const mark = r.code === 0 ? "✓" : "✗";
console.log(`\n${mark} ${r.name} (${(r.ms / 1000).toFixed(1)}s)`);
// Sur succès on ne montre que les avertissements éventuels, pas le bruit.
if (r.code !== 0 || /warning|warn/i.test(r.out)) {
const body = r.out.trimEnd();
if (body) console.log(body.replace(/^/gm, " "));
}
}
const total = ((Date.now() - started) / 1000).toFixed(1);
if (failed.length) {
console.error(`\n✗ Porte de qualité en échec : ${failed.map((f) => f.name).join(", ")} (${total}s)`);
process.exit(1);
}
console.log(`\n✓ Tout est vert (${total}s)`);

View file

@ -14,6 +14,13 @@ import { defineConfig } from "vitest/config";
export default defineConfig({ export default defineConfig({
test: { test: {
environment: "node", environment: "node",
// Voir vitest.mutation.full.config.js pour les mesures : sur un jeu de
// fichiers 100 % node, threads sans ré-isolation divise l'overhead
// d'amorçage par 8. Ne pas reporter tel quel dans vitest.config.js, qui
// contient le test a11y sous jsdom (où ce réglage est plus lent).
pool: "threads",
poolOptions: { threads: { isolate: false, singleThread: true } },
fileParallelism: false,
include: [ include: [
"apps/api/test/validate.test.js", "apps/api/test/validate.test.js",
"apps/api/test/adminAuth.test.js", "apps/api/test/adminAuth.test.js",

View file

@ -3,15 +3,35 @@ import { defineConfig } from "vitest/config";
/** /**
* Config Vitest du profil de mutation COMPLET (`npm run test:mutation:full`). * Config Vitest du profil de mutation COMPLET (`npm run test:mutation:full`).
* *
* Même principe que vitest.mutation.config.js : ne charger que les fichiers qui * Deux réglages, tous deux mesurés sur l'overhead d'amorçage que Stryker paie
* couvrent le code muté. Ici tout le code API est muté, donc tous les tests API * à CHAQUE activation de mutant (ligne « net / overhead » du dry-run) :
* sont nécessaires mais surtout PAS les tests frontend, dont le fichier a11y *
* initialise jsdom (~2 s) à chaque activation de mutant alors qu'il ne couvre * 1. Ne charger que les tests API. Le fichier a11y initialise jsdom (~2 s) et
* aucune ligne de l'API. * ne couvre aucune ligne de l'API : le charger reviendrait à payer ce coût
* 1500 fois.
*
* 2. Workers `threads` sans -isolation. Mesuré sur ce jeu de fichiers :
* forks, isolate:true (défaut) : overhead 12 844 ms
* forks, isolate:false : overhead 7 064 ms
* threads, isolate:false : overhead 1 592 ms retenu
* Le temps net des tests, lui, ne bouge presque pas (~1 s) : l'amorçage
* représentait 90 % du cycle.
*
* À NE PAS reporter dans vitest.config.js : la suite complète contient le
* test a11y, et sous jsdom ce même réglage est nettement PLUS lent (18,8 s
* contre 3,5 s). Le bon réglage dépend du jeu de fichiers re-mesurer.
*
* L'isolation ne protège de rien ici : chaque fichier de test construit sa
* propre app et son propre faux pool, il n'y a aucun état global mutable.
*/ */
export default defineConfig({ export default defineConfig({
test: { test: {
environment: "node", environment: "node",
pool: "threads",
poolOptions: { threads: { isolate: false, singleThread: true } },
// Stryker parallélise déjà entre mutants (concurrency) : paralléliser aussi
// les fichiers à l'intérieur d'un worker ne ferait que se disputer les cœurs.
fileParallelism: false,
include: ["apps/api/test/**/*.test.js"], include: ["apps/api/test/**/*.test.js"],
reporters: ["default"], reporters: ["default"],
}, },