Cours NestJS · Module 7: Tests

Test Coverage - cartographier le territoire testé

5 min de lecture
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:cov

Ce 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-à-dire jest --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 if dans 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,
  • coverageThreshold dans 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 expect donne 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// }
104

Tu 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. 1. La métrique « branch coverage » mesure :

  2. 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.