É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.ts↔booking.service.spec.ts). - transform :
ts-jestcompile 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 :
describeregroupe logiquement un ensemble de tests (souvent par classe ou par méthode).beforeEachest exécuté avant chaque test : on y reconstruit un module frais pour garantir l'isolation.afterEachavecjest.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()dansafterEach. - Privilégiez
mockResolvedValueOncepour 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.




Commentaires