Organiser ses tests et créer des helpers dans NestJS

Organiser ses tests et créer des helpers dans NestJS

Écrire des tests, c'est bien. Les écrire de manière maintenable, lisible et réutilisable, c'est mieux. Dans un projet NestJS de taille moyenne, on se retrouve rapidement avec des dizaines de fichiers de tests E2E qui dupliquent la même logique : créer un utilisateur admin, générer un token JWT, nettoyer la base de données, créer des entités de test… Sans organisation rigoureuse, vos tests deviennent une charge plutôt qu'un filet de sécurité.

Dans ce tutoriel, nous allons voir comment structurer les tests d'une application NestJS, créer un TestHelper centralisé, gérer la configuration du TestingModule, et adopter les bons hooks (beforeAll, beforeEach, etc.) pour des tests rapides et fiables.

Prérequis

  • Une application NestJS configurée avec TypeORM (PostgreSQL)
  • Jest installé (fourni par défaut avec NestJS)
  • supertest pour les tests HTTP
  • Une base de données dédiée aux tests (ex. app_test)

Structurer ses tests : unitaires vs E2E

NestJS recommande deux types de tests, et il est important de bien les séparer :

  • Tests unitaires : ils vivent à côté du code qu'ils testent, dans le même dossier. Par exemple, users.service.ts est testé par users.service.spec.ts. Ils sont rapides, isolés et utilisent largement le mocking.
  • Tests end-to-end (E2E) : ils sont regroupés dans un dossier test/ (ou src/test/) à la racine. Ils démarrent une vraie application NestJS, frappent les endpoints HTTP et utilisent une vraie base de données.

Voici une structure recommandée :

src/
├── users/
│   ├── users.service.ts
│   ├── users.service.spec.ts      # Test unitaire
│   ├── users.controller.ts
│   └── users.controller.spec.ts
├── bookings/
│   └── ...
└── test/
    ├── helpers/
    │   └── test-helper.ts          # Utilitaires partagés
    ├── fixtures/
    │   └── users.fixture.ts        # Données prédéfinies
    └── functional/
        ├── integration-flow.e2e-spec.ts
        └── error-handling.e2e-spec.ts

Cette séparation permet de lancer rapidement les tests unitaires (npm run test) en CI, et les tests E2E plus lents (npm run test:e2e) sur un job dédié.

Créer un TestHelper réutilisable

Le cœur d'une bonne organisation E2E, c'est un helper centralisé. Au lieu de répéter la création d'un admin, le reset de la base ou la génération d'un token dans chaque fichier de test, on encapsule tout ça dans une classe.

Voici un exemple complet inspiré d'un projet réel. Le helper reçoit l'INestApplication et la DataSource TypeORM via son constructeur :

// src/test/helpers/test-helper.ts
import { INestApplication } from '@nestjs/common';
import { DataSource } from 'typeorm';
import * as request from 'supertest';
import { GuidanceService, ServiceType } from '../../database/entities/guidance-service.entity';
import { TimeSlot } from '../../database/entities/time-slot.entity';
import { User, UserRole } from '../../database/entities/user.entity';
import * as bcrypt from 'bcrypt';

export class TestHelper {
  constructor(
    private readonly app: INestApplication,
    private readonly dataSource: DataSource,
  ) {}

  async resetDatabase(): Promise<void> {
    const entities = this.dataSource.entityMetadatas;
    for (const entity of entities) {
      try {
        await this.dataSource.query(
          `TRUNCATE TABLE "${entity.tableName}" CASCADE`,
        );
      } catch (error) {
        console.warn(`Could not truncate ${entity.tableName}:`, error.message);
      }
    }
  }
}

La méthode resetDatabase() tronque toutes les tables avec CASCADE, ce qui garantit un état propre entre chaque test, sans avoir à supprimer et recréer le schéma.

Créer un utilisateur authentifié et générer un token JWT

L'un des besoins les plus fréquents est de tester des endpoints protégés. Plutôt que de signer manuellement un JWT (ce qui couplerait le test à la configuration interne), on passe par le vrai endpoint /auth/login. Cela teste indirectement la chaîne d'authentification.

async createAdminToken(): Promise<string> {
  const userRepo = this.dataSource.getRepository(User);

  // Crée l'admin si nécessaire (idempotent)
  let admin = await userRepo.findOne({ where: { email: 'admin@test.com' } });

  if (!admin) {
    const hashedPassword = await bcrypt.hash('password123', 10);
    admin = userRepo.create({
      email: 'admin@test.com',
      password: hashedPassword,
      firstName: 'Admin',
      lastName: 'Test',
      role: UserRole.ADMIN,
      isActive: true,
      emailVerified: true,
    });
    await userRepo.save(admin);
  }

  // Login pour récupérer un vrai token JWT
  const response = await request(this.app.getHttpServer())
    .post('/auth/login')
    .send({ email: 'admin@test.com', password: 'password123' });

  if (response.status !== 200) {
    throw new Error('Failed to create admin token');
  }

  return response.body.data.token;
}

Cette méthode est idempotente : elle vérifie si l'admin existe avant de le créer. Pratique si vous ne réinitialisez pas la base entre certains tests.

Le pattern Factory pour générer des entités

Plutôt que d'écrire à la main les objets dans chaque test, on définit des factories : des méthodes qui produisent des entités avec des valeurs par défaut sensées, modifiables au besoin.

async createService(
  type: ServiceType = ServiceType.APPOINTMENT,
): Promise<GuidanceService> {
  const serviceRepo = this.dataSource.getRepository(GuidanceService);
  const service = serviceRepo.create({
    title: 'Test Service',
    subtitle: 'Test Subtitle',
    description: 'Test Description',
    duration: '30 min',
    price: 10000, // 100.00 EUR en centimes
    type,
    isActive: true,
  });
  return serviceRepo.save(service);
}

async createTimeSlot(
  dayOfWeek: number,
  startTime: string,
  endTime: string,
  capacity: number = 1,
): Promise<TimeSlot> {
  const timeSlotRepo = this.dataSource.getRepository(TimeSlot);
  const timeSlot = timeSlotRepo.create({
    dayOfWeek,
    startTime,
    endTime,
    capacity,
    isActive: true,
  });
  return timeSlotRepo.save(timeSlot);
}

On peut compléter ce helper avec des utilitaires de date — souvent nécessaires quand on teste des réservations futures :

getTomorrowDate(): string {
  const tomorrow = new Date();
  tomorrow.setDate(tomorrow.getDate() + 1);
  return tomorrow.toISOString().split('T')[0];
}

getTomorrowDayOfWeek(): number {
  const tomorrow = new Date();
  tomorrow.setDate(tomorrow.getDate() + 1);
  const day = tomorrow.getDay();
  return day === 0 ? 7 : day; // Lundi=1 ... Dimanche=7
}

Centraliser la configuration du TestingModule

Chaque fichier de test E2E commence quasiment de la même manière : importer AppModule, créer l'application Nest, récupérer la DataSource, instancier le TestHelper. Voilà à quoi ressemble la configuration type :

// src/test/functional/integration-flow.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { AppModule } from '../../app.module';
import { TestHelper } from '../helpers/test-helper';

describe('Integration Flow (e2e)', () => {
  let app: INestApplication;
  let dataSource: DataSource;
  let testHelper: TestHelper;
  let adminToken: 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);
  });

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

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

  // ... vos tests
});

Si cette structure se répète, vous pouvez aller plus loin et créer une fonction setupTestApp() dans un fichier test/helpers/setup.ts qui retourne { app, dataSource, testHelper }. Cela réduit le boilerplate à deux lignes par fichier.

Bien utiliser beforeAll, beforeEach et afterAll

Le choix du bon hook a un impact direct sur la vitesse et la fiabilité de vos tests :

  • beforeAll : exécuté une seule fois avant tous les tests du fichier. Idéal pour démarrer l'application Nest, qui est coûteuse à initialiser.
  • beforeEach : exécuté avant chaque test. Parfait pour réinitialiser la base et recréer un admin, garantissant que chaque test démarre dans un état connu.
  • afterEach : utile pour nettoyer des ressources spécifiques (fichiers temporaires, mocks à restaurer).
  • afterAll : indispensable pour fermer proprement l'application avec app.close(), sinon Jest signalera des handles ouverts.
Astuce : ne mettez jamais app.init() dans beforeEach. Sur 30 tests, vous gaspilleriez plusieurs secondes à chaque exécution. Réinitialiser uniquement les données est largement suffisant.

Utiliser le helper dans des scénarios réels

Une fois le helper en place, écrire un test devient quasiment de la prose lisible. Voici un exemple de flux complet de réservation :

it('should complete full booking flow', async () => {
  // 1. Créer un service via le helper
  const service = await testHelper.createService(ServiceType.APPOINTMENT);

  // 2. Créer un créneau horaire pour demain
  await testHelper.createTimeSlot(
    testHelper.getTomorrowDayOfWeek(),
    '10:00',
    '10:45',
    1,
  );

  // 3. Créer une réservation via l'API
  const bookingResponse = await request(app.getHttpServer())
    .post('/api/bookings')
    .send({
      serviceId: service.id,
      guestName: 'Test Guest',
      guestEmail: 'guest@test.com',
      bookingDate: testHelper.getTomorrowDate(),
      bookingTime: '10:00',
    })
    .expect(201);

  // 4. Confirmer la réservation en tant qu'admin
  await request(app.getHttpServer())
    .patch(`/api/bookings/${bookingResponse.body.data.id}`)
    .set('Authorization', `Bearer ${adminToken}`)
    .send({ status: BookingStatus.CONFIRMED })
    .expect(200);
});

Plus de boilerplate de setup : on se concentre sur le scénario métier.

Fixtures : données de test prédéfinies

Pour des jeux de données plus riches (par exemple, 10 services avec des configurations variées), créez un dossier fixtures/ avec des constantes ou des fonctions :

// src/test/fixtures/users.fixture.ts
import { UserRole } from '../../database/entities/user.entity';

export const userFixtures = {
  admin: {
    email: 'admin@test.com',
    firstName: 'Admin',
    lastName: 'Test',
    role: UserRole.ADMIN,
  },
  client: {
    email: 'client@test.com',
    firstName: 'Client',
    lastName: 'Test',
    role: UserRole.CLIENT,
  },
};

Les fixtures complètent les factories : les premières fournissent des données stables et nommées, les secondes génèrent des entités à la volée.

Tests en parallèle vs séquentiels

Par défaut, Jest exécute les fichiers de tests en parallèle. C'est génial pour les tests unitaires, mais problématique pour les tests E2E qui partagent une base de données : deux fichiers tronquent les tables au même moment et corrompent les données de l'autre.

Deux solutions :

  1. Forcer l'exécution séquentielle avec l'option --runInBand :
    jest --config ./test/jest-e2e.json --runInBand
  2. Isoler les bases : chaque worker Jest utilise une base distincte (app_test_1, app_test_2...) basée sur process.env.JEST_WORKER_ID. Plus rapide, mais nécessite plus de configuration.

Pour la majorité des projets, --runInBand est un excellent compromis simplicité/fiabilité.

Conclusion

Une suite de tests E2E bien organisée repose sur trois piliers : une structure claire séparant tests unitaires et E2E, un TestHelper centralisé exposant des factories et des utilitaires d'authentification, et l'usage pertinent des hooks Jest pour minimiser le coût de setup tout en garantissant l'isolation des tests.

En adoptant ces patterns, vous gagnerez du temps à chaque nouveau test écrit, et surtout vos tests resteront lisibles et maintenables au fil de la croissance du projet. Pour aller plus loin, explorez l'isolation par base de données par worker, l'utilisation de typeorm-fixtures pour des jeux de données complexes, ou la mise en place de Testcontainers pour démarrer une PostgreSQL éphémère à chaque run de CI.

Continuer la lecture

Article suivant — NestJS Tests E2E dans NestJS : tester ses endpoints HTTP Explorer tout : NestJS

Commentaires

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

Laisser un commentaire

Les champs obligatoires sont indiqués avec *