test(charge): mesure l'API sous charge avec k6
Aucun garde-fou de performance n'existait, et le dépôt ne chiffrait aucun temps de réponse. tests/load, quatre scénarios : - smoke : une minute sur chaque route de lecture, joué à chaque PR ; - charge : 50 utilisateurs, 40 sur le tableau de bord au rythme de son rafraîchissement, 10 qui explorent les sites ; - stress : débit croissant jusqu'à la rupture, arrêt au-delà de 10 % d'erreurs ; - limitation-debit : par le proxy, vérifie que nginx répond 429 et jamais 5xx. Seuils : p95 < 500 ms et p99 < 1 s sur les lectures, moins de 1 % d'échecs. k6 tourne en service Compose (profil load) sur le réseau du projet : il vise backend:8000 et mesure l'API plutôt que la limite de 20 req/s par adresse de nginx. Chaque tir écrit un rapport HTML, une synthèse Markdown et le JSON brut dans tests/load/results. make load-smoke, load-test, load-stress et load-limits ; le job E2E enchaîne le smoke et le test de limitation après Playwright. Closes #47
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
import { check, fail } from 'k6';
|
||||
import http from 'k6/http';
|
||||
|
||||
import { API, COMPTE } from './config.js';
|
||||
|
||||
export function connecter() {
|
||||
if (!COMPTE.email || !COMPTE.password) {
|
||||
fail('K6_EMAIL et K6_PASSWORD désignent un compte lecteur déjà activé');
|
||||
}
|
||||
const reponse = http.post(`${API}/auth/login`, JSON.stringify(COMPTE), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
tags: { name: 'auth_login', type: 'auth' },
|
||||
});
|
||||
if (!check(reponse, { 'connexion acceptée': (r) => r.status === 200 })) {
|
||||
fail(`connexion refusée : ${reponse.status} ${reponse.body}`);
|
||||
}
|
||||
return reponse.json('access_token');
|
||||
}
|
||||
|
||||
// Jeton propre à chaque VU, repris sur un 401 : un tir plus long que le TTL du jeton (15 min)
|
||||
// ne doit pas finir en erreurs. Jamais de refresh, dont la rotation révoquerait la session.
|
||||
let jetonDuVu = null;
|
||||
|
||||
export function lire(chemin, nom, jetonInitial) {
|
||||
const envoyer = () =>
|
||||
http.get(`${API}${chemin}`, {
|
||||
headers: { Authorization: `Bearer ${jetonDuVu || jetonInitial}` },
|
||||
tags: { name: nom, type: 'lecture' },
|
||||
});
|
||||
let reponse = envoyer();
|
||||
if (reponse.status === 401) {
|
||||
jetonDuVu = connecter();
|
||||
reponse = envoyer();
|
||||
}
|
||||
check(reponse, { [`${nom} répond 200`]: (r) => r.status === 200 });
|
||||
return reponse;
|
||||
}
|
||||
|
||||
export function preparer() {
|
||||
const jeton = connecter();
|
||||
const sites = lire('/sites', 'sites', jeton).json();
|
||||
if (!Array.isArray(sites) || sites.length === 0) {
|
||||
fail('aucun site en base : semer db/seeds/demo.sql ou importer des relevés avant le tir');
|
||||
}
|
||||
return { jeton, sites: sites.map((site) => site.site_id) };
|
||||
}
|
||||
|
||||
export function auHasard(liste) {
|
||||
return liste[Math.floor(Math.random() * liste.length)];
|
||||
}
|
||||
|
||||
// Même fenêtre que le détail d'un site dans le frontend : les 24 heures qui précèdent sa
|
||||
// dernière mesure, pas celles qui précèdent l'instant présent.
|
||||
export function consulterSite(siteId, jeton) {
|
||||
lire(`/sites/${siteId}`, 'site', jeton);
|
||||
const courant = lire(`/sites/${siteId}/current`, 'site_current', jeton);
|
||||
const horodatage = courant.status === 200 ? courant.json('timestamp') : null;
|
||||
const fin = horodatage ? new Date(horodatage) : new Date();
|
||||
const debut = new Date(fin.getTime() - 24 * 3600 * 1000);
|
||||
const fenetre = `start=${encodeURIComponent(debut.toISOString())}&end=${encodeURIComponent(fin.toISOString())}`;
|
||||
lire(`/readings?site_id=${siteId}&${fenetre}`, 'readings', jeton);
|
||||
lire(`/recommendations?site_id=${siteId}`, 'recommendations', jeton);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export const BASE_URL = __ENV.K6_BASE_URL || 'http://backend:8000';
|
||||
export const API = `${BASE_URL}/api/v1`;
|
||||
export const PROXY_URL = __ENV.K6_PROXY_URL || 'https://proxy';
|
||||
|
||||
export const COMPTE = {
|
||||
email: __ENV.K6_EMAIL,
|
||||
password: __ENV.K6_PASSWORD,
|
||||
};
|
||||
|
||||
// Seuils posés par l'ADR 0015, faute d'exigence chiffrée dans le cahier des charges.
|
||||
export const SEUILS = {
|
||||
http_req_failed: ['rate<0.01'],
|
||||
checks: ['rate>0.99'],
|
||||
'http_req_duration{type:lecture}': ['p(95)<500', 'p(99)<1000'],
|
||||
'http_req_duration{name:stats_summary}': ['p(95)<500'],
|
||||
'http_req_duration{name:readings}': ['p(95)<800'],
|
||||
};
|
||||
|
||||
export const STATISTIQUES = ['avg', 'min', 'med', 'p(90)', 'p(95)', 'p(99)', 'max'];
|
||||
@@ -0,0 +1,47 @@
|
||||
export function rapport(nom, data) {
|
||||
const horodatage = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const markdown = enMarkdown(nom, data);
|
||||
const sorties = {
|
||||
stdout: `${markdown}\n`,
|
||||
[`/results/${nom}-${horodatage}.json`]: JSON.stringify(data, null, 2),
|
||||
[`/results/${nom}-${horodatage}.md`]: markdown,
|
||||
};
|
||||
if (__ENV.K6_RESUME) {
|
||||
sorties[__ENV.K6_RESUME] = markdown;
|
||||
}
|
||||
return sorties;
|
||||
}
|
||||
|
||||
function valeur(metrique, statistique, unite) {
|
||||
const brut = metrique?.values?.[statistique];
|
||||
if (brut === undefined) {
|
||||
return '-';
|
||||
}
|
||||
return unite === '%' ? `${(brut * 100).toFixed(2)} %` : `${brut.toFixed(1)} ${unite}`;
|
||||
}
|
||||
|
||||
function enMarkdown(nom, data) {
|
||||
const m = data.metrics;
|
||||
const lignes = [
|
||||
`### Tir k6 « ${nom} »`,
|
||||
'',
|
||||
'| Mesure | Valeur |',
|
||||
'|---|---|',
|
||||
`| Requêtes | ${m.http_reqs?.values?.count ?? 0} (${valeur(m.http_reqs, 'rate', 'req/s')}) |`,
|
||||
`| Échecs HTTP | ${valeur(m.http_req_failed, 'rate', '%')} |`,
|
||||
`| Vérifications réussies | ${valeur(m.checks, 'rate', '%')} |`,
|
||||
`| Durée médiane | ${valeur(m.http_req_duration, 'med', 'ms')} |`,
|
||||
`| Durée p95 | ${valeur(m.http_req_duration, 'p(95)', 'ms')} |`,
|
||||
`| Durée p99 | ${valeur(m.http_req_duration, 'p(99)', 'ms')} |`,
|
||||
`| VUs au plus haut | ${m.vus_max?.values?.max ?? '-'} |`,
|
||||
'',
|
||||
'| Seuil | Résultat |',
|
||||
'|---|---|',
|
||||
];
|
||||
for (const [metrique, detail] of Object.entries(m)) {
|
||||
for (const [expression, resultat] of Object.entries(detail.thresholds || {})) {
|
||||
lignes.push(`| \`${metrique}\` ${expression} | ${resultat.ok ? 'tenu' : '**franchi**'} |`);
|
||||
}
|
||||
}
|
||||
return lignes.join('\n');
|
||||
}
|
||||
Reference in New Issue
Block a user