Skip to content

Une petite histoire de tests de charge

Published:

Pour faire suite à mon article précédent, j’ai voulu aborder un autre sujet sur lequel j’ai travaillé autour de cette même app de poker planning.

Oui, cette application de poker planning, poker.slashgear.dev est un peu anecdotique, j’en conviens. Mais c’est justement l’intérêt : c’est ma sandbox. Une app simple, avec des boutons, des interactions, plusieurs pages — parfaite pour expérimenter sans risque.

Et l’appli commence à avoir une vraie base d’utilisateurs réguliers : plus de 200 rooms créées et 1 500 votes. C’est franchement cool.

Les Server-Sent Events, mon choix technique

La synchronisation des votes entre les différents utilisateurs est gérée grâce aux Server-Sent Events (SSE)1.

Les SSE, c’est une connexion HTTP persistante unidirectionnelle : le serveur pousse des événements vers le client, sans que le client ait besoin de redemander. Par rapport aux WebSockets, c’est bien plus simple à mettre en place — pas de protocole de handshake, pas de connexion bidirectionnelle à gérer, et ça passe sans broncher à travers les proxies HTTP. Pour un cas d’usage comme le mien — diffuser un état partagé à plusieurs clients — c’est largement suffisant.

Si vous voulez voir comment c’est implémenté, le code est disponible :

Voilà globalement comment les échanges se passent :

Diagramme de séquence SSE entre le front et le serveur Le client ouvre une connexion SSE, le serveur envoie l'état initial, puis à chaque vote (POST) le serveur broadcast un événement SSE à tous les clients connectés à la room. Client A Serveur Client B GET /rooms/:code/events SSE : état initial GET /rooms/:code/events SSE : état initial POST /rooms/:code/vote broadcast SSE : nouvel état SSE : nouvel état ↕ keep-alive toutes les 30s
Quand un client vote, le serveur broadcast un événement SSE à tous les clients connectés à la room.

La présentation de M4DZ lors du meetup LyonJS de février 2026 est une excellente ressource pour creuser le sujet :

Les SSE, c’est élégant. Mais j’avais un doute.

La question qui gratte

Est-ce que les SSE tiennent vraiment la charge ? Est-ce que ça ne va pas flancher dès qu’il y a un peu de monde sur l’application ?

Il n’y a qu’une seule façon de le savoir : mesurer. C’est quelque chose que j’ai appris chez Bedrock Streaming, notamment grâce à Yann Verry.

J’avais déjà utilisé plusieurs outils pour ça dans le passé : Gatling et Artillery.

Gatling, avec sa DSL spécifique… franchement pas fan. Artillery, la configuration en YAML paraît séduisante au début, mais ça devient vite pénible à maintenir dès qu’on sort des scénarios simples.

C’est là que j’ai découvert k6.

k6, une solution à mon problème

k6 est un outil open source de tests de charge développé par Grafana Labs. Le principe : vous écrivez vos scénarios en JavaScript (ou TypeScript), et k6 se charge de les exécuter avec le niveau de charge que vous définissez — nombre d’utilisateurs virtuels, rampe de montée, durée.

Ce qui m’a convaincu, c’est son SDK TypeScript : une API claire, une excellente DX2, et la possibilité de modéliser des scénarios complexes avec les mêmes réflexes qu’on a en développement web. On code un test comme on coderait un script — pas de YAML, pas de DSL ésotérique.

J’ai mis en place cinq scénarios pour couvrir différentes situations :

ScénarioObjectifCharge simulée
basic-workflowValider le flux nominal10–20 utilisateurs
spike-testTester une montée brutale5 → 100 utilisateurs en 10s
stress-testTrouver le point de ruptureJusqu’à 1 000 utilisateurs
realistic-sessionsSimuler de vraies sessions500 utilisateurs, 50–100 rooms
sse-enduranceTester la tenue des connexions longues1 000 connexions SSE pendant 20 min

Quelques conseils tirés de cette expérience :

Ce que je n’ai pas encore exploré : l’intégration avec Grafana Cloud pour visualiser les métriques en temps réel, et le mode k6 browser qui permet de piloter un vrai Chromium pour simuler des interactions utilisateur côté front.

Pour illustrer la flexibilité de k6, voici trois exemples de scénarios sur un site fictif (il y a d’autres exemples sur la documentation officielle de k6) :

Scénario HTTP — flux nominal

Le cas le plus courant : simuler des utilisateurs qui naviguent sur un site et appellent une API.

import http from "k6/http";
import { sleep, check } from "k6";

export const options = {
  stages: [
    { duration: "30s", target: 20 },
    { duration: "1m", target: 20 },
    { duration: "15s", target: 0 },
  ],
  thresholds: {
    http_req_failed: ["rate<0.01"],
    http_req_duration: ["p(95)<500"],
  },
};

export default function () {
  // Créer une room
  const create = http.post(
    "https://example.com/api/rooms",
    JSON.stringify({ name: `room-${__VU}` }),
    { headers: { "Content-Type": "application/json" } },
  );
  check(create, { "room créée": (r) => r.status === 201 });

  const { code } = create.json();

  // Voter
  const vote = http.post(
    `https://example.com/api/rooms/${code}/vote`,
    JSON.stringify({ value: 5 }),
    { headers: { "Content-Type": "application/json" } },
  );
  check(vote, { "vote accepté": (r) => r.status === 200 });

  sleep(1);
}
Scénario Browser — interactions réelles via Chromium

Le module k6/browser pilote un vrai Chromium : idéal pour mesurer les Core Web Vitals et tester les interactions UI sous charge.

import { browser } from "k6/browser";
import { check } from "k6";

export const options = {
  scenarios: {
    ui: {
      executor: "constant-vus",
      vus: 3,
      duration: "30s",
      options: {
        browser: { type: "chromium" },
      },
    },
  },
  thresholds: {
    browser_web_vital_lcp: ["p(75)<2500"],
    browser_web_vital_fid: ["p(75)<100"],
  },
};

export default async function () {
  const page = await browser.newPage();

  try {
    await page.goto("https://example.com");

    // Remplir le nom et rejoindre une room
    await page.locator('input[placeholder="Votre nom"]').fill(`User ${__VU}`);
    await page.locator('button[data-action="join"]').click();
    await page.waitForSelector(".room-board");

    check(page, {
      "board affiché": () => page.locator(".room-board").isVisible(),
    });

    // Voter
    await page.locator('.card[data-value="5"]').click();
  } finally {
    await page.close();
  }
}
Scénario MQTT — charge sur un broker de messages

Via l’extension k6/x/mqtt, k6 peut aussi tester des brokers MQTT — utile pour les apps IoT ou les systèmes de messagerie temps réel.

import mqtt from "k6/x/mqtt";
import { check } from "k6";
import { sleep } from "k6";

export const options = {
  vus: 10,
  duration: "30s",
  thresholds: {
    checks: ["rate>0.99"],
  },
};

export default function () {
  const client = new mqtt.Client({
    brokerAddr: "broker.example.com:1883",
    clientId: `k6-vu-${__VU}-${__ITER}`,
  });

  client.connect();
  check(client, { connecté: (c) => c.isConnected() });

  // Publier un événement
  client.publish(
    "rooms/updates",
    JSON.stringify({ vu: __VU, vote: 8, ts: Date.now() }),
    /* qos */ 1,
    /* retain */ false,
  );

  // S'abonner et attendre un message de retour
  const msg = client.subscribe("rooms/ack", /* qos */ 1, /* timeout ms */ 2000);
  check(msg, { "ack reçu": (m) => m !== null });

  sleep(1);
  client.disconnect();
}

Et vous ?

Vous avez déjà mis en place des tests de charge sur vos projets ?

En tout cas, de mon côté, le verdict est rassurant : l’appli tient bien la charge.

Footnotes

  1. Server-Sent Events — mécanisme HTTP permettant au serveur de pousser des événements vers le client via une connexion persistante unidirectionnelle.

  2. DX (Developer Experience) — qualité de l’expérience de développement offerte par un outil ou une API.

  3. P95 (95e percentile) — valeur en dessous de laquelle se situent 95 % des mesures. Un P95 à 500ms signifie que 95 % des requêtes répondent en moins de 500ms.


Next Post
Une petite histoire de compression