Tests unitaires avec Jest dans NestJS

Tests unitaires avec Jest dans NestJS

Écrire des tests unitaires est un pilier du développement professionnel : ils sécurisent le refactoring, documentent le comportement attendu et détectent les régressions avant la mise en production. NestJS, fidèle à sa philosophie inspirée d'Angular, intègre nativement Jest et propose un module dédié @nestjs/testing qui simplifie considérablement l'isolation des dépendances.

Dans ce tutoriel, nous allons explorer en profondeur la mise en place et la rédaction de tests unitaires avec Jest dans une application NestJS. Les exemples sont tirés d'un projet réel comprenant des services de réservation (BookingService), de contact (ContactService) et d'authentification (AuthService).

Prérequis et configuration de Jest

Lorsque vous créez un projet NestJS avec la CLI (nest new), Jest est déjà préconfiguré. La configuration se trouve généralement dans le package.json, sous la clé jest :

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

Trois éléments à retenir :

  • testRegex : Jest cherche les fichiers se terminant par .spec.ts. Par convention, chaque fichier de test est placé à côté du fichier qu'il teste (booking.service.tsbooking.service.spec.ts).
  • transform : ts-jest compile le TypeScript à la volée pour Jest.
  • collectCoverageFrom : définit le périmètre de la couverture de code.

Les commandes utiles, déclarées dans les scripts du package.json :

npm run test          # Lance tous les tests
npm run test:watch    # Mode watch (relance à chaque modification)
npm run test:cov      # Génère le rapport de couverture
npm run test:debug    # Mode debug avec node --inspect

Anatomie d'un fichier de test NestJS

Tout test NestJS s'articule autour du TestingModule exposé par @nestjs/testing. Ce module reproduit le système d'injection de dépendances de NestJS dans un environnement isolé, ce qui vous permet de remplacer chaque dépendance d'un service par un mock.

Voici la structure type, illustrée par un test simple :

import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ContactService } from './contact.service';
import { ContactMessage } from '../../database/entities/contact-message.entity';
import { EmailService } from '../email/email.service';
import { ConfigService } from '@nestjs/config';

describe('ContactService', () => {
  let service: ContactService;
  let repository: Repository<ContactMessage>;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        ContactService,
        { provide: getRepositoryToken(ContactMessage), useValue: mockRepository },
        { provide: EmailService, useValue: mockEmailService },
        { provide: ConfigService, useValue: { get: jest.fn() } },
      ],
    }).compile();

    service = module.get<ContactService>(ContactService);
    repository = module.get<Repository<ContactMessage>>(
      getRepositoryToken(ContactMessage),
    );
  });

  afterEach(() => {
    jest.clearAllMocks();
  });
});

Décomposons les blocs essentiels :

  • describe regroupe logiquement un ensemble de tests (souvent par classe ou par méthode).
  • beforeEach est exécuté avant chaque test : on y reconstruit un module frais pour garantir l'isolation.
  • afterEach avec jest.clearAllMocks() réinitialise l'historique des appels des mocks pour éviter les fuites entre tests.
  • Test.createTestingModule() est l'équivalent de @Module() en environnement de test.

Mocker les dépendances avec useValue

Un test unitaire doit isoler la classe testée de ses collaborateurs. NestJS propose plusieurs stratégies de remplacement (useValue, useFactory, useClass), mais useValue reste le choix le plus courant car il permet d'injecter directement un objet contenant des fonctions Jest mockées (jest.fn()).

Mocker un repository TypeORM

NestJS fournit le helper getRepositoryToken(Entity) pour récupérer le token d'injection d'un repository. C'est ce token qu'il faut utiliser dans le tableau providers de votre module de test :

const mockRepository = {
  create: jest.fn().mockImplementation((dto) => dto),
  save: jest.fn().mockImplementation((entity) =>
    Promise.resolve({ id: '1', ...entity }),
  ),
  find: jest.fn().mockResolvedValue([mockMessage]),
  findOne: jest.fn().mockResolvedValue(mockMessage),
  remove: jest.fn().mockResolvedValue(mockMessage),
  count: jest.fn().mockResolvedValue(5),
  createQueryBuilder: jest.fn(() => ({
    leftJoinAndSelect: jest.fn().mockReturnThis(),
    where: jest.fn().mockReturnThis(),
    orderBy: jest.fn().mockReturnThis(),
    getMany: jest.fn().mockResolvedValue([mockMessage]),
  })),
};

// Dans le module de test :
{ provide: getRepositoryToken(ContactMessage), useValue: mockRepository }

Notez le pattern mockReturnThis() : il permet de simuler le chaining du QueryBuilder de TypeORM (.where().orderBy().getMany()). Sans cela, l'appel à .where(...) renverrait undefined et la chaîne s'effondrerait.

Mocker des services et des modules externes

Pour les autres dépendances (services métier, JwtService, ConfigService, EmailService...), le principe est identique : on remplace l'instance réelle par un objet contenant uniquement les méthodes utilisées par le service testé.

const mockEmailService = {
  sendBookingConfirmation: jest.fn().mockResolvedValue(undefined),
  sendBookingNotificationToAdmin: jest.fn().mockResolvedValue(undefined),
};

const mockJwtService = {
  sign: jest.fn().mockReturnValue('mockToken'),
  verify: jest.fn(),
};

const mockConfigService = {
  get: jest.fn().mockReturnValue('secret'),
};

Pour mocker une librairie externe comme bcrypt, utilisez jest.mock() au sommet du fichier :

import * as bcrypt from 'bcrypt';

jest.mock('bcrypt');

// Dans le test :
(bcrypt.compare as jest.Mock).mockResolvedValue(true);
(bcrypt.hash as jest.Mock).mockResolvedValue('hashedPassword');

Écrire des tests : assertions courantes

Une fois le module et les mocks en place, on rédige les tests avec it (ou son alias test). Voici les assertions Jest les plus utilisées dans un projet NestJS :

  • toBe(value) : égalité stricte (===), pour les primitives.
  • toEqual(value) : égalité structurelle profonde, pour les objets et tableaux.
  • toHaveBeenCalled() / toHaveBeenCalledWith(...) : vérifie qu'un mock a été invoqué (avec ces arguments).
  • toHaveProperty(key, value?) : vérifie la présence d'une propriété.
  • toBeDefined(), toBeNull(), toBeGreaterThan(n) : assertions classiques.

Exemple tiré du BookingService :

describe('getStats', () => {
  it('should return booking statistics', async () => {
    mockRepository.createQueryBuilder.mockReturnValueOnce({
      select: jest.fn().mockReturnThis(),
      addSelect: jest.fn().mockReturnThis(),
      getRawOne: jest.fn().mockResolvedValue({
        total: '3', pending: '1', confirmed: '1',
        completed: '1', cancelled: '0', revenue: '12000',
      }),
    });

    const result = await service.getStats();

    expect(result).toHaveProperty('total', 3);
    expect(result).toHaveProperty('pending', 1);
    expect(result).toHaveProperty('revenue', 12000);
  });
});

Tester les exceptions asynchrones

Dans NestJS, les services lèvent fréquemment des exceptions HTTP (NotFoundException, BadRequestException, UnauthorizedException). Pour tester un rejet de Promise, on combine expect().rejects.toThrow() :

describe('findOne', () => {
  it('should return a booking by id', async () => {
    const result = await service.findOne('1');
    expect(mockRepository.findOne).toHaveBeenCalled();
    expect(result).toEqual(mockBooking);
  });

  it('should throw NotFoundException if booking not found', async () => {
    mockRepository.findOne.mockResolvedValueOnce(null);
    await expect(service.findOne('999')).rejects.toThrow(NotFoundException);
  });
});

Le test AuthService illustre la couverture exhaustive des cas d'erreur d'authentification :

describe('validateUser', () => {
  it('should throw UnauthorizedException when user not found', async () => {
    mockRepository.findOne.mockResolvedValue(null);
    await expect(
      service.validateUser('test@example.com', 'password'),
    ).rejects.toThrow(UnauthorizedException);
  });

  it('should throw UnauthorizedException when password is invalid', async () => {
    mockRepository.findOne.mockResolvedValue(mockUser);
    (bcrypt.compare as jest.Mock).mockResolvedValue(false);
    await expect(
      service.validateUser('test@example.com', 'wrongPassword'),
    ).rejects.toThrow(UnauthorizedException);
  });

  it('should throw UnauthorizedException when user is inactive', async () => {
    mockRepository.findOne.mockResolvedValue({ ...mockUser, isActive: false });
    (bcrypt.compare as jest.Mock).mockResolvedValue(true);
    await expect(
      service.validateUser('test@example.com', 'password'),
    ).rejects.toThrow(UnauthorizedException);
  });
});

Astuce : pensez toujours à utiliser await devant expect().rejects, sinon Jest considérera le test comme passé même si l'exception n'est jamais levée.

Vérifier les effets de bord

Un test unitaire ne se contente pas de vérifier la valeur retournée : il valide aussi que les bonnes méthodes ont été appelées avec les bons arguments. C'est crucial pour les services orchestrant plusieurs collaborateurs, comme l'envoi d'e-mails après confirmation d'une réservation :

it('should send confirmation email when status is CONFIRMED', async () => {
  mockRepository.findOne.mockResolvedValueOnce({
    ...mockBooking,
    service: mockService,
    confirmationSent: false,
  });

  await service.updateStatus('1', BookingStatus.CONFIRMED);

  expect(mockEmailService.sendBookingConfirmation).toHaveBeenCalled();
  expect(mockEmailService.sendBookingNotificationToAdmin).toHaveBeenCalled();
});

Mesurer la couverture de code

La commande npm run test:cov génère un rapport détaillé dans le dossier coverage/. Ouvrez coverage/lcov-report/index.html pour visualiser, ligne par ligne, les portions de code non couvertes.

npm run test:cov

# ----------------------|---------|----------|---------|---------|
# File                  | % Stmts | % Branch | % Funcs | % Lines |
# ----------------------|---------|----------|---------|---------|
# All files             |   87.5  |   75.0   |   92.3  |   88.1  |
#  booking.service.ts   |   91.2  |   80.0   |  100.0  |   91.7  |
#  contact.service.ts   |   95.0  |   85.0   |  100.0  |   95.5  |

Visez idéalement plus de 80 % de couverture sur la couche service, où réside la logique métier. Inutile de viser 100 % à tout prix : un test mal écrit qui couvre du code sans réellement vérifier de comportement n'apporte aucune valeur.

Bonnes pratiques et pièges à éviter

  • Un test = un comportement : ne cumulez pas plusieurs assertions sans rapport dans un seul it.
  • Nommez explicitement vos tests : 'should throw NotFoundException if booking not found' est plus parlant que 'test 3'.
  • Évitez les états partagés : utilisez jest.clearAllMocks() dans afterEach.
  • Privilégiez mockResolvedValueOnce pour modifier ponctuellement le comportement d'un mock dans un seul test sans polluer les suivants.
  • Ne testez pas les détails d'implémentation de TypeORM ou de Nest : concentrez-vous sur le contrat public de votre service.

Conclusion

Vous disposez maintenant des fondations pour écrire des tests unitaires robustes dans une application NestJS : configuration de Jest, mise en place du TestingModule, mock des repositories TypeORM via getRepositoryToken(), mock des services collaborateurs via useValue, gestion des exceptions asynchrones et mesure de la couverture.

Pour aller plus loin, explorez les tests end-to-end (fichiers *.e2e-spec.ts) qui démarrent une application NestJS complète avec supertest, ainsi que les tests d'intégration avec une base de données SQLite en mémoire pour valider le comportement réel de TypeORM. Consultez la documentation officielle de NestJS pour approfondir les techniques avancées (override de providers, mock de guards et d'interceptors). Tester n'est pas une option : c'est la base d'une codebase pérenne.

Continuer la lecture

Article suivant — NestJS Personnaliser l'interface Swagger 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 *