Les tests fonctionnels vérifient que le système fonctionne correctement. Les tests de performance vérifient qu'il fonctionne encore correctement sous pression. k6 comble cet écart.

Ce qu'est k6 et pourquoi les ingénieurs QA devraient le connaître

k6 est un outil de test de performance open source développé par Grafana Labs. Vous écrivez des scripts en JavaScript ou TypeScript, les lancez depuis la ligne de commande, et obtenez des données détaillées sur le comportement de votre système sous charge. Le dépôt GitHub dépasse les 24 000 étoiles et l'outil est utilisé dans des organisations de toutes tailles.

Ce qui fait de k6 le bon point d'entrée pour les ingénieurs QA, c'est son modèle de scripting. Si vous écrivez déjà des tests Playwright ou Jest, la syntaxe vous sera familière : vous importez des modules, écrivez des fonctions, faites des requêtes HTTP et ajoutez des assertions. Pas d'interface graphique lourde, pas de format d'enregistrement propriétaire, pas de fichier de configuration XML. Juste un script.

k6 s'intègre aussi proprement dans GitHub Actions. Vous installez le binaire, lancez votre script, et si le test échoue (on reviendra sur ce que "échouer" signifie), l'étape CI se termine avec un code non nul. C'est le même contrat que tout autre outil de test.

k6 ne remplace pas Playwright. Playwright vérifie le comportement ; k6 mesure les performances sous charge. Ils répondent à des questions différentes et ont tous les deux leur place dans une stratégie QA mature.

Installer k6 et lancer votre premier test

k6 est distribué sous forme de binaire autonome. Sur macOS :

brew install k6

Sur Linux (Debian/Ubuntu) :

sudo gpg -k
sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69
echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6.list
sudo apt-get update
sudo apt-get install k6

Sur Windows, utilisez l'installateur officiel depuis k6.io/docs/get-started/installation ou installez via Chocolatey :

choco install k6

Une fois installé, vérifiez que ça fonctionne :

k6 version

Créez ensuite un fichier load-test.js avec ce script minimal :

import http from 'k6/http';

export default function () {
  http.get('https://test-api.k6.io/public/crocodiles/');
}

Lancez-le :

k6 run load-test.js

k6 utilise test-api.k6.io comme environnement de test public. Vous pouvez l'exécuter sans rien configurer. Vous verrez dans le terminal le nombre de requêtes, les temps de réponse et un tableau récapitulatif. C'est votre premier test de charge.

Anatomie d'un test k6

Un script k6 réel comporte trois parties. L'export options contrôle l'exécution du test, la fonction default contient la logique de test, et la configuration gère les requêtes. La structure :

import http from 'k6/http';
import { sleep } from 'k6';

export const options = {
  vus: 10,         // utilisateurs virtuels simultanés
  duration: '30s', // durée d'exécution
};

export default function () {
  http.get('https://your-api.example.com/api/products');
  sleep(1); // temps de réflexion entre les requêtes
}

vus désigne les utilisateurs virtuels. Chaque VU exécute la fonction default en boucle pendant toute la durée du test, indépendamment des autres. Avec vus: 10 et duration: '30s', 10 utilisateurs simulés frappent votre endpoint en continu pendant 30 secondes.

Le sleep(1) n'est pas facultatif : il modélise le temps de réflexion qu'un vrai utilisateur prendrait entre ses actions. Sans lui, chaque VU envoie des requêtes aussi vite que le serveur peut répondre, ce qui est irréaliste et produit des chiffres de débit trompeurs. Une seconde est une valeur raisonnable par défaut ; ajustez-la au comportement réel de vos utilisateurs.

Les utilisateurs virtuels k6 ne sont pas des threads ni des processus, mais des goroutines légères. Une seule instance k6 peut faire tourner des milliers de VUs sans consommer une quantité proportionnelle de mémoire ou de CPU. Pas besoin d'un cluster pour un test de charge utile.

Pour les requêtes POST avec un corps JSON, nécessaires pour la plupart des tests d'API :

import http from 'k6/http';
import { sleep } from 'k6';

export const options = {
  vus: 20,
  duration: '1m',
};

export default function () {
  const url = 'https://your-api.example.com/api/login';
  const payload = JSON.stringify({
    username: 'testuser@example.com',
    password: 'testpassword',
  });
  const params = {
    headers: { 'Content-Type': 'application/json' },
  };

  http.post(url, payload, params);
  sleep(1);
}

Lire la sortie k6

Après une exécution, k6 affiche un tableau récapitulatif dans le terminal. Savoir le lire rapidement transforme des chiffres bruts en informations exploitables.

Une sortie typique ressemble à ceci :

✓ checks.........................: 100.00% ✓ 1800 ✗ 0
  data_received..................: 4.3 MB  143 kB/s
  data_sent......................: 523 kB  17 kB/s
  http_req_blocked...............: avg=1.2ms    min=1µs    med=4µs    max=312ms  p(90)=7µs    p(95)=11µs
  http_req_connecting............: avg=743µs    min=0s     med=0s     max=181ms  p(90)=0s     p(95)=0s
  http_req_duration..............: avg=312ms    min=98ms   med=280ms  max=2.1s   p(90)=520ms  p(95)=640ms
    { expected_response:true }...: avg=312ms    min=98ms   med=280ms  max=2.1s   p(90)=520ms  p(95)=640ms
  http_req_failed................: 0.00%   ✓ 0 ✗ 1800
  http_req_receiving.............: avg=2.1ms    min=45µs   med=1.1ms  max=74ms   p(90)=4.2ms  p(95)=5.8ms
  http_req_sending...............: avg=178µs    min=27µs   med=136µs  max=3.2ms  p(90)=312µs  p(95)=389µs
  http_req_tls_handshaking.......: avg=412µs    min=0s     med=0s     max=168ms  p(90)=0s     p(95)=0s
  http_req_waiting...............: avg=310ms    min=97ms   med=278ms  max=2.1s   p(90)=518ms  p(95)=637ms
  http_reqs......................: 1800    60.0/s
  iteration_duration.............: avg=1.31s    min=1.1s   med=1.28s  max=3.1s   p(90)=1.52s  p(95)=1.64s
  iterations.....................: 1800    60.0/s
  vus............................: 10      min=10     max=10
  vus_max........................: 10      min=10     max=10

Les trois métriques à regarder en premier :

http_req_duration est le temps aller-retour total pour chaque requête. La colonne p(95) donne le 95e percentile : 95 % des requêtes se sont terminées dans ce délai. C'est ce chiffre qui compte pour les SLA, pas la moyenne. Les moyennes masquent les valeurs extrêmes ; le p95 non. http_reqs indique le nombre total de requêtes effectuées et le débit par seconde. http_req_failed montre le pourcentage de requêtes ayant reçu une réponse d'erreur (4xx ou 5xx). Zéro est attendu ; tout ce qui dépasse mérite investigation.

Ajouter des checks

La correction fonctionnelle sous charge compte autant que la vitesse. k6 dispose d'un mécanisme d'assertion appelé checks, qui fonctionne comme les instructions expect dans d'autres frameworks :

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

export const options = {
  vus: 10,
  duration: '30s',
};

export default function () {
  const res = http.get('https://your-api.example.com/api/products');

  check(res, {
    'statut est 200': (r) => r.status === 200,
    'temps de réponse < 500ms': (r) => r.timings.duration < 500,
    'corps contient des produits': (r) => r.body.includes('"id"'),
  });

  sleep(1);
}

La différence importante avec les assertions traditionnelles : un check qui échoue n'arrête pas le test. Tous les VUs continuent de tourner. k6 comptabilise combien de checks ont réussi et échoué sur toutes les itérations, et affiche le pourcentage dans le récapitulatif. C'est intentionnel. Vous voulez savoir quel pourcentage de requêtes sous charge a produit une réponse correcte, pas seulement si une requête a échoué.

Utilisez les checks pour tout ce que vous asserteriez normalement dans un test fonctionnel : code de statut, temps de réponse, présence des champs attendus dans le corps de la réponse. Le taux de réussite des checks dans le récapitulatif vous donne un signal de santé global sur des milliers de requêtes.

Pour un endpoint de connexion qui retourne un token :

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

export default function () {
  const payload = JSON.stringify({
    username: 'user@example.com',
    password: 'password123',
  });

  const res = http.post('https://your-api.example.com/auth/login', payload, {
    headers: { 'Content-Type': 'application/json' },
  });

  check(res, {
    'connexion réussie': (r) => r.status === 200,
    'token présent': (r) => JSON.parse(r.body).token !== undefined,
    'connexion assez rapide': (r) => r.timings.duration < 1000,
  });

  sleep(1);
}

Stages : montée en charge, maintien, descente

Une charge plate et constante est rarement ce dont vous avez besoin. Le trafic réel monte progressivement à l'arrivée des utilisateurs, maintient un pic, puis redescend. k6 modélise cela avec les stages, qui définissent comment le nombre de VUs évolue dans le temps :

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

export const options = {
  stages: [
    { duration: '2m', target: 50 },   // montée à 50 VUs en 2 minutes
    { duration: '5m', target: 50 },   // maintien à 50 VUs pendant 5 minutes
    { duration: '2m', target: 100 },  // montée à 100 VUs en 2 minutes
    { duration: '5m', target: 100 },  // maintien à 100 VUs pendant 5 minutes
    { duration: '2m', target: 0 },    // descente à 0
  ],
};

export default function () {
  const res = http.get('https://your-api.example.com/api/products');

  check(res, {
    'statut est 200': (r) => r.status === 200,
    'temps de réponse acceptable': (r) => r.timings.duration < 1000,
  });

  sleep(1);
}

Ce profil (montée, maintien, montée, maintien, descente) correspond à un profil de test de stress. Il révèle à quel nombre d'utilisateurs votre système commence à se dégrader. Vous verrez le p95 des temps de réponse grimper régulièrement à mesure que les VUs augmentent. Le point d'inflexion où il monte brusquement est le point de rupture de votre système.

Pour la plupart des tests d'API, un test en deux phases suffit : montée puis maintien.

export const options = {
  stages: [
    { duration: '1m', target: 50 },  // échauffement
    { duration: '5m', target: 50 },  // charge soutenue
    { duration: '30s', target: 0 },  // descente
  ],
};

La phase de descente compte. Couper la charge brusquement peut masquer des problèmes de pool de connexions et donne des métriques plus propres en fin de test.

Seuils : faire passer ou échouer le test

Les checks indiquent ce qui s'est passé. Les seuils déterminent si l'exécution réussit ou échoue. Un seuil est une condition de succès/échec sur n'importe quelle métrique. S'il est violé, k6 se termine avec un code non nul. C'est ce mécanisme qui rend les tests de charge utiles en CI.

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

export const options = {
  stages: [
    { duration: '1m', target: 50 },
    { duration: '5m', target: 50 },
    { duration: '30s', target: 0 },
  ],
  thresholds: {
    http_req_duration: ['p(95)<2000'],  // le 95e percentile doit rester sous 2 secondes
    http_req_failed: ['rate<0.01'],     // moins de 1% de requêtes peuvent échouer
    checks: ['rate>0.99'],              // plus de 99% des checks doivent passer
  },
};

export default function () {
  const res = http.get('https://your-api.example.com/api/products');

  check(res, {
    'statut est 200': (r) => r.status === 200,
    'temps de réponse < 2s': (r) => r.timings.duration < 2000,
  });

  sleep(1);
}

Quand k6 exécute ce script et que le p95 dépasse 2000 ms, il se termine avec le code 99. L'étape CI échoue. C'est le comportement attendu. Un build qui rend votre API 40 % plus lente doit échouer.

Vous pouvez définir des seuils par endpoint avec des tags :

export default function () {
  const loginRes = http.post(
    'https://your-api.example.com/auth/login',
    JSON.stringify({ username: 'user@example.com', password: 'pass' }),
    { headers: { 'Content-Type': 'application/json' }, tags: { name: 'login' } }
  );

  const productsRes = http.get(
    'https://your-api.example.com/api/products',
    { tags: { name: 'products' } }
  );
}

export const options = {
  thresholds: {
    'http_req_duration{name:login}': ['p(95)<1000'],
    'http_req_duration{name:products}': ['p(95)<500'],
  },
};

La connexion est naturellement plus lente qu'une liste de produits en cache, donc chaque endpoint a son propre SLA.

Les seuils sont évalués en continu pendant le test, pas seulement à la fin. Si votre p95 dépasse la limite tôt dans l'exécution puis récupère, le seuil reste marqué comme échoué. C'est intentionnel. Un système qui se dégrade puis récupère cache peut-être un problème de capacité à investiguer.

Lancer k6 dans GitHub Actions

k6 s'intègre proprement dans GitHub Actions. L'équipe Grafana publie une action officielle qui installe le binaire et gère la version :

# .github/workflows/load-test.yml
name: Tests de charge

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  workflow_dispatch:

jobs:
  load-test:
    runs-on: ubuntu-latest
    timeout-minutes: 30

    steps:
      - name: Checkout du code
        uses: actions/checkout@v4

      - name: Installer k6
        uses: grafana/setup-k6-action@v1
        with:
          k6-version: '0.55.0'

      - name: Lancer le test de charge
        run: k6 run load-test.js
        env:
          BASE_URL: ${{ vars.BASE_URL }}
          API_KEY: ${{ secrets.API_KEY }}

      - name: Enregistrer les résultats k6
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: k6-results
          path: k6-results.json
          retention-days: 14

Pour utiliser des variables d'environnement dans votre script k6, accédez-y via __ENV :

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

const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000';

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

export default function () {
  const res = http.get(`${BASE_URL}/api/products`);

  check(res, {
    'statut est 200': (r) => r.status === 200,
    'temps de réponse acceptable': (r) => r.timings.duration < 2000,
  });

  sleep(1);
}

Pour générer un fichier JSON des résultats pour l'artifact upload, passez le flag --out :

k6 run --out json=k6-results.json load-test.js

Une recommandation pratique : ne lancez pas les tests de charge sur chaque pull request contre votre environnement de production ou de staging partagé. Le job de test de charge doit tourner sur les merges vers main, ou être déclenché manuellement via workflow_dispatch. Des tests de charge lourds sur chaque branche PR créent des interférences entre les exécutions concurrentes. Un test smoke (5 VUs, 30 secondes) sur les PR, c'est acceptable ; un test de charge complet, non.

Quels endpoints tester

Choisir quoi tester avec k6 est aussi important que le test lui-même. Tous les endpoints ne nécessitent pas un test de charge. Concentrez-vous sur ceux où la performance compte.

L'endpoint de connexion est le premier test à écrire. Chaque session utilisateur commence là. Une connexion lente multipliée par 500 utilisateurs simultanés effondre l'expérience avant même d'atteindre les fonctionnalités. Testez le flux de connexion complet : POST des credentials, réception du token, une requête authentifiée.

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

const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000';

export const options = {
  stages: [
    { duration: '1m', target: 50 },
    { duration: '5m', target: 50 },
    { duration: '1m', target: 0 },
  ],
  thresholds: {
    http_req_duration: ['p(95)<1500'],
    http_req_failed: ['rate<0.01'],
  },
};

export default function () {
  // Étape 1 : Connexion
  const loginRes = http.post(
    `${BASE_URL}/auth/login`,
    JSON.stringify({ username: 'loadtest@example.com', password: 'loadtestpass' }),
    { headers: { 'Content-Type': 'application/json' } }
  );

  check(loginRes, {
    'connexion statut 200': (r) => r.status === 200,
    'token retourné': (r) => JSON.parse(r.body).token !== undefined,
  });

  const token = JSON.parse(loginRes.body).token;

  // Étape 2 : Requête authentifiée
  const profileRes = http.get(`${BASE_URL}/api/me`, {
    headers: { Authorization: `Bearer ${token}` },
  });

  check(profileRes, {
    'profil statut 200': (r) => r.status === 200,
  });

  sleep(2);
}

Les endpoints de lecture critiques sont la deuxième priorité. Liste de produits, résultats de recherche, données de tableau de bord : tout ce que les utilisateurs consultent fréquemment et qui interroge la base de données. Ce sont les endpoints les plus susceptibles de se dégrader à l'échelle, car ils agrègent des données sur plusieurs lignes ou tables. Le flux de checkout ou de soumission de commande est le troisième. C'est l'endpoint où une performance lente a un impact direct sur le chiffre d'affaires. Testez-le avec un nombre de VUs plus faible que vos endpoints de lecture, car la concurrence réelle sur le checkout est inférieure à la navigation. Appliquez en revanche un seuil de temps de réponse plus strict.

Un bon point de départ pour la plupart des applications : trois scripts de test de charge. Un pour l'authentification, un pour vos trois principaux endpoints de lecture dans un seul script, et un pour votre opération d'écriture critique. C'est suffisant pour détecter les régressions de performance qui comptent, sans construire une suite complète avant d'avoir une baseline.

FAQ

Ai-je besoin d'un environnement de test séparé pour lancer k6 ?

Il vous faut un environnement capable d'absorber la charge sans affecter les vrais utilisateurs ni corrompre les données de production. Un environnement dédié aux tests de charge est idéal. Si vous n'avez que du staging, lancez les tests hors des heures de pointe et assurez-vous que vos données de test sont isolées. Ne pointez jamais k6 vers la production avec un nombre élevé de VUs.

Combien de VUs utiliser ?

Commencez par un nombre représentant le pic de trafic réaliste, pas votre maximum théorique. Si votre application compte 50 utilisateurs simultanés un jour chargé, testez à 50-100. Savoir ce qui se passe à 1 000 VUs compte moins que de savoir que votre système gère confortablement la charge normale de pointe.

Mon test k6 passe mais l'application semble lente. Pourquoi ?

k6 mesure le temps de réponse HTTP, qui inclut le traitement serveur mais exclut le rendu côté client. Un endpoint API qui répond en 200 ms peut quand même sembler lent si le frontend fait 20 appels séquentiels pour afficher une page. k6 pour les performances au niveau API ; les outils de profiling navigateur pour les performances perçues.

k6 peut-il tester des endpoints WebSocket ou gRPC ?

Oui. k6 intègre le support WebSocket via le module k6/ws et gRPC via le module k6/net/grpc. Le modèle de scripting est le même ; seule l'API spécifique au protocole diffère.

Comment partager les résultats k6 avec mon équipe ?

Le fichier JSON généré par --out json peut être importé dans Grafana pour la visualisation. Si votre équipe utilise Grafana Cloud, k6 s'intègre nativement et envoie les résultats en temps réel pour comparaison entre les exécutions. Sans Grafana, le tableau récapitulatif terminal copié dans un commentaire de PR ou un message Slack suffit pour communiquer la réussite/échec et les percentiles clés.

Quelle est la différence entre un test de charge et un test de stress ?

Les tests de charge vérifient que votre système respecte les exigences de performance au niveau de trafic attendu. Les tests de stress poussent au-delà pour trouver le point de rupture. La configuration par stages gère les deux : un test de charge maintient un nombre cible de VUs, un test de stress continue de monter jusqu'à ce que les checks échouent.

→ See also: Fondamentaux des Tests de Performance: Load, Stress et Spike Testing | Tests d'API 101: Tout ce que Chaque Ingénieur QA Doit Savoir en 2026 | CI/CD pour QA: GitHub Actions, Jenkins et GitLab Comparés