Cours NestJS · Module 7: Tests
Test Coverage - cartographier le territoire testé
Dans cette leçon4
Tu as désormais écrit des tests unitaires, d'intégration et E2E. Mais comment sais-tu que tu n'as pas laissé dans le code une ruelle sombre où aucun test n'a jamais mis les pieds ?
Le cartographe de la légion a une méthode pour cela. Il prend une carte de l'Empire et y marque chaque route qu'un éclaireur a parcourue. Ce qui reste blanc, personne ne l'a vérifié. Le test coverage - la couverture du code par les tests - est exactement cette carte : tu lances les tests, et l'outil note quelles lignes de code se sont réellement exécutées.
Lire la carte
Tu commandes le rapport par une seule commande :
1npm run test:covCe script n'est rien d'autre que jest --coverage - Jest exécute tous les tests en observant au passage quelles parties du code il touche. Le résultat ressemble à ceci :
1Statements : 87.5% ( 175/200 )
2Branches : 83.3% ( 50/60 )
3Functions : 90.0% ( 45/50 )
4Lines : 88.0% ( 176/200 )Quatre nombres, quatre questions différentes sur le même code. Il vaut la peine de les distinguer, car ils diffèrent par leur finesse.
Statements et Lines comptent les instructions et les lignes exécutées - la mesure la plus grossière, qui dit seulement "nous sommes passés par là". Functions demande si chaque fonction a été appelée au moins une fois ; utile pour repérer les méthodes que tout le monde a oubliées.
Le troisième nombre est le plus important. Branches mesure les chemins conditionnels - si chaque if a été éprouvé aussi bien quand la condition était vraie que lorsqu'elle était fausse. Regarde cette fonction :
1function calculatePay(legionary: Legionary): number {
2 if (legionary.rank === 'CENTURION') {
3 return legionary.basePay * 2;
4 }
5 return legionary.basePay;
6}Un seul test avec un centurion exécute toutes les lignes sauf return legionary.basePay - la couverture des instructions grimpe haut. Mais la branche "ce n'est pas un centurion" reste non vérifiée, et c'est justement là que les bugs aiment se loger. Voilà pourquoi la couverture des branches est toujours inférieure à celle des instructions, et pourquoi c'est ce nombre qui dit la vérité sur tes tests.
Fixer un seuil
Le rapport en lui-même n'impose rien. Pour empêcher la couverture de glisser semaine après semaine, nous fixons un seuil dans la configuration de Jest :
1coverageThreshold: {
2 global: {
3 statements: 80,
4 branches: 75,
5 functions: 80,
6 lines: 80,
7 },
8},Désormais, une chute sous le seuil se solde par un échec - la commande renvoie un code de sortie non nul, elle arrêtera donc aussi une construction en CI. Note que le seuil de branches est plus bas que les autres. Ce n'est pas un oubli : les branches sont par nature plus difficiles à atteindre, un seuil réaliste se place donc plus bas, pour que l'exigence reste tenable au lieu de devenir un rite que tout le monde contourne.
Ce que la carte ne montre pas
Nous arrivons ici à ce qu'on oublie le plus facilement en admirant de jolis pourcentages. La couverture mesure si le code s'est exécuté - pas s'il s'est comporté correctement.
Ce test donne une couverture complète et ne vérifie rien :
1it('calcule la solde', () => {
2 calculatePay({ rank: 'CENTURION', basePay: 100 });
3});La fonction a été appelée, le cartographe marque donc la route comme explorée. Mais il n'y a pas un seul expect ici - si la fonction renvoyait un nombre négatif ou levait une exception, le test passerait quand même au vert. La couverture dit où l'éclaireur est allé ; elle ne dit pas s'il a regardé.
D'où la règle pratique : traite la couverture comme un détecteur de zones blanches, non comme une note. Une couverture basse est un signal net que quelque chose n'a pas été vérifié - il vaut la peine d'y aller voir. Une couverture élevée ne prouve rien en soi. La course aux cent pour cent ronds finit d'ordinaire en tests écrits pour le compteur : sans assertions, mais en abondance sur les getters et les fichiers de configuration.
Lis donc dans le rapport la liste des lignes non couvertes plutôt que le pourcentage. C'est elle qui désigne les ruelles sombres, et parmi elles vérifie d'abord la gestion des erreurs et les cas limites - ils abritent les branches les moins souvent exécutées et les plus coûteuses.
Récapitulatif
La carte de l'Empire est dressée et les zones blanches sont visibles :
- le test coverage montre quelles parties du code se sont exécutées pendant les tests,
- tu commandes le rapport par
npm run test:cov, c'est-à-direjest --coverage, - statements et lines sont la mesure la plus grossière, functions repère les fonctions jamais appelées,
- la couverture des branches vérifie les chemins conditionnels - chaque
ifdans ses deux variantes - et c'est elle qui parle le mieux de la qualité des tests, - la couverture des branches est toujours inférieure à celle des instructions, son seuil se place donc plus bas,
coverageThresholddans la configuration de Jest transforme le seuil en exigence stricte : une chute en dessous casse la construction,- la couverture mesure l'exécution, pas la justesse - un test sans
expectdonne une couverture complète et ne vérifie rien, - lis la liste des lignes non couvertes, pas le pourcentage ; commence par la gestion des erreurs et les cas limites.
Dans la prochaine leçon, nous aborderons les tests de performance - nous vérifierons non pas si le fort fonctionne, mais quelle pression il supporte. En attendant, retiens ceci : la couverture est une carte où sont tracées les routes des éclaireurs - les zones blanches disent où personne n'est allé, mais une route tracée ne prouve pas que l'éclaireur y cherchait quoi que ce soit.
Code de cette leçon : src/coverage-example.spec.ts
1// Test Coverage - Cartographier les zones testées du fort
2
3// Service pour l'analyse de la couverture
4class TributeCalculator {
5 // Calculer l'impôt d'une province
6 calculateTax(tribute: number, rate: number): number {
7 if (rate < 0 || rate > 1) {
8 throw new Error('Rate must be between 0 and 1');
9 }
10 return tribute * rate;
11 }
12
13 // Catégoriser une province
14 categorizeProvince(tribute: number): string {
15 if (tribute >= 2000) return 'wealthy';
16 if (tribute >= 1000) return 'standard';
17 if (tribute >= 500) return 'developing';
18 return 'poor';
19 }
20
21 // Calculer le bonus de loyauté
22 loyaltyBonus(years: number, baseTribute: number): number {
23 if (years >= 50) return baseTribute * 0.2;
24 if (years >= 20) return baseTribute * 0.1;
25 if (years >= 10) return baseTribute * 0.05;
26 return 0;
27 }
28
29 // Résumé des provinces
30 getSummary(provinces: Array<{ name: string; tribute: number }>) {
31 const total = provinces.reduce((sum, p) => sum + p.tribute, 0);
32 const avg = provinces.length > 0 ? total / provinces.length : 0;
33 const richest = provinces.reduce(
34 (max, p) => (p.tribute > max.tribute ? p : max),
35 provinces[0]
36 );
37 return { total, average: avg, richest: richest?.name };
38 }
39}
40
41// Tests à 100 % de couverture - chaque ligne, chaque branche
42describe('TributeCalculator - Full Coverage', () => {
43 let calc: TributeCalculator;
44
45 beforeEach(() => {
46 calc = new TributeCalculator();
47 });
48
49 // Branch coverage - chaque condition if/else
50 describe('calculateTax', () => {
51 it('should calculate tax correctly', () => {
52 expect(calc.calculateTax(1000, 0.1)).toBe(100);
53 });
54 it('should throw for negative rate', () => {
55 expect(() => calc.calculateTax(1000, -0.1)).toThrow();
56 });
57 it('should throw for rate > 1', () => {
58 expect(() => calc.calculateTax(1000, 1.5)).toThrow();
59 });
60 });
61
62 describe('categorizeProvince', () => {
63 it('wealthy: >= 2000', () => {
64 expect(calc.categorizeProvince(2500)).toBe('wealthy');
65 });
66 it('standard: >= 1000', () => {
67 expect(calc.categorizeProvince(1500)).toBe('standard');
68 });
69 it('developing: >= 500', () => {
70 expect(calc.categorizeProvince(700)).toBe('developing');
71 });
72 it('poor: < 500', () => {
73 expect(calc.categorizeProvince(200)).toBe('poor');
74 });
75 });
76
77 describe('loyaltyBonus', () => {
78 it('50+ years: 20% bonus', () => {
79 expect(calc.loyaltyBonus(60, 1000)).toBe(200);
80 });
81 it('20+ years: 10% bonus', () => {
82 expect(calc.loyaltyBonus(25, 1000)).toBe(100);
83 });
84 it('10+ years: 5% bonus', () => {
85 expect(calc.loyaltyBonus(15, 1000)).toBe(50);
86 });
87 it('< 10 years: no bonus', () => {
88 expect(calc.loyaltyBonus(5, 1000)).toBe(0);
89 });
90 });
91});
92
93// Configuration de la couverture dans package.json :
94// "jest": {
95// "collectCoverage": true,
96// "coverageDirectory": "coverage",
97// "coverageThreshold": {
98// "global": {
99// "branches": 80, "functions": 80,
100// "lines": 80, "statements": 80
101// }
102// }
103// }
104Tu 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. La métrique « branch coverage » mesure :
2. La commande npm run test:cov génère :
Voici 2 des 3 questions de cette leçon. Réponds aux autres dans le jeu.