Kurs NestJS · Moduł 7: Testowanie

Test Coverage - mapowanie przetestowanych obszarów

4 min czytania
W tej lekcji4

Napisałeś już testy jednostkowe, integracyjne i E2E. Ale skąd wiesz, że nie zostawiłeś w kodzie ciemnego zaułka, do którego żaden test nigdy nie zajrzał?

Kartograf legionu ma na to sposób. Bierze mapę Imperium i zaznacza na niej każdą drogę, którą przeszedł zwiadowca. To, co zostanie białą plamą, nikt nie sprawdził. Test coverage - pokrycie kodu testami - jest właśnie taką mapą: uruchamiasz testy, a narzędzie zapisuje, które linie kodu naprawdę się wykonały.

Odczytanie mapy

Raport zamawiasz jednym poleceniem:

1npm run test:cov

Ten skrypt to nic innego jak jest --coverage - Jest wykonuje wszystkie testy, obserwując po drodze, których fragmentów kodu dotknął. Wynik wygląda tak:

1Statements   : 87.5% ( 175/200 )
2Branches     : 83.3% ( 50/60 )
3Functions    : 90.0% ( 45/50 )
4Lines        : 88.0% ( 176/200 )

Cztery liczby, cztery różne pytania o ten sam kod. Warto je rozdzielić, bo różnią się dokładnością.

Statements i Lines liczą wykonane instrukcje i linie - to najgrubszy pomiar, mówi tylko "tędy przechodziliśmy". Functions pyta, czy każda funkcja została choć raz wywołana; przydatne do wyłapania metod, o których wszyscy zapomnieli.

Najważniejsza jest trzecia liczba. Branches mierzy ścieżki warunkowe - czy każde if sprawdzono zarówno wtedy, gdy warunek był prawdziwy, jak i wtedy, gdy fałszywy. Spójrz na tę funkcję:

1function calculatePay(legionary: Legionary): number {
2  if (legionary.rank === 'CENTURION') {
3    return legionary.basePay * 2;
4  }
5  return legionary.basePay;
6}

Jeden test z centurionem wykona obie linijki poza return legionary.basePay - statement coverage podskoczy wysoko. Ale gałąź "to nie centurion" pozostanie niesprawdzona, a to właśnie w niej lubią siedzieć błędy. Dlatego branch coverage jest zawsze niższy od statement coverage i to on mówi prawdę o jakości testów.

Wyznaczenie progu

Sam raport niczego nie wymusza. Żeby pokrycie nie osuwało się z każdym tygodniem, ustawiamy próg w konfiguracji Jest:

1coverageThreshold: {
2  global: {
3    statements: 80,
4    branches: 75,
5    functions: 80,
6    lines: 80,
7  },
8},

Od tej chwili spadek poniżej progu kończy się błędem - polecenie zwraca kod wyjścia różny od zera, więc zatrzyma też budowanie w CI. Zauważ, że próg dla branches jest niższy niż dla reszty. To nie niedopatrzenie: gałęzi jest z natury trudniej dosięgnąć, więc realistyczny próg ustawia się niżej, żeby wymóg pozostał wykonalny, a nie stał się obrzędem obchodzonym przez wszystkich.

Czego mapa nie pokazuje

Tu dochodzimy do rzeczy, o której łatwo zapomnieć, patrząc na ładne procenty. Coverage mierzy, czy kod się wykonał - nie czy zachował się poprawnie.

Ten test da stuprocentowe pokrycie i nie sprawdza niczego:

1it('oblicza żołd', () => {
2  calculatePay({ rank: 'CENTURION', basePay: 100 });
3});

Funkcja została wywołana, więc kartograf zaznaczy drogę jako przebadaną. Ale nie ma tu ani jednego expect - gdyby funkcja zwróciła ujemną liczbę albo rzuciła wyjątkiem, test nadal przeszedłby na zielono. Pokrycie mówi, gdzie zwiadowca był; nie mówi, czy patrzył.

Stąd praktyczna zasada: traktuj coverage jako wykrywacz białych plam, a nie jako ocenę. Niskie pokrycie to twardy sygnał, że czegoś nie sprawdzono - warto tam zajrzeć. Wysokie pokrycie nie dowodzi niczego samo z siebie. Pogoń za okrągłymi stu procentami kończy się zwykle testami pisanymi pod licznik: bez asercji, za to na getterach i plikach konfiguracyjnych.

Dlatego z raportu czytaj przede wszystkim listę nieprzetestowanych linii, a nie sam procent. To ona wskazuje ciemne zaułki, a wśród nich najpierw sprawdzaj obsługę błędów i przypadki brzegowe - bo tam trafiają najrzadziej uruchamiane, a najbardziej kosztowne gałęzie.

Podsumowanie

Mapa Imperium narysowana, białe plamy widoczne:

  • test coverage pokazuje, które fragmenty kodu wykonały się podczas testów,
  • raport zamawiasz przez npm run test:cov, czyli jest --coverage,
  • statements i lines to pomiar najgrubszy, functions wyłapuje niewywołane funkcje,
  • branch coverage sprawdza ścieżki warunkowe - każde if w obu wariantach - i to on najlepiej mówi o jakości testów,
  • branch coverage jest zawsze niższy od statement coverage, więc jego próg ustawia się niżej,
  • coverageThreshold w konfiguracji Jest zamienia próg w twardy wymóg: spadek poniżej przerywa budowanie,
  • coverage mierzy wykonanie, nie poprawność - test bez expect da pełne pokrycie i nie sprawdzi niczego,
  • czytaj listę nieprzetestowanych linii, nie sam procent; zaczynaj od obsługi błędów i przypadków brzegowych.

W następnej lekcji zajmiemy się testami wydajnościowymi - sprawdzimy nie to, czy fort działa, ale ile naporu wytrzyma. A na razie zapamiętaj: coverage to mapa z zaznaczonymi drogami zwiadowców - białe plamy mówią, gdzie nikt nie był, ale zaznaczona droga nie dowodzi, że zwiadowca czegokolwiek na niej szukał.

Kod do tej lekcji: src/coverage-example.spec.ts
1// Test Coverage - Mapowanie Przetestowanych Obszarow Fortu
2
3// Serwis do analizy pokrycia
4class TributeCalculator {
5  // Oblicz podatek od prowincji
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  // Kategoryzuj prowincje
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  // Oblicz bonus za lojalnosc
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  // Podsumowanie prowincji
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// 100% Coverage Tests - kazda linia, kazda galaz
42describe('TributeCalculator - Full Coverage', () => {
43  let calc: TributeCalculator;
44
45  beforeEach(() => {
46    calc = new TributeCalculator();
47  });
48
49  // Branch coverage - kazdy warunek 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// Konfiguracja pokrycia w 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

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Metryka 'branch coverage' mierzy:

  2. 2. Komenda npm run test:cov generuje:

To 2 z 3 pytań do tej lekcji. Pozostałe rozwiążesz w grze.

Przydatne artykuły