Tests E2E dans NestJS : tester ses endpoints HTTP

Tests E2E dans NestJS : tester ses endpoints HTTP

Les tests unitaires sont indispensables pour valider la logique métier isolée, mais ils ne suffisent pas à garantir qu'une API fonctionne correctement dans son ensemble. Comment s'assurer que vos contrôleurs, vos guards d'authentification, vos pipes de validation et votre base de données collaborent harmonieusement ? La réponse tient en trois lettres : E2E (end-to-end). Dans ce tutoriel, nous allons voir comment tester les endpoints HTTP d'une application NestJS avec supertest et Test.createTestingModule(), en s'appuyant sur des exemples concrets issus d'un projet réel.

Prérequis et différence avec les tests unitaires

Avant de plonger dans le code, assurez-vous d'avoir :

  • Une application NestJS fonctionnelle (v9+)
  • Jest installé (livré par défaut avec NestJS)
  • Une base de données dédiée aux tests (PostgreSQL, MySQL ou SQLite en mémoire)

La distinction est fondamentale :

  • Tests unitaires : isolent une classe (service, contrôleur) et mockent ses dépendances. Rapides, ils valident la logique pure.
  • Tests E2E : démarrent l'application complète, envoient de vraies requêtes HTTP et vérifient les réponses ainsi que l'état de la base. Ils valident le flux complet : routing → guards → pipes → contrôleur → service → base de données.
En pratique, on cherche un équilibre : beaucoup d'unitaires rapides, et quelques E2E qui couvrent les parcours critiques (authentification, paiement, réservation, etc.).

Configuration : jest-e2e.json et structure des tests

NestJS fournit, lors de la création d'un projet via le CLI, un dossier test/ avec un fichier jest-e2e.json. Voici une configuration typique :

{
  "moduleFileExtensions": ["js", "json", "ts"],
  "rootDir": ".",
  "testEnvironment": "node",
  "testRegex": ".e2e-spec.ts$",
  "transform": {
    "^.+\\.(t|j)s$": "ts-jest"
  }
}

Concernant l'emplacement des tests, deux conventions cohabitent :

  • test/ à la racine du projet : convention par défaut du CLI NestJS.
  • src/test/functional/ : convention adoptée par certains projets pour regrouper les tests fonctionnels avec le code source. C'est cette structure que nous utiliserons ici, avec des fichiers nommés *.e2e-spec.ts.

Installez ensuite supertest, la librairie qui permet de simuler des requêtes HTTP :

npm install --save-dev supertest @types/supertest

Ajoutez un script dans votre package.json :

{
  "scripts": {
    "test:e2e": "jest --config ./test/jest-e2e.json"
  }
}

Bootstrap de l'application de test

Le cœur d'un test E2E NestJS repose sur Test.createTestingModule(), qui instancie un module de test, puis sur createNestApplication() qui crée une instance INestApplication. Voici le squelette utilisé dans tous nos fichiers de test :

import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../../app.module';
import { DataSource } from 'typeorm';
import { TestHelper } from '../helpers/test-helper';

describe('Contact API (e2e)', () => {
  let app: INestApplication;
  let dataSource: DataSource;
  let testHelper: TestHelper;
  let authToken: string;

  beforeAll(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = moduleFixture.createNestApplication();
    await app.init();

    dataSource = moduleFixture.get<DataSource>(DataSource);
    testHelper = new TestHelper(app, dataSource);
    authToken = await testHelper.createAdminToken();
  });

  afterAll(async () => {
    await app.close();
  });
});

Quelques points clés :

  • app.init() déclenche tout le cycle de vie : instanciation des providers, exécution des hooks OnModuleInit, etc.
  • app.close() dans afterAll est crucial pour libérer les connexions à la base et éviter les fuites entre suites de tests.
  • On récupère le DataSource TypeORM via moduleFixture.get() pour piloter la base depuis nos helpers.

Tester GET, POST, PATCH avec supertest

L'API de supertest est fluide : on appelle request(app.getHttpServer()), puis on chaîne la méthode HTTP, les en-têtes, le body et les assertions. Voici comment tester un POST simple sur un endpoint public :

it('should create a contact message successfully', async () => {
  const response = await request(app.getHttpServer())
    .post('/contact')
    .send({
      name: 'John Test',
      email: 'john@example.com',
      subject: 'Hello',
      message: 'Ceci est un message de test valide',
    })
    .expect(201);

  expect(response.body.success).toBe(true);
  expect(response.body.data).toHaveProperty('id');
  expect(response.body.data.name).toBe('John Test');
});

Notez le format de réponse { success, data } qui correspond au pattern ApiResponseDto standardisé du projet. On valide à la fois le code HTTP (.expect(201)) et le contenu du body.

Pour tester un endpoint protégé, on injecte un token JWT dans l'en-tête Authorization :

it('should require authentication for admin actions', async () => {
  await request(app.getHttpServer())
    .get('/contact/unread-count')
    .expect(401);
});

it('should allow admin to get unread count', async () => {
  const response = await request(app.getHttpServer())
    .get('/contact/unread-count')
    .set('Authorization', `Bearer ${authToken}`)
    .expect(200);

  expect(response.body.success).toBe(true);
  expect(response.body.data).toBeGreaterThanOrEqual(0);
});

Pour un PATCH, le principe est identique :

const updateResponse = await request(app.getHttpServer())
  .patch(`/bookings/${bookingId}/status`)
  .set('Authorization', `Bearer ${authToken}`)
  .send({ status: BookingStatus.CONFIRMED })
  .expect(200);

expect(updateResponse.body.data.status).toBe(BookingStatus.CONFIRMED);

Tester un parcours métier complet

L'intérêt majeur des tests E2E est de valider des scénarios métier. Prenons un flux de réservation : créer un service, créer un créneau, réserver, vérifier que la capacité est consommée, et qu'une seconde réservation est rejetée.

it('should create a booking and consume slot capacity', async () => {
  const dateStr = testHelper.getTomorrowDate();

  const payload = {
    serviceId,
    guestName: 'John Doe',
    guestEmail: 'john@example.com',
    bookingDate: dateStr,
    bookingTime: '10:00',
  };

  const response = await request(app.getHttpServer())
    .post('/bookings')
    .send(payload)
    .expect(201);

  expect(response.body.data.status).toBe(BookingStatus.PENDING);

  // Vérifie que le créneau est maintenant indisponible
  const slotsResponse = await request(app.getHttpServer())
    .get(`/bookings/slots?date=${dateStr}`)
    .expect(200);

  const slot = slotsResponse.body.data.find((s: any) => s.start === '10:00');
  expect(slot.available).toBe(false);
});

it('should reject booking when slot capacity is full', async () => {
  const dateStr = testHelper.getTomorrowDate();
  const payload = { serviceId, guestName: 'John', guestEmail: 'j@x.com',
                    bookingDate: dateStr, bookingTime: '10:00' };

  await request(app.getHttpServer()).post('/bookings').send(payload).expect(201);

  const response = await request(app.getHttpServer())
    .post('/bookings')
    .send({ ...payload, guestEmail: 'jane@example.com', guestName: 'Jane' })
    .expect(400);

  expect(response.body.message).toContain('not available');
});

Ce type de test détecte des bugs invisibles aux tests unitaires : verrous concurrentiels, validations croisées, bonnes erreurs renvoyées, etc.

Base de données de test et nettoyage

Un test E2E touche à la base réelle. Plusieurs stratégies existent :

  • Base dédiée : une base PostgreSQL/MySQL séparée (par exemple app_test), configurée via une variable d'environnement NODE_ENV=test.
  • SQLite en mémoire : très rapide, utile pour la CI, mais attention aux différences de SQL (types, fonctions natives).
  • Conteneur Docker éphémère : la solution la plus fidèle à la production.

Quel que soit le choix, il faut nettoyer les données entre les tests pour garantir leur isolation. C'est le rôle d'un helper centralisé, instancié dans beforeAll et appelé dans beforeEach :

beforeEach(async () => {
  await testHelper.resetDatabase();

  const service = await testHelper.createService(ServiceType.APPOINTMENT);
  serviceId = service.id;

  const dayOfWeek = testHelper.getTomorrowDayOfWeek();
  await testHelper.createTimeSlot(dayOfWeek, '10:00', '10:45', 1);
});

La méthode resetDatabase() tronque les tables (en respectant les contraintes de clés étrangères) et createAdminToken() crée un utilisateur admin puis génère un JWT valide via le service d'authentification réel. Centraliser ces opérations dans un TestHelper évite la duplication et garantit la cohérence entre les suites.

Astuce : exécutez vos tests E2E avec --runInBand pour éviter les conflits de base entre suites parallèles, ou utilisez une base par worker Jest.

Bonnes pratiques et pièges courants

  • Ne mockez pas la base dans un E2E : sinon ce n'est plus un test E2E.
  • Isolez chaque test avec un reset systématique. Un test qui dépend de l'ordre d'exécution finit toujours par devenir flaky.
  • Testez les cas d'erreur autant que les cas nominaux : 401, 400 avec validation, 404, conflits métier (capacité pleine, créneau invalide…).
  • Fermez l'application dans afterAll pour éviter les handles ouverts qui empêchent Jest de quitter.
  • Mesurez le temps : un test E2E qui dure plus de quelques secondes mérite une optimisation (transaction rollback, fixtures minimales).

Conclusion

Les tests E2E dans NestJS combinent Test.createTestingModule(), INestApplication et supertest pour simuler des requêtes HTTP réelles contre l'application complète. Ils valident le routing, les guards, les pipes, la validation, la persistance — bref, tout ce que vos utilisateurs vivront. En adoptant un TestHelper pour le reset de base et la génération de tokens, vous garderez vos tests lisibles, déterministes et rapides à écrire.

Pour aller plus loin, explorez :

  • L'intégration des tests E2E dans une CI (GitHub Actions, GitLab CI) avec une base éphémère.
  • Les transactions auto-rollback pour accélérer le reset entre tests.
  • La couverture de code combinée unitaires + E2E avec jest --coverage.
  • La documentation officielle NestJS sur les tests.

Avec ces outils en main, vous pouvez livrer en confiance et détecter les régressions avant qu'elles n'atteignent la production.

Continuer la lecture

Article suivant — NestJS Tests unitaires avec Jest dans NestJS Explorer tout : NestJS

Commentaires

Soyez le premier à laisser un commentaire — le robot attend.

Laisser un commentaire

Les champs obligatoires sont indiqués avec *