Kurs JavaScript i TypeScript · Moduł 11: Testowanie z Jest

Projekt: Kompletny zestaw testów dla systemu Parku Jurajskiego

5 min czytania
W tej lekcji2

Park działa, dinozaury są nakarmione, ale nikt nie wie, czy jutrzejsza poprawka w systemie karmienia nie wyłączy ogrodzenia w strefie raptorów. Dlatego piszemy zestaw testów: żeby każda zmiana przechodziła przez te same czujniki, zanim trafi do parku.

Czas na finalne wyzwanie! Zbudujesz kompletny zestaw testów dla systemu zarządzania Parkiem Jurajskim. Ten projekt łączy wszystkie koncepcje z modułu: Jest, matchery, setup/teardown, mockowanie, testowanie asynchroniczne, TDD i TypeScript.

System do przetestowania

Poniżej znajdziesz kompletny system zarządzania parkiem. Twoim zadaniem jest napisanie testów dla każdego modułu. Kod jest długi, ale nie musisz go zapamiętywać. Czytaj go jak specyfikację, w której każda instrukcja if i każdy throw to kandydat na osobny test.

Moduł 1: Zarządzanie dinozaurami

DinosaurManager przechowuje okazy w Map, czyli kolekcji par klucz-wartość, i nadaje im identyfikatory DINO-001, DINO-002 i kolejne. Zwróć uwagę na wyjątek przy braku imienia albo gatunku oraz na wszystkożerców, którzy przyjmują dwa rodzaje pokarmu:

1// dinosaurManager.js
2class DinosaurManager {
3  constructor() {
4    this.dinosaurs = new Map();
5    this.nextId = 1;
6  }
7
8  addDinosaur(name, species, diet, zone) {
9    if (!name || !species) {
10      throw new Error('Name and species are required');
11    }
12
13    const id = `DINO-${String(this.nextId).padStart(3, '0')}`;
14    this.nextId++;
15
16    const dinosaur = {
17      id,
18      name,
19      species,
20      diet,
21      zone,
22      health: 100,
23      fed: false,
24      lastFed: null,
25    };
26
27    this.dinosaurs.set(id, dinosaur);
28    return dinosaur;
29  }
30
31  getDinosaur(id) {
32    return this.dinosaurs.get(id) || null;
33  }
34
35  feedDinosaur(id, food) {
36    const dino = this.getDinosaur(id);
37    if (!dino) throw new Error('Dinosaur not found');
38
39    const correctFood = {
40      carnivore: 'meat',
41      herbivore: 'plants',
42      omnivore: ['meat', 'plants'],
43    };
44
45    const expected = correctFood[dino.diet];
46    const isCorrect = Array.isArray(expected)
47      ? expected.includes(food)
48      : expected === food;
49
50    if (!isCorrect) {
51      return { success: false, message: 'Wrong food type' };
52    }
53
54    dino.health = Math.min(100, dino.health + 10);
55    dino.fed = true;
56    dino.lastFed = new Date();
57
58    return { success: true, health: dino.health };
59  }
60
61  getByZone(zone) {
62    return [...this.dinosaurs.values()].filter(d => d.zone === zone);
63  }
64
65  getByDiet(diet) {
66    return [...this.dinosaurs.values()].filter(d => d.diet === diet);
67  }
68
69  getTotalCount() {
70    return this.dinosaurs.size;
71  }
72}

Zacznij od beforeEach, który tworzy świeżego menedżera, bo inaczej licznik nextId przeniesie się między testami i drugi test dostanie DINO-002 zamiast DINO-001. Pole lastFed dostaje new Date(), czyli wartość, której nie przewidzisz. Sprawdź je przez expect.any(Date) albo zamroź czas sztucznym zegarem i jest.setSystemTime. Przy złym pokarmie upewnij się też, że zdrowie się nie zmieniło.

Moduł 2: System bezpieczeństwa

SecuritySystem dostaje alertService w konstruktorze. To wstrzykiwanie zależności i najlepsza wiadomość dla testera, bo zamiast jest.mock wystarczy przekazać obiekt z mockiem. Metoda reportBreach po trzecim naruszeniu włącza lockdown i podwaja napięcie ogrodzenia:

1// securitySystem.js
2class SecuritySystem {
3  constructor(alertService) {
4    this.alertService = alertService; // zależność do mockowania
5    this.zones = new Map();
6    this.logs = [];
7  }
8
9  registerZone(name, fenceVoltage, securityLevel) {
10    this.zones.set(name, {
11      name,
12      fenceVoltage,
13      securityLevel,
14      status: 'active',
15      breachCount: 0,
16    });
17  }
18
19  async reportBreach(zoneName) {
20    const zone = this.zones.get(zoneName);
21    if (!zone) throw new Error('Zone not found');
22
23    zone.breachCount++;
24    zone.status = 'breached';
25
26    this.logs.push({
27      type: 'BREACH',
28      zone: zoneName,
29      timestamp: Date.now(),
30    });
31
32    await this.alertService.sendAlert(zoneName, 'Security breach detected');
33
34    if (zone.breachCount >= 3) {
35      await this.activateLockdown(zoneName);
36    }
37
38    return { status: 'reported', breachCount: zone.breachCount };
39  }
40
41  async activateLockdown(zoneName) {
42    const zone = this.zones.get(zoneName);
43    zone.status = 'lockdown';
44    zone.fenceVoltage *= 2;
45
46    await this.alertService.sendAlert(
47      zoneName,
48      'LOCKDOWN ACTIVATED'
49    );
50
51    this.logs.push({
52      type: 'LOCKDOWN',
53      zone: zoneName,
54      timestamp: Date.now(),
55    });
56  }
57
58  getZoneStatus(zoneName) {
59    const zone = this.zones.get(zoneName);
60    return zone ? zone.status : null;
61  }
62}

Tak może wyglądać początek Twoich testów. Mock sendAlert od razu zwraca spełnioną obietnicę, a strefa dostaje napięcie 10000 i poziom zabezpieczeń 9:

1const alertService = {
2  sendAlert: jest.fn().mockResolvedValue({ status: 'sent' }),
3};
4const security = new SecuritySystem(alertService);
5security.registerZone('Raptor Pen', 10000, 9);

Po trzech wywołaniach await security.reportBreach('Raptor Pen') strefa powinna mieć status lockdown, a sendAlert powinien zostać wywołany cztery razy: trzy alerty o naruszeniu i jeden "LOCKDOWN ACTIVATED". Nieznana strefa to odrzucona obietnica, więc sprawdzisz ją przez rejects.toThrow('Zone not found').

Moduł 3: System monitoringu (asynchroniczny)

MonitoringService łączy obietnicę z timerem: checkSensor odpowiada po sekundzie, a startMonitoring co intervalMs milisekund przekazuje odczyty do callbacku przez setInterval:

1// monitoringService.js
2class MonitoringService {
3  constructor() {
4    this.sensors = [];
5    this.interval = null;
6  }
7
8  addSensor(zoneId, type) {
9    this.sensors.push({
10      zoneId,
11      type,
12      status: 'online',
13      lastReading: null,
14    });
15  }
16
17  async checkSensor(index) {
18    return new Promise((resolve, reject) => {
19      setTimeout(() => {
20        if (index < 0 || index >= this.sensors.length) {
21          reject(new Error('Sensor not found'));
22          return;
23        }
24
25        const sensor = this.sensors[index];
26        sensor.lastReading = Date.now();
27
28        resolve({
29          zoneId: sensor.zoneId,
30          type: sensor.type,
31          status: sensor.status,
32          reading: Math.random() * 100,
33        });
34      }, 1000);
35    });
36  }
37
38  startMonitoring(callback, intervalMs) {
39    this.interval = setInterval(() => {
40      const readings = this.sensors.map(s => ({
41        zone: s.zoneId,
42        status: s.status,
43      }));
44      callback(readings);
45    }, intervalMs);
46  }
47
48  stopMonitoring() {
49    if (this.interval) {
50      clearInterval(this.interval);
51      this.interval = null;
52    }
53  }
54}

Tu przydadzą się sztuczne zegary. Wywołaj checkSensor, przewiń czas o 1000 ms i dopiero wtedy poczekaj na obietnicę przez await. Bez przewinięcia obietnica nigdy by się nie spełniła, a test utknąłby do timeoutu. Pole reading pochodzi z Math.random(), więc sprawdzaj je przez expect.any(Number). Dla startMonitoring przewiń czas o kilka interwałów i policz wywołania callbacku przez toHaveBeenCalledTimes, a po stopMonitoring upewnij się, że kolejne przewinięcie niczego już nie dodaje.

Twoje zadanie

Napisz kompletny zestaw testów obejmujący:

  1. DinosaurManager - testy jednostkowe z beforeEach, testowanie wyjątków
  2. SecuritySystem - mockowanie alertService, testowanie async
  3. MonitoringService - fake timers, testowanie callbacków i Promise
  4. Testy integracyjne - współpraca między modułami

Pamiętaj o wzorcu AAA (Arrange-Act-Assert) i zasadach TDD! Test integracyjny może na przykład dodać raptora do strefy w DinosaurManager, zarejestrować tę samą strefę w SecuritySystem i sprawdzić, że po serii naruszeń raptor wciąż jest na liście, a strefa ma status lockdown.

Moja rada: zacznij od jednego describe na moduł i jednego testu na każdą gałąź if. Dopiero gdy wszystkie ścieżki są zielone, dopisuj przypadki brzegowe, takie jak karmienie dinozaura, którego nie ma w rejestrze. Gotowy projekt wyślij mentorowi w następnym zadaniu.

W laboratorium poniżej czekają uproszczone testy DinosaurManager i komentarze TODO dla dwóch pozostałych modułów. Potraktuj je jako rozgrzewkę przed prawdziwym zestawem w Jeście.

Pamiętaj: kompletny zestaw testów to sieć czujników całego parku - dopiero gdy każdy wybieg ma swój czujnik, możesz spać spokojnie.

Kod do tej lekcji: index.js
1// Projekt: Kompletny zestaw testow - Park Jurajski
2console.log("=== Park Jurajski - Kompletny System Testow ===\n");
3
4let passed = 0, failed = 0;
5function assert(cond, msg) {
6  if (cond) { passed++; console.log("    [PASS] " + msg); }
7  else { failed++; console.log("    [FAIL] " + msg); }
8}
9function assertThrows(fn, expected) {
10  try { fn(); failed++; console.log("    [FAIL] Expected throw"); }
11  catch (e) {
12    if (expected && !e.message.includes(expected)) {
13      failed++; console.log("    [FAIL] Wrong error: " + e.message);
14    } else { passed++; console.log("    [PASS] Threw: " + e.message); }
15  }
16}
17
18// =============================================
19// KOD PRODUKCYJNY
20// =============================================
21
22class DinosaurManager {
23  constructor() { this.dinosaurs = new Map(); this.nextId = 1; }
24
25  addDinosaur(name, species, diet, zone) {
26    if (!name || !species) throw new Error("Name and species are required");
27    const id = "DINO-" + String(this.nextId).padStart(3, "0");
28    this.nextId++;
29    const dino = { id, name, species, diet, zone, health: 100, fed: false, lastFed: null };
30    this.dinosaurs.set(id, dino);
31    return dino;
32  }
33
34  getDinosaur(id) { return this.dinosaurs.get(id) || null; }
35
36  feedDinosaur(id, food) {
37    const dino = this.getDinosaur(id);
38    if (!dino) throw new Error("Dinosaur not found");
39    const correct = { carnivore: "meat", herbivore: "plants", omnivore: ["meat", "plants"] };
40    const expected = correct[dino.diet];
41    const ok = Array.isArray(expected) ? expected.includes(food) : expected === food;
42    if (!ok) return { success: false, message: "Wrong food type" };
43    dino.health = Math.min(100, dino.health + 10);
44    dino.fed = true;
45    dino.lastFed = new Date();
46    return { success: true, health: dino.health };
47  }
48
49  getByZone(zone) { return [...this.dinosaurs.values()].filter(d => d.zone === zone); }
50  getByDiet(diet) { return [...this.dinosaurs.values()].filter(d => d.diet === diet); }
51  getTotalCount() { return this.dinosaurs.size; }
52}
53
54// TODO: Napisz testy dla SecuritySystem i MonitoringService
55// Uzyj wzorca AAA, mockowania i fake timerow
56
57// =============================================
58// TESTY
59// =============================================
60
61console.log("Suite: DinosaurManager\n");
62
63// beforeEach equivalent
64function setup() { return new DinosaurManager(); }
65
66console.log("  describe: addDinosaur");
67let mgr = setup();
68let rex = mgr.addDinosaur("Rex", "T-Rex", "carnivore", "Zone A");
69assert(rex.id === "DINO-001", "first dino has ID DINO-001");
70assert(rex.name === "Rex", "has correct name");
71assert(rex.health === 100, "starts with 100 health");
72assert(rex.fed === false, "starts not fed");
73
74mgr = setup();
75assertThrows(() => mgr.addDinosaur("", "T-Rex", "carnivore", "A"), "Name and species");
76assertThrows(() => mgr.addDinosaur("Rex", "", "carnivore", "A"), "Name and species");
77
78console.log("\n  describe: feedDinosaur");
79mgr = setup();
80rex = mgr.addDinosaur("Rex", "T-Rex", "carnivore", "Zone A");
81let feedResult = mgr.feedDinosaur(rex.id, "meat");
82assert(feedResult.success === true, "carnivore eats meat");
83assert(rex.fed === true, "marked as fed");
84assert(rex.health === 100, "health capped at 100");
85
86mgr = setup();
87rex = mgr.addDinosaur("Rex", "T-Rex", "carnivore", "A");
88feedResult = mgr.feedDinosaur(rex.id, "plants");
89assert(feedResult.success === false, "carnivore rejects plants");
90assert(feedResult.message === "Wrong food type", "correct error message");
91
92mgr = setup();
93assertThrows(() => mgr.feedDinosaur("FAKE-ID", "meat"), "not found");
94
95console.log("\n  describe: queries");
96mgr = setup();
97mgr.addDinosaur("Rex", "T-Rex", "carnivore", "Zone A");
98mgr.addDinosaur("Blue", "Velociraptor", "carnivore", "Zone A");
99mgr.addDinosaur("Brachio", "Brachiosaurus", "herbivore", "Zone B");
100
101assert(mgr.getTotalCount() === 3, "total count is 3");
102assert(mgr.getByZone("Zone A").length === 2, "2 in Zone A");
103assert(mgr.getByZone("Zone B").length === 1, "1 in Zone B");
104assert(mgr.getByDiet("carnivore").length === 2, "2 carnivores");
105assert(mgr.getByDiet("herbivore").length === 1, "1 herbivore");
106assert(mgr.getDinosaur("FAKE") === null, "null for missing dino");
107
108// TODO: Dodaj wlasne testy ponizej!
109// - Przetestuj SecuritySystem z mockowanym alertService
110// - Przetestuj MonitoringService z fake timerami
111// - Napisz testy integracyjne
112
113console.log(`\n=== Wyniki: ${passed} passed, ${failed} failed ===`);
114console.log("\nDodaj wiecej testow w edytorze powyzej!");

Widzisz błąd w tej lekcji?

Przydatne artykuły