Cours NestJS · Module 7: Tests
PROJET - tester intégralement le système des légions
Dans cette leçon7
Tout au long de ce module, nous avons écrit les tests un à un : un test de service ici, un test de contrôleur là, un test HTTP ailleurs. Le projet est l'endroit où ils doivent devenir une suite - celle que quelqu'un lancera dans six mois et dont il croira le résultat.
Ta tâche est de couvrir de tests le système de gestion des légions : légions et cohortes, affectation des légionnaires, planification des expéditions, suivi des tributs.
Trois niveaux, trois questions
Avant d'écrire le premier fichier, établis à quelle question répond chaque niveau :
- Un test unitaire - cette méthode calcule-t-elle correctement quand tout autour est mocké ?
- Un test d'intégration - les modules s'entendent-ils, avec un vrai conteneur de dépendances ?
- Un test E2E - le parcours complet de l'utilisateur fonctionne-t-il par HTTP, de la requête à la réponse ?
Chaque niveau a son coût. Écris surtout des tests unitaires : ils sont bon marché et désignent du doigt le coupable ; le moins de tests E2E possible, car ils sont lents et, lorsqu'ils échouent, ne disent que « quelque chose ne va pas ».
Given-When-Then
Tout test, quel que soit son niveau, présente la même structure tripartite. Given, ce sont les préconditions, When, l'action, Then, le résultat attendu :
1it('should create a legion with the given name', async () => {
2 // Given - les preconditions
3 const dto = { name: 'Legio X Equestris', maxSoldiers: 5000 };
4 mockRepository.save.mockResolvedValue({ id: 1, ...dto });
5
6 // When - l'action
7 const result = await service.create(dto);
8
9 // Then - le resultat attendu
10 expect(result.id).toBe(1);
11 expect(mockRepository.save).toHaveBeenCalledWith(dto);
12});Ne confonds pas ce découpage avec les trois niveaux de tests - Given ne désigne pas un test unitaire, ni Then un test e2e. Il ne s'agit pas davantage des sortes de doublures : un mock, un spy et un stub sont des outils que tu emploies dans la partie Given. Ni des couches HTTP : données, requête et code de réponse ne sont qu'un remplissage possible de ce squelette.
La valeur du pattern est pratique. Quand un test n'a pas de When distinct, cela signifie d'ordinaire qu'il vérifie deux actions à la fois - et en cas d'échec, tu ne sauras pas laquelle a cédé.
Deux principes qui décident de la crédibilité d'une suite
Les tests doivent être indépendants les uns des autres et isolés. C'est le premier principe du test unitaire, et le seul dont la violation gâte la suite entière d'un coup.
Des tests qui partagent un état passent dans l'ordre où on les a écrits et échouent dès que l'ordre change, dès qu'ils s'exécutent en parallèle, ou dès que quelqu'un ajoute un test au milieu du fichier. Le pire est qu'ils ont alors l'air d'avoir détecté un bogue - alors qu'ils n'ont détecté que leur propre couplage.
Deux malentendus valent d'être écartés d'emblée. Les noms de tests doivent être longs et descriptifs, non les plus courts possible : le nom d'un test est le message d'erreur que tu verras sur fond rouge dans la CI, et t1 ne t'apprendra rien. Et il faut aussi tester les cas limites (edge cases), non le seul chemin nominal (happy path) - car les bogues logent exactement là où personne n'a regardé : une liste vide, un zéro, un caractère spécial dans un nom.
Les hooks et le mock des dépendances
L'isolation s'obtient par les hooks, et leur ordre d'exécution est fixe : beforeAll() → beforeEach() → test/it() → afterAll() :
1describe('LegionsController', () => {
2 let controller: LegionsController;
3 let module: TestingModule;
4
5 const mockLegionsService = {
6 findAll: jest.fn(),
7 create: jest.fn(),
8 };
9
10 beforeAll(async () => {
11 module = await Test.createTestingModule({
12 controllers: [LegionsController],
13 providers: [{ provide: LegionsService, useValue: mockLegionsService }],
14 }).compile();
15
16 controller = module.get(LegionsController);
17 });
18
19 beforeEach(() => {
20 jest.clearAllMocks();
21 });
22
23 afterAll(async () => {
24 await module.close();
25 });
26});Le partage du travail entre les hooks découle directement de leur ordre. beforeAll s'exécute une fois - il y bâtit le coûteux module de test. beforeEach s'exécute avant chaque test, et c'est lui qui applique le principe d'isolation : jest.clearAllMocks() efface l'historique des appels, pour qu'un test ne voie nulle trace de son prédécesseur. afterAll fait le ménage à la fin - fermeture du module, des connexions, des poignées restées ouvertes.
Remarque l'écriture de la doublure dans providers. L'ordre est toujours le même : { provide: ouvre l'objet, LegionsService, désigne le token à remplacer, useValue: annonce une valeur, et mockLegionsService } la fournit. Cela se lit comme une phrase : « à la place de LegionsService, utilise cette valeur ».
Mocker un dépôt tient en une ligne par cas : mockRepository.save.mockResolvedValue(expectedResult) fait renvoyer à la méthode un résultat tout prêt au lieu de toucher la base. Pour le chemin d'erreur, tu emploieras mockRejectedValue.
Le test E2E
Au niveau le plus haut, tu t'adresses à l'application comme le fera un client - par HTTP, au moyen de la bibliothèque supertest :
1it('GET /legions returns all legions', async () => {
2 const response = await request(app.getHttpServer())
3 .get('/legions')
4 .expect(200)
5 .expect((res) => expect(res.body).toHaveLength(3));
6});
7
8it('POST /legions rejects a body without a name', async () => {
9 await request(app.getHttpServer())
10 .post('/legions')
11 .send({ maxSoldiers: 5000 })
12 .expect(400);
13});L'assertion se compose de quatre maillons dans un ordre fixe : const response = await request(app.getHttpServer()) ouvre une requête vers l'application en marche, .get('/legions') désigne la méthode et le chemin, .expect(200) vérifie le code de réponse, et .expect(res => expect(res.body).toHaveLength(3)) regarde dans son corps.
Le second test montre une chose qu'on oublie aisément : vérifie aussi les rejets. Une requête privée d'un champ obligatoire doit recevoir un 400, et c'est le seul moyen de s'assurer que ValidationPipe est réellement branché dans la configuration de test, et non dans le seul main.ts.
Quand un test échoue
Un test rouge se débogue en quatre étapes, toujours dans cet ordre :
- Lire le message d'erreur et la stack trace. Elle contient d'ordinaire tout : ce qui était attendu, ce qui est revenu, et à quelle ligne.
- Identifier quelle assertion a échoué. Avec plusieurs
expectdans un même test, ce n'est pas évident - d'où le conseil de ne pas surcharger un test. - Vérifier les données d'entrée et les mocks. La cause la plus fréquente ne loge pas dans le code, mais dans une doublure qui renvoie autre chose que tu ne crois - ou qui se souvient d'un appel du test précédent.
- Corriger le test ou le code testé. Maintenant seulement, une fois établi ce qui est cassé.
L'ordre compte, car le réflexe naturel - commencer par la quatrième étape et retoucher le code à l'instinct - finit par modifier une implémentation qui marchait pour satisfaire un test fautif.
Ce que tu rends
Le projet est achevé lorsqu'il comporte :
- Des tests unitaires des services avec dépôts mockés, couvrant aussi les chemins d'erreur.
- Des tests d'intégration vérifiant la coopération des modules sur un vrai
TestingModule. - Des tests E2E pour les parcours essentiels, avec assertions sur le code de réponse et sur le corps.
- Des blocs
describedistincts pour chaque niveau, afin de pouvoir les lancer séparément. - Un rapport de couverture assorti d'une justification de ce que tu as délibérément laissé de côté.
Termine par une épreuve qui met la suite entière à l'essai : lance les tests dans un ordre aléatoire (jest --randomize). Si l'un d'eux échoue, tu as un état partagé quelque part - et une suite qui ne passe que dans un seul ordre ne dit pas la vérité sur ton code.
Envoie le lien de ton dépôt une fois terminé.
Code de cette leçon : src/fleet-test-project.spec.ts
1// PROJET : Tests complets du système de gestion de la flotte
2import { Test, TestingModule } from '@nestjs/testing';
3import { Injectable, NotFoundException } from '@nestjs/common';
4
5// === SERVICES À TESTER ===
6
7@Injectable()
8class ShipService {
9 private ships = new Map<string, any>();
10
11 create(data: { name: string; type: string; crew: number }) {
12 if (!data.name || !data.type) {
13 throw new Error('Name and type are required');
14 }
15 const id = `ship-${Date.now()}`;
16 const ship = { id, ...data, status: 'docked' };
17 this.ships.set(id, ship);
18 return ship;
19 }
20
21 findOne(id: string) {
22 const ship = this.ships.get(id);
23 if (!ship) throw new NotFoundException(`Ship ${id} not found`);
24 return ship;
25 }
26
27 findAll() { return Array.from(this.ships.values()); }
28
29 deploy(id: string) {
30 const ship = this.findOne(id);
31 ship.status = 'deployed';
32 return ship;
33 }
34}
35
36@Injectable()
37class FleetService {
38 constructor(private shipService: ShipService) {}
39
40 getFleetStrength() {
41 const ships = this.shipService.findAll();
42 return {
43 total: ships.length,
44 deployed: ships.filter(s => s.status === 'deployed').length,
45 docked: ships.filter(s => s.status === 'docked').length,
46 totalCrew: ships.reduce((sum, s) => sum + s.crew, 0),
47 };
48 }
49
50 deployFleet() {
51 const ships = this.shipService.findAll();
52 return ships.map(s => this.shipService.deploy(s.id));
53 }
54}
55
56// === TESTS ===
57
58describe('Fleet Management - Complete Test Suite', () => {
59 // UNIT TESTS
60 describe('ShipService - Unit Tests', () => {
61 let service: ShipService;
62 beforeEach(() => { service = new ShipService(); });
63
64 it('should create a ship', () => {
65 const ship = service.create({
66 name: 'Trireme Roma', type: 'warship', crew: 200,
67 });
68 expect(ship.id).toBeDefined();
69 expect(ship.status).toBe('docked');
70 });
71
72 it('should throw on invalid data', () => {
73 expect(() => service.create({
74 name: '', type: '', crew: 0,
75 })).toThrow('Name and type are required');
76 });
77
78 it('should throw NotFoundException', () => {
79 expect(() => service.findOne('unknown')).toThrow(NotFoundException);
80 });
81
82 it('should deploy a ship', () => {
83 const ship = service.create({
84 name: 'Corvus', type: 'warship', crew: 150,
85 });
86 const deployed = service.deploy(ship.id);
87 expect(deployed.status).toBe('deployed');
88 });
89 });
90
91 // INTEGRATION TESTS
92 describe('FleetService - Integration', () => {
93 let fleetService: FleetService;
94 let shipService: ShipService;
95
96 beforeEach(async () => {
97 const modulee: TestingModule = await Test.createTestingModule({
98 providers: [FleetService, ShipService],
99 }).compile();
100 fleetService = modulee.get(FleetService);
101 shipService = modulee.get(ShipService);
102 });
103
104 it('should calculate fleet strength', () => {
105 shipService.create({ name: 'S1', type: 'war', crew: 200 });
106 shipService.create({ name: 'S2', type: 'war', crew: 150 });
107 const strength = fleetService.getFleetStrength();
108 expect(strength.total).toBe(2);
109 expect(strength.totalCrew).toBe(350);
110 });
111
112 it('should deploy entire fleet', () => {
113 shipService.create({ name: 'S1', type: 'war', crew: 200 });
114 shipService.create({ name: 'S2', type: 'war', crew: 150 });
115 const deployed = fleetService.deployFleet();
116 expect(deployed.every(s => s.status === 'deployed')).toBe(true);
117 });
118 });
119});
120Tu 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. Le pattern Given-When-Then dans les tests signifie :
2. Lequel des principes suivants est crucial lors de l'écriture de tests unitaires ?
Tâches pratiques dans le jeu
- Éditeur de code
Teste si le controller rejette des données d'entrée invalides
- Éditeur de code
Écris un test pour la méthode create() avec un repository.save() mocké
- Clique dans l'ordre
Ordonne l'ordre d'exécution des hooks dans un fichier de test :
- Tri vertical
Ordonne l'assertion vérifiant une réponse HTTP dans un test E2E :
- Tri vertical
Ordonne les étapes pour déboguer un test échoué :
- Tri horizontal
Ordonne la configuration d'un mock provider dans TestingModule :
- Éditeur de code
Écris une suite de tests complète couvrant le controller, le service et l'endpoint HTTP