Cours NestJS · Module 7: Tests
Unit Testing - tester les pièces du fort une à une
Dans cette leçon6
La catapulte ne tire pas, mais le coupable reste introuvable. Peut-être une corde a-t-elle cédé, peut-être le mécanisme de détente a-t-il failli, peut-être le contrepoids a-t-il été mal calculé. Vérifier la machine entière d'un coup ne dit qu'une chose : quelque chose ne va pas - pas quoi.
Le mécanicien de la légion procède autrement : il démonte la machine et examine chaque pièce séparément. La corde tient-elle la tension ? Le mécanisme se déclenche-t-il quand on tire ? Un test unitaire fait exactement la même chose avec le code - il vérifie le plus petit élément en isolation du reste du système.
Les trois mots dont un test est fait
Commençons par le cas le plus simple - une classe sans aucune dépendance :
1describe('Bolt', () => {
2 it('calcule le poids du projectile', () => {
3 const bolt = new Bolt('iron', 5);
4
5 expect(bolt.getWeight()).toBe(5);
6 });
7});Trois mots et voilà un test. describe regroupe les tests portant sur un même élément - c'est un titre de chapitre. it décrit un seul comportement, et son nom se lit comme une phrase : "it calcule le poids du projectile". expect pose une attente et la compare à la réalité, ici par le matcher toBe.
Note le nom placé dans it : il dit ce que l'élément doit faire, non comment il est construit. Quand un tel test échoue dans un rapport de CI, tu lis "calcule le poids du projectile" et tu sais aussitôt ce qui s'est cassé - sans ouvrir le fichier.
Le problème : un service ne se crée pas avec new
Nous avons créé la classe Bolt par un simple new. Mais un service NestJS a des dépendances injectées - un repository, de la configuration, d'autres services. Écrire new LegionsService() finira en erreur, car le constructeur attend quelque chose.
On pourrait passer ces dépendances à la main, mais on contourne alors tout le mécanisme d'injection et le test cesse de ressembler à la réalité. NestJS propose donc un module de test : une version miniature de l'application ne contenant que ce dont un test a besoin.
Les trois étapes du module de test
La construction du module suit toujours la même séquence :
1const module = await Test.createTestingModule({
2 providers: [
3 LegionsService,
4 { provide: getRepositoryToken(Legion), useValue: repoMock },
5 ],
6}).compile();
7
8const service = module.get<LegionsService>(LegionsService);Première étape : Test.createTestingModule({...}) crée un module isolé pour le test. Dans providers, tu énumères uniquement ce dont le test a réellement besoin - le service testé et ses dépendances. Nous y remplaçons le vrai repository par une doublure via useValue, si bien que le test ne touche jamais de base de données.
Deuxième étape : .compile() finalise la configuration - NestJS résout les dépendances entre providers et crée leurs instances. C'est une opération asynchrone, d'où l'await. Sans cet appel, tu ne détiens qu'une description de module, pas un module en état de marche.
Troisième étape : module.get(LegionsService) sort du module achevé l'instance du provider - celle-là même que NestJS a construite, avec la doublure du repository déjà injectée. L'écriture <LegionsService> est un paramètre de type ; grâce à lui, TypeScript sait ce que tu reçois et te suggérera ses méthodes.
Retiens ces trois-là comme un tout : construis, compile, sors. Oublier compile() est l'erreur la plus fréquente sur un premier module de test.
Un fort neuf avant chaque épreuve
Le code ci-dessus va d'ordinaire dans un hook beforeEach :
1describe('LegionsService', () => {
2 let service: LegionsService;
3 let repoMock: { find: jest.Mock };
4
5 beforeEach(async () => {
6 repoMock = { find: jest.fn() };
7
8 const module = await Test.createTestingModule({
9 providers: [
10 LegionsService,
11 { provide: getRepositoryToken(Legion), useValue: repoMock },
12 ],
13 }).compile();
14
15 service = module.get<LegionsService>(LegionsService);
16 });
17});beforeEach s'exécute avant chaque test, chacun reçoit donc une instance fraîche du service et une doublure propre. Pourquoi, alors que construire le module coûte du temps ?
Parce que sans cela les tests commencent à s'influencer. Un service ayant rangé quelque chose en mémoire lors d'un test emporte cet état dans le suivant - et le résultat se met alors à dépendre de l'ordre d'exécution. Un tel test peut être vert chez toi et rouge en CI, où Jest parallélise autrement. Une instance fraîche supprime toute cette catégorie de problèmes en une ligne.
Un test, un comportement
Le module en place, nous écrivons les vrais tests :
1it('renvoie la liste des légions', async () => {
2 repoMock.find.mockResolvedValue([{ id: 1, name: 'Legio X' }]);
3
4 const result = await service.findAll();
5
6 expect(result).toHaveLength(1);
7});
8
9it('renvoie une liste vide quand il n\'y a aucune légion', async () => {
10 repoMock.find.mockResolvedValue([]);
11
12 const result = await service.findAll();
13
14 expect(result).toEqual([]);
15});Deux comportements, deux tests - et c'est une règle que je te recommande de tenir fermement. Il est tentant de vérifier les deux cas dans un seul it, mais tu perds alors la propriété la plus précieuse des tests unitaires : quand l'un échoue, tu sais exactement quoi. Un test qui examine cinq choses à la fois en dit autant qu'une catapulte qui ne tire pas - quelque chose cloche, débrouille-toi.
Note aussi ce que ces tests ne vérifient pas : si le repository récupère vraiment les données d'une base. C'est délibéré - la doublure coupe la base, nous examinons donc la seule logique du service. Nous vérifierons la coopération des vrais éléments avec un autre type de test.
Récapitulatif
La machine est démontée, chaque pièce examinée à part :
- un test unitaire examine le plus petit élément en isolation du reste du système,
describeregroupe les tests d'un élément,itdécrit un comportement,expectpose une attente,- le nom dans
itdit ce que l'élément doit faire - c'est lui qui apparaîtra dans le rapport après un échec, - un service NestJS ne se crée pas avec
new, car il a des dépendances injectées - c'est à cela que sert le module de test, - trois étapes :
Test.createTestingModule({providers})construit un module isolé,.compile()le finalise et crée les instances,module.get(Service)en sort le provider prêt, - oublier
.compile()est l'erreur la plus fréquente - sans lui, tu détiens une description de module, pas un module, useValueremplace une vraie dépendance par une doublure, le test ne touche donc jamais de base,beforeEachdonne une instance fraîche avant chaque test - sans quoi le résultat se met à dépendre de l'ordre d'exécution,- un comportement par test : un test qui en examine cinq ne dit pas lequel s'est cassé.
Dans la prochaine leçon, nous vérifierons ce qu'un test unitaire ne peut par construction pas voir - si les éléments coopèrent réellement. En attendant, retiens ceci : construis, compile, sors - puis examine une pièce à la fois.
Code de cette leçon : src/unit-testing.spec.ts
1// Unit Testing - Tester les éléments individuels du fort
2import { Test, TestingModule } from '@nestjs/testing';
3
4// Service à tester - gestion des provinces
5class ProvinceService {
6 private provinces = [
7 { id: 1, name: 'Gallia', governor: 'Caesar', tribute: 1000 },
8 { id: 2, name: 'Aegyptus', governor: 'Cleopatra', tribute: 2000 },
9 { id: 3, name: 'Britannia', governor: 'Agricola', tribute: 500 },
10 ];
11
12 findAll() {
13 return this.provinces;
14 }
15
16 findOne(id: number) {
17 return this.provinces.find(p => p.id === id) || null;
18 }
19
20 calculateTotalTribute(): number {
21 return this.provinces.reduce((sum, p) => sum + p.tribute, 0);
22 }
23
24 getProvincesByGovernor(governor: string) {
25 return this.provinces.filter(p => p.governor === governor);
26 }
27}
28
29describe('ProvinceService - Unit Tests', () => {
30 let service: ProvinceService;
31
32 // beforeEach - préparation avant chaque test
33 beforeEach(() => {
34 service = new ProvinceService();
35 });
36
37 describe('findAll', () => {
38 it('should return all provinces', () => {
39 const provinces = service.findAll();
40 expect(provinces).toHaveLength(3);
41 expect(provinces[0].name).toBe('Gallia');
42 });
43 });
44
45 describe('findOne', () => {
46 it('should return a province by id', () => {
47 const province = service.findOne(1);
48 expect(province).toBeDefined();
49 expect(province!.name).toBe('Gallia');
50 });
51
52 it('should return null for non-existent id', () => {
53 const province = service.findOne(999);
54 expect(province).toBeNull();
55 });
56 });
57
58 describe('calculateTotalTribute', () => {
59 it('should calculate total tribute correctly', () => {
60 const total = service.calculateTotalTribute();
61 expect(total).toBe(3500); // 1000 + 2000 + 500
62 });
63 });
64
65 describe('getProvincesByGovernor', () => {
66 it('should filter provinces by governor', () => {
67 const provinces = service.getProvincesByGovernor('Caesar');
68 expect(provinces).toHaveLength(1);
69 expect(provinces[0].name).toBe('Gallia');
70 });
71
72 it('should return empty array for unknown governor', () => {
73 const provinces = service.getProvincesByGovernor('Nero');
74 expect(provinces).toHaveLength(0);
75 });
76 });
77});
78Tu as repéré une erreur dans cette leçon ?
Teste tes connaissances
Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.
1. Test.createTestingModule() dans NestJS sert à :
2. Que fait la méthode compile() dans TestingModule ?
Voici 2 des 3 questions de cette leçon. Réponds aux autres dans le jeu.
Tâches pratiques dans le jeu
- Éditeur de code
Crée une configuration de TestingModule avec des dépendances injectées
- Tri horizontal
Ordonne la création du TestingModule dans le bon ordre :