Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

Chapitre 1223 minIntermédiaire

Tester une API Node.js avec node:test et Supertest

Par Josselyn C., prof de BTS SIO. Mis à jour le .

À la fin de ce chapitre, tu sauras

  • écrire un test unitaire avec node:test et assert ;
  • tester une route Express de bout en bout avec Supertest ;
  • préparer une base de test qui repart du même état à chaque test ;
  • prouver par des tests que les contrôles d'accès fonctionnent.

4 exercices corrigés

Tester une API, c'est écrire du code qui l'appelle et vérifie automatiquement ses réponses : le bon code de statut, le bon contenu, les bons refus. Node.js fournit son propre lanceur de tests, node:test, avec le module d'assertions node:assert ; Supertest envoie des requêtes HTTP à une application Express sans démarrer de serveur. Ce chapitre écrit des tests unitaires pour les règles de gestion d'AppliFrais, puis des tests d'intégration sur les routes, avec une base de test, et surtout des tests qui prouvent que les contrôles d'accès fonctionnent.

Quels tests pour une API

Les types de tests, la pyramide des tests et les bonnes pratiques générales sont présentés dans le cours Tests (les types de tests, tests d'intégration d'une API). Pour une API, deux niveaux font l'essentiel du travail :

TypeCe qu'il testeDans AppliFraisVitesse
UnitaireUne fonction seule, sans base ni réseaucalculerTotal, verifierTransitionQuelques millisecondes
IntégrationUne route de bout en bout : middlewares, service, dépôt, vraie basePOST /v1/fiches répond 201 et crée les lignesQuelques dizaines de millisecondes
De bout en boutL'application complète, front compris, dans un navigateurUn visiteur crée et clôture sa fiche dans ReactPlusieurs secondes

Les tests d'intégration sont les plus rentables pour une API : un seul test traverse toutes les couches et vérifie ce que le client verra vraiment.

node:test et assert

node:test est livré avec Node.js : rien à installer.

js
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';

describe('un groupe de tests', () => {
  it('vérifie quelque chose', () => {
    assert.equal(1 + 1, 2);
  });
});
AssertionVérifie
assert.equal(reel, attendu)Égalité stricte (===) avec node:assert/strict
assert.deepEqual(reel, attendu)Égalité de contenu de deux objets ou tableaux
assert.ok(valeur)La valeur est vraie
assert.match(texte, /regex/)Le texte correspond à l'expression régulière
assert.throws(() => …, attendu)La fonction lève une erreur qui correspond à attendu
await assert.rejects(promesse, attendu)La promesse est rejetée

describe regroupe des tests ; it (ou test) en déclare un ; before, after, beforeEach, afterEach exécutent du code de préparation ou de nettoyage. Un test échoue dès qu'une assertion échoue ou qu'une erreur est levée.

bash
node --test                              # cherche les fichiers de test (dont **/*.test.js)
node --test "test/**/*.test.js"          # seulement ces fichiers (motif entre guillemets)
node --test --watch                      # relance à chaque modification

Pour une API, préfère un motif explicite : sinon, node --test exécute aussi comme test tout fichier placé dans un dossier test/, y compris tes fichiers utilitaires.

Tester une règle de gestion

Les règles de gestion sont des fonctions pures du service : elles se testent directement.

js
// test/fiches.service.test.js
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { calculerTotal, verifierTransition } from '../src/services/fiches.service.js';

describe('calculerTotal', () => {
  it('additionne forfait et hors forfait sans erreur d’arrondi', () => {
    const lignes = [
      { quantite: 3, montantUnitaire: 110 },
      { quantite: 420, montantUnitaire: 0.62 },
      { quantite: 2, montantUnitaire: 80 },
      { quantite: 5, montantUnitaire: 25 },
    ];
    assert.equal(calculerTotal(lignes, [{ montant: 23.5 }]), 898.9);
  });

  it('vaut 0 pour une fiche vide', () => {
    assert.equal(calculerTotal([], []), 0);
  });
});

describe('verifierTransition', () => {
  it('accepte CR vers CL', () => {
    assert.doesNotThrow(() => verifierTransition('CR', 'CL'));
  });

  it('refuse CR vers VA avec une erreur 409', () => {
    assert.throws(() => verifierTransition('CR', 'VA'), { status: 409, type: 'transition-interdite' });
  });
});

assert.throws reçoit ici un objet : le test passe si l'erreur levée possède ces propriétés. On vérifie ainsi le code de l'erreur, pas seulement qu'une erreur a eu lieu.

Supertest : appeler une route sans ouvrir de port

bash
npm install -D supertest

Supertest prend l'application Express (exportée par src/app.js, sans listen), démarre un serveur temporaire sur un port libre, envoie la requête et rend la réponse. C'est pour ça que l'application et son démarrage sont séparés depuis le chapitre 3.

js
import request from 'supertest';
import { app } from '../src/app.js';

const reponse = await request(app)
  .post('/v1/fiches')
  .set('Authorization', jeton)
  .send({ mois: '2026-11' });   // send() d'un objet : corps JSON et Content-Type ajoutés

reponse.status;                  // 201
reponse.headers.location;        // '/v1/fiches/14'
reponse.body;                    // le corps, déjà converti depuis JSON

Une base de test

Les tests d'intégration ont besoin d'une vraie base MySQL, mais jamais de la base de développement ni, évidemment, de celle de production : les tests la vident et la remplissent. On crée une base dédiée, applifrais_test, avec le même schema.sql, et un fichier .env.test :

bash
# .env.test
NODE_ENV=test
DB_HOST=localhost
DB_USER=applifrais_api
DB_PASSWORD=change-moi
DB_NAME=applifrais_test
JWT_SECRET=un-secret-de-test-long-de-plus-de-32-caracteres
LOG_LEVEL=silent
bash
mysql -u root -p -e "CREATE DATABASE applifrais_test CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci"
mysql -u root -p applifrais_test < sql/schema.sql

Chaque test doit partir d'un état connu, sinon il dépend de ceux qui ont tourné avant lui. Avant chaque test, on rejoue le jeu de données :

js
// test/outils.js
import { readFile } from 'node:fs/promises';
import request from 'supertest';
import { app } from '../src/app.js';
import { pool } from '../src/db.js';

// Remet la base de test dans l'état du jeu de données, avant chaque test.
export async function reinitialiserBase() {
  const script = await readFile(new URL('../sql/donnees.sql', import.meta.url), 'utf8');
  const instructions = script
    .split(';')
    .map((bloc) => bloc.replace(/^\s*--.*$/gm, '').trim())
    .filter((instruction) => instruction !== '');
  for (const instruction of instructions) await pool.query(instruction);
}

// Se connecte et renvoie l'en-tête Authorization à utiliser.
export async function jetonDe(login, motDePasse) {
  const reponse = await request(app).post('/v1/auth/connexion').send({ login, motDePasse });
  return `Bearer ${reponse.body.accessToken}`;
}

Le script est découpé sur les ; et chaque instruction est exécutée à part : mysql2 refuse plusieurs requêtes dans un seul appel (et c'est très bien ainsi, chapitre 6). Les lignes de commentaires sont retirées avant.

Tester les routes, et surtout les refus

js
// test/fiches.api.test.js
import assert from 'node:assert/strict';
import { after, beforeEach, describe, it } from 'node:test';
import request from 'supertest';
import { app } from '../src/app.js';
import { pool } from '../src/db.js';
import { jetonDe, reinitialiserBase } from './outils.js';

beforeEach(reinitialiserBase);
after(() => pool.end());

describe('GET /v1/fiches/:id', () => {
  it('renvoie 401 sans jeton', async () => {
    const reponse = await request(app).get('/v1/fiches/12');
    assert.equal(reponse.status, 401);
    assert.match(reponse.headers['content-type'], /application\/problem\+json/);
  });

  it('renvoie la fiche et son total à son propriétaire', async () => {
    const reponse = await request(app)
      .get('/v1/fiches/12')
      .set('Authorization', await jetonDe('lmartin', 'Visiteur#2026'));
    assert.equal(reponse.status, 200);
    assert.equal(reponse.body.mois, '2026-10');
    assert.equal(reponse.body.total, 898.9);
  });

  it("renvoie 404 pour la fiche d'un autre visiteur", async () => {
    const reponse = await request(app)
      .get('/v1/fiches/13')
      .set('Authorization', await jetonDe('lmartin', 'Visiteur#2026'));
    assert.equal(reponse.status, 404);
  });
});

describe('POST /v1/fiches', () => {
  it('crée la fiche du mois avec 201 et Location', async () => {
    const reponse = await request(app)
      .post('/v1/fiches')
      .set('Authorization', await jetonDe('lmartin', 'Visiteur#2026'))
      .send({ mois: '2026-11' });
    assert.equal(reponse.status, 201);
    assert.equal(reponse.headers.location, `/v1/fiches/${reponse.body.id}`);
    assert.equal(reponse.body.lignesForfait.length, 4);
  });

  it('refuse une deuxième fiche pour le même mois avec 409', async () => {
    const reponse = await request(app)
      .post('/v1/fiches')
      .set('Authorization', await jetonDe('lmartin', 'Visiteur#2026'))
      .send({ mois: '2026-10' });
    assert.equal(reponse.status, 409);
  });

  it('refuse un mois mal formé avec 422', async () => {
    const reponse = await request(app)
      .post('/v1/fiches')
      .set('Authorization', await jetonDe('lmartin', 'Visiteur#2026'))
      .send({ mois: '2026-13' });
    assert.equal(reponse.status, 422);
    assert.equal(reponse.body.erreurs[0].champ, 'mois');
  });
});

describe('POST /v1/fiches/:id/validations', () => {
  it('est interdite à un visiteur (403)', async () => {
    const reponse = await request(app)
      .post('/v1/fiches/12/validations')
      .set('Authorization', await jetonDe('lmartin', 'Visiteur#2026'))
      .send({ montantValide: 898.9 });
    assert.equal(reponse.status, 403);
  });

  it('valide une fiche clôturée pour un comptable', async () => {
    const reponse = await request(app)
      .post('/v1/fiches/13/validations')
      .set('Authorization', await jetonDe('adurand', 'Comptable#2026'))
      .send({ montantValide: 567 });
    assert.equal(reponse.status, 201);
    assert.equal(reponse.body.etat, 'VA');
  });
});
bash
npm test
text
▶ GET /v1/fiches/:id
  ✔ renvoie 401 sans jeton (18.7ms)
  ✔ renvoie la fiche et son total à son propriétaire (47.4ms)
  ✔ renvoie 404 pour la fiche d'un autre visiteur (37.2ms)
✔ GET /v1/fiches/:id (103.8ms)
▶ POST /v1/fiches
  ✔ crée la fiche du mois avec 201 et Location (37.8ms)
  ✔ refuse une deuxième fiche pour le même mois avec 409 (37.2ms)
  ✔ refuse un mois mal formé avec 422 (37.0ms)
✔ POST /v1/fiches (112.2ms)
…
ℹ tests 12
ℹ pass 12
ℹ fail 0

La moitié des tests vérifient des refus. C'est voulu : un test qui prouve qu'un visiteur ne lit pas la fiche d'un collègue (404) ou ne valide pas sa propre fiche (403) protège contre la faille la plus fréquente des API. Si un jour quelqu'un retire chargerFiche ou exigerRole, le test échoue avant la mise en production.

La couverture de code

La couverture indique quelles lignes du code ont été exécutées par les tests :

bash
node --env-file=.env.test --test --test-concurrency=1 --experimental-test-coverage "test/**/*.test.js"
text
ℹ file                    | line % | branch % | funcs % | uncovered lines
ℹ   fiches.service.js     |  61.05 |    94.12 |   60.00 | 31-36 39-42 56-59 62-64 67-79 85-86 91-95
ℹ   frais.service.js      |  33.33 |   100.00 |    0.00 | 5-14 17-19 22-24
ℹ all files               |  75.21 |    77.78 |   49.21 |

La colonne « uncovered lines » montre ce que aucun test n'a exécuté : ici, tout le service des frais hors forfait. Une couverture élevée ne prouve pas que les tests sont bons (un test sans assertion « couvre » des lignes), mais une ligne jamais exécutée n'a jamais été testée. L'option est encore marquée expérimentale dans Node.js 24, mais elle est utilisable.

Vitest, l'alternative

Vitest est un lanceur de tests très répandu dans l'écosystème JavaScript, notamment côté front avec Vite et React. Les tests s'écrivent presque de la même façon, avec Supertest pour l'API :

js
import request from 'supertest';
import { describe, expect, it } from 'vitest';
import { app } from '../src/app.js';

describe('GET /sante', () => {
  it('répond ok', async () => {
    const reponse = await request(app).get('/sante');
    expect(reponse.status).toBe(200);
    expect(reponse.body).toEqual({ statut: 'ok' });
  });
});

npx vitest run lance les tests une fois, npx vitest les relance à chaque modification. Vitest apporte un affichage plus riche, la simulation de modules et un rapport de couverture intégré ; node:test a l'avantage de ne rien installer. Si tu utilises déjà Vitest dans le front React, le même outil pour l'API simplifie la vie.

Les tests dans la CI

Un test n'est utile que s'il tourne à chaque modification. Dans GitHub Actions, un conteneur MySQL démarre à côté du job :

yaml
# .github/workflows/tests.yml
name: Tests de l'API
on: [push, pull_request]

jobs:
  tests:
    runs-on: ubuntu-latest
    services:
      mysql:
        image: mysql:8.4
        env:
          MYSQL_ROOT_PASSWORD: root
          MYSQL_DATABASE: applifrais_test
        ports:
          - 3306:3306
        options: >-
          --health-cmd="mysqladmin ping -h 127.0.0.1 -proot"
          --health-interval=5s
          --health-timeout=3s
          --health-retries=10
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - name: Créer les tables
        run: mysql -h 127.0.0.1 -uroot -proot applifrais_test < sql/schema.sql
      - name: Préparer la configuration de test
        run: |
          {
            echo "NODE_ENV=test"
            echo "DB_HOST=127.0.0.1"
            echo "DB_USER=root"
            echo "DB_PASSWORD=root"
            echo "DB_NAME=applifrais_test"
            echo "JWT_SECRET=secret-de-ci-uniquement-pour-les-tests-0123456789"
            echo "LOG_LEVEL=silent"
          } > .env.test
      - run: npm test

Le fichier .env.test n'est pas dans le dépôt : le job le crée avec des valeurs jetables, propres à la CI. La lecture de ce workflow ligne par ligne, la protection de branche qui interdit de fusionner si les tests échouent et le reste du pipeline sont dans le cours Intégration continue.

Exercices

Exercice 1 sur 4 Écrire un test unitaire Réussi À revoir

Écris un test qui vérifie que calculerTotal calcule correctement une fiche de 57 km et deux frais hors forfait de 3,10 € et 12,20 €, sans autre frais. Quel est le résultat attendu ? Pourquoi ce test est-il intéressant ?

Voir la réponseMasquer la réponse de l'exercice 1
js
it('ne cumule pas les erreurs de virgule flottante', () => {
  const lignes = [{ quantite: 57, montantUnitaire: 0.62 }];
  const horsForfait = [{ montant: 3.1 }, { montant: 12.2 }];
  assert.equal(calculerTotal(lignes, horsForfait), 50.64);
});

57 × 0,62 = 35,34 ; 35,34 + 3,10 + 12,20 = 50,64 €. Le test est intéressant parce qu'en virgule flottante 57 * 0.62 vaut 35.339999999999996 : une implémentation naïve, sans passer par les centimes, renverrait un total faux, et ce test le détecterait.

Tu avais trouvé ?

Exercice 2 sur 4 Prouver un contrôle d'accès Réussi À revoir

Écris le test d'intégration qui prouve que Lucas (lmartin) ne peut pas supprimer le frais hors forfait 59, qui appartient à Sarah, et que ce frais existe toujours après la tentative.

Voir la réponseMasquer la réponse de l'exercice 2
js
describe('DELETE /v1/frais-hors-forfait/:id', () => {
  it("refuse de supprimer le frais d'un autre visiteur", async () => {
    const jetonLucas = await jetonDe('lmartin', 'Visiteur#2026');
    const reponse = await request(app)
      .delete('/v1/frais-hors-forfait/59')
      .set('Authorization', jetonLucas);
    assert.equal(reponse.status, 404);

    const jetonSarah = await jetonDe('svincent', 'Visiteur#2026');
    const fiche = await request(app).get('/v1/fiches/13').set('Authorization', jetonSarah);
    assert.ok(fiche.body.fraisHorsForfait.some((f) => f.id === 59));
  });
});

La seconde partie est essentielle : un 404 ne suffit pas à prouver que rien n'a été supprimé. On vérifie l'état après la tentative, avec le compte de la propriétaire.

Tu avais trouvé ?

Exercice 3 sur 4 Des tests qui échouent au hasard Réussi À revoir

Les tests passent quand on lance les fichiers un par un, mais échouent au hasard avec npm test. Le script test est node --env-file=.env.test --test "test/**/*.test.js". Les deux fichiers de test appellent reinitialiserBase avant chaque test. Explique et corrige.

Voir la réponseMasquer la réponse de l'exercice 3

node --test exécute les fichiers en parallèle, chacun dans son processus. Pendant qu'un fichier lit la fiche 12, l'autre vide et recharge la base : les résultats dépendent de l'ordre d'exécution, d'où des échecs aléatoires. Correction : --test-concurrency=1 dans le script, pour exécuter les fichiers l'un après l'autre. (Une autre solution, plus avancée, donne une base différente à chaque fichier.)

Tu avais trouvé ?

Exercice 4 sur 4 Lire un échec Réussi À revoir

text
✖ crée la fiche du mois avec 201 et Location (31.2ms)
  AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:

  422 !== 201

Le test envoie request(app).post('/v1/fiches').set('Authorization', jeton).send('{"mois":"2026-11"}'). Quelle est la cause probable ?

Voir la réponseMasquer la réponse de l'exercice 4

Le corps est passé comme une chaîne, pas comme un objet. Supertest l'envoie alors avec un Content-Type qui n'est pas application/json (un formulaire), express.json() ne le lit pas, req.body reste undefined et la validation répond 422 (« mois » manquant). Corrections possibles : .send({ mois: '2026-11' }), ou ajouter .set('Content-Type', 'application/json'). Le message 422 !== 201 dit ce qui est faux ; console.log(reponse.body) dans le test dit pourquoi.

Tu avais trouvé ?

Chapitre terminé !

Fais les 4 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : Documenter une API Express avec Swagger (OpenAPI)Revoir le sommaire du cours