Retour au cours

backend / nodejs

Tests avec Vitest et Supertest

Leçon 131 exercice

Explication

Ce que vous allez apprendre

  • Écrire des tests unitaires purs avec Vitest (describe/it/expect)
  • Tester une API HTTP de bout en bout avec Supertest, sans démarrer de vrai serveur
  • Isoler une dépendance externe imprévisible (réseau, API tierce) avec le mocking
  • Garantir l'isolation des tests de base de données avec la technique de la transaction annulée
  • Reconnaître un test "flaky" ou fragile, et comprendre pourquoi tester le comportement plutôt que l'implémentation

Dans quel contexte ?

Une équipe backend a été échaudée par une régression en production : un changement apparemment anodin dans le calcul d'un prix a cassé silencieusement trois autres fonctionnalités qui en dépendaient, découvert seulement une semaine plus tard par un client. Depuis, chaque nouvelle route API doit être couverte par un test d'intégration avant d'être mergée. Cette leçon construit exactement cette discipline, du test unitaire le plus simple jusqu'à l'isolation des tests de base de données.

Au-delà de "ça marche chez moi"

Sans tests automatisés, la seule façon de vérifier qu'une fonctionnalité marche encore après une modification est de tout retester manuellement. Ça devient vite impossible à mesure que l'application grandit.

Les tests automatisés donnent une garantie rapide et répétable qu'un changement n'a rien cassé ailleurs. Il existe trois niveaux de tests, chacun avec un objectif différent.

NiveauCe qu'il vérifieVitesse
Unitaireune fonction isolée, sans dépendancetrès rapide
Intégration (Supertest)une route HTTP complète, avec l'apprapide
Bout en bout (E2E)l'application déployée, via navigateur/HTTP réellent

Commençons par le plus simple : les tests unitaires. Ils vérifient une fonction isolée, sans dépendance externe — rapides, précis, faciles à écrire, comme le montre l'exemple avec add/divide.

Un cran au-dessus, les tests d'intégration testent l'application dans son ensemble. Avec Supertest, on simule des requêtes HTTP directement contre l'objet app, SANS avoir besoin d'un vrai serveur écoutant un port réseau.

Il reste un troisième outil essentiel : le mocking. Il isole le code testé de ses dépendances externes imprévisibles, comme une API tierce ou le réseau.

vi.mock remplace une fonction réelle par une version contrôlée. Ça permet de tester à la fois le cas de succès ET le cas d'échec sans dépendre d'un vrai service, qui pourrait être lent ou indisponible.

Une fois ces trois niveaux compris, un piège spécifique aux tests de base de données mérite d'être connu. Si chaque test insère des données réelles sans les nettoyer, les tests deviennent dépendants de l'ordre d'exécution et des résultats précédents.

C'est une source classique de tests "flaky", qui échouent parfois sans raison apparente. La technique de la transaction annulée (BEGIN avant chaque test, ROLLBACK après) règle ce problème complètement.

Bonne pratique

Testez le comportement observable (ce que l'appelant reçoit) plutôt que les détails d'implémentation (quelle fonction interne a été appelée). Un test qui vérifie response.status === 201 survit à un refactoring interne ; un test qui vérifie qu'une fonction privée précise a été appelée casse à chaque réorganisation du code, même sans bug.

Chaque test part alors d'un état propre, sans avoir à recréer les données à chaque fois. Une isolation totale, obtenue sans effort supplémentaire.

Pour finir, un point sur la couverture de code : c'est un indicateur, pas un objectif absolu. Viser 100% de couverture partout est souvent contre-productif, ça pousse à tester du code trivial juste pour la métrique.

Le piège fréquent à connaître avant de pratiquer : écrire des tests qui vérifient l'implémentation plutôt que le comportement observable rend les tests fragiles — ils cassent à chaque refactoring, même quand le comportement réel n'a pas changé.

Commandes & code

Tests avec Vitest et Supertest

bash
npm install -D vitest supertest
js
// math.test.js — tests unitaires purs
import { describe, it, expect } from "vitest";
import { add, divide } from "./math.js";

describe("add", () => {
  it("additionne deux nombres positifs", () => {
    expect(add(2, 3)).toBe(5);
  });

  it("gère les nombres négatifs", () => {
    expect(add(-2, 5)).toBe(3);
  });
});

describe("divide", () => {
  it("lève une erreur en cas de division par zéro", () => {
    expect(() => divide(10, 0)).toThrow("Division par zéro");
  });
});
js
// app.test.js — test d'intégration HTTP avec Supertest (sans vrai serveur écoutant un port)
import { describe, it, expect, beforeEach } from "vitest";
import request from "supertest";
import { createApp } from "../src/app.js";

describe("API /users", () => {
  const app = createApp({ database: createTestDatabase() });

  it("GET /users retourne 200 et un tableau", async () => {
    const response = await request(app).get("/users");

    expect(response.status).toBe(200);
    expect(Array.isArray(response.body)).toBe(true);
  });

  it("POST /users avec payload invalide retourne 400", async () => {
    const response = await request(app).post("/users").send({ name: "" });

    expect(response.status).toBe(400);
    expect(response.body.error).toBeDefined();
  });

  it("POST /users crée un utilisateur et retourne 201", async () => {
    const response = await request(app)
      .post("/users")
      .send({ name: "Alice", email: "alice@example.com" });

    expect(response.status).toBe(201);
    expect(response.body).toMatchObject({ name: "Alice" });
    expect(response.body.id).toBeDefined();
  });
});

function createTestDatabase() {
  return { users: [] };
}
js
// Mocking d'un service externe — isoler le code testé du réseau réel
import { describe, it, expect, vi, beforeEach } from "vitest";
import { getWeather } from "./weather-service.js";

vi.mock("./http-client.js", () => ({
  httpGet: vi.fn(),
}));

import { httpGet } from "./http-client.js";

describe("getWeather", () => {
  beforeEach(() => vi.clearAllMocks());

  it("retourne les données formatées quand l'API répond", async () => {
    httpGet.mockResolvedValue({ temp_c: 18, condition: "Ensoleillé" });

    const result = await getWeather("Paris");

    expect(result).toEqual({ city: "Paris", temperature: 18, condition: "Ensoleillé" });
    expect(httpGet).toHaveBeenCalledWith(expect.stringContaining("Paris"));
  });

  it("propage une erreur claire si l'API échoue", async () => {
    httpGet.mockRejectedValue(new Error("Timeout"));

    await expect(getWeather("Paris")).rejects.toThrow("Impossible de récupérer la météo");
  });
});
js
// Base de données de test — isolation via transaction annulée après chaque test
import { beforeEach, afterEach } from "vitest";
import { pool } from "../src/db.js";

let client;

beforeEach(async () => {
  client = await pool.connect();
  await client.query("BEGIN"); // toute mutation du test reste dans cette transaction
});

afterEach(async () => {
  await client.query("ROLLBACK"); // annule TOUTES les écritures du test précédent
  client.release();
});
json
// vitest.config.js — couverture de code
// export default { test: { coverage: { provider: "v8", thresholds: { lines: 80 } } } }

Résumé

  • Supertest teste l'app Express directement (sans port réseau réel), rapide et fiable en CI.
  • vi.mock isole le code testé des dépendances externes (API tierces, services réseau).
  • Envelopper chaque test DB dans une transaction annulée (ROLLBACK) garantit l'isolation sans recréer les données.
  • Viser une couverture élevée sur la logique métier critique, pas 100% partout à tout prix.

Exercices pratiques

1 disponible
1

Mission : une suite de tests qui échoue au hasard sur la CI

Objectif : Diagnostiquer une suite de tests flaky causée par un manque d'isolation de la base de données, puis remplacer un test fragile axé implémentation par un test de comportement.

Contexte

La CI de l'équipe échoue environ une fois sur cinq, toujours sur des tests différents, jamais les mêmes. En creusant, tu remarques que les tests d'intégration insèrent des utilisateurs directement via pool.query('INSERT INTO users...') sans jamais les supprimer après le test, et que les tests s'exécutent en parallèle par défaut. Un autre test, expect(createUserSpy).toHaveBeenCalledWith('validateEmailFormat'), casse systématiquement dès qu'un développeur renomme une fonction interne, même quand le comportement observable de l'API n'a strictement pas changé.

Résoudre l’exercice →