Kurs JavaScript i TypeScript · Moduł 10: TypeScript w praktyce

Test-Driven Development (TDD) - Ewolucja kodu przez testy

8 min czytania
W tej lekcji6

Witaj w świecie Test-Driven Development! Wyobraź sobie, że zmieniasz jedną linijkę w systemie ogrodzeń i nie wiesz, czy przy okazji nie wyłączyłeś alarmu na wybiegu Velociraptorów. Podobnie jak naukowcy w Parku Jurajskim musieli najpierw zrozumieć DNA dinozaurów, zanim mogli je sklonować, TDD wymaga najpierw zdefiniowania oczekiwanego zachowania (testu), a dopiero potem napisania implementacji.

Czym jest TDD?

Test-Driven Development to technika programowania, w której najpierw piszemy test, który początkowo nie przechodzi, następnie minimalny kod potrzebny do jego zaliczenia, a na końcu refaktoryzujemy kod, zachowując przechodzące testy. Technikę spopularyzował Kent Beck na przełomie XX i XXI wieku.

Cykl Red-Green-Refactor

Ten rytm powtarza się w kółko, a każdy obrót powinien być krótki, najlepiej kilkuminutowy:

1    ┌──────────────────────────────────────┐
2    │                                      │
3    │   RED                             │
4    │   Napisz test, który nie przechodzi  │
5    │                                      │
6    └───────────────┬──────────────────────┘
7                    │
8                    ▼
9    ┌──────────────────────────────────────┐
10    │                                      │
11    │   GREEN                           │
12    │   Napisz minimalny kod do           │
13    │   zaliczenia testu                   │
14    │                                      │
15    └───────────────┬──────────────────────┘
16                    │
17                    ▼
18    ┌──────────────────────────────────────┐
19    │                                      │
20    │   REFACTOR                        │
21    │   Ulepsz kod zachowując             │
22    │   przechodzące testy                 │
23    │                                      │
24    └───────────────┬──────────────────────┘
25                    │
26                    └──────────► Powtórz cykl

Czerwony test udowadnia, że test w ogóle potrafi coś wykryć. Zielony oznacza spełnione wymaganie, a refaktoryzacja poprawia kod bez zmiany zachowania.

TDD w praktyce - przykład z Parku Jurajskiego

Wyobraź sobie, że budujesz system monitorowania dinozaurów. Zaczynamy od testu.

Krok 1: RED - Piszemy test

Test w Vitest opisuje zachowanie w blokach describe i it, a komentarze pokazują wzorzec AAA: Arrange (przygotuj), Act (wykonaj), Assert (sprawdź):

1// dinosaur.test.js
2import { describe, it, expect } from 'vitest';
3import { Dinosaur } from './dinosaur';
4
5describe('Dinosaur', () => {
6  describe('constructor', () => {
7    it('should create a dinosaur with name and species', () => {
8      // Arrange & Act
9      const dino = new Dinosaur('Rex', 'Tyrannosaurus');
10
11      // Assert
12      expect(dino.name).toBe('Rex');
13      expect(dino.species).toBe('Tyrannosaurus');
14    });
15  });
16
17  describe('isDangerous', () => {
18    it('should return true for carnivore dinosaurs', () => {
19      const dino = new Dinosaur('Rex', 'Tyrannosaurus', 'carnivore');
20      expect(dino.isDangerous()).toBe(true);
21    });
22
23    it('should return false for herbivore dinosaurs', () => {
24      const dino = new Dinosaur('Steggy', 'Stegosaurus', 'herbivore');
25      expect(dino.isDangerous()).toBe(false);
26    });
27  });
28});

Ten test teraz nie przechodzi i to dobrze, bo nie mamy jeszcze klasy Dinosaur, więc raport świeci na czerwono.

Krok 2: GREEN - Minimalny kod

Piszemy najprostszy kod, który spełni testy, bez zapasów na przyszłość:

1// dinosaur.js
2export class Dinosaur {
3  constructor(name, species, diet = 'unknown') {
4    this.name = name;
5    this.species = species;
6    this.diet = diet;
7  }
8
9  isDangerous() {
10    return this.diet === 'carnivore';
11  }
12}

Teraz testy przechodzą. Kod wygląda skromnie, ale robi dokładnie to, co opisują testy, i nic więcej.

Krok 3: REFACTOR - Ulepszamy kod

Z zieloną siatką bezpieczeństwa porządkujemy kod: pola prywatne #, gettery i stała z listą niebezpiecznych diet:

1// dinosaur.js - po refaktoryzacji
2const DANGEROUS_DIETS = ['carnivore', 'omnivore'];
3
4export class Dinosaur {
5  #name;
6  #species;
7  #diet;
8
9  constructor(name, species, diet = 'unknown') {
10    this.#validateInput(name, species);
11    this.#name = name;
12    this.#species = species;
13    this.#diet = diet;
14  }
15
16  get name() { return this.#name; }
17  get species() { return this.#species; }
18  get diet() { return this.#diet; }
19
20  #validateInput(name, species) {
21    if (!name || !species) {
22      throw new Error('Name and species are required');
23    }
24  }
25
26  isDangerous() {
27    return DANGEROUS_DIETS.includes(this.#diet);
28  }
29}

Uwaga: to nie jest czysta refaktoryzacja. Ta wersja uznaje wszystkożerców za groźnych i rzuca błąd przy braku nazwy, czyli zmienia zachowanie. W prawdziwym TDD każda z tych reguł zaczyna się od nowego, czerwonego testu, a refaktoryzacja zmienia wyłącznie strukturę kodu.

Zaawansowane TDD - System Bezpieczeństwa Parku

Teraz zbudujemy bardziej złożony system, zaczynając od pełnego zestawu testów.

Test Suite dla SecuritySystem

beforeEach tworzy świeży system przed każdym testem, więc testy nie wpływają na siebie, a vi.fn() tworzy funkcję szpiegującą (spy), która zapamiętuje swoje wywołania:

1// security-system.test.js
2import { describe, it, expect, beforeEach, vi } from 'vitest';
3import { SecuritySystem } from './security-system';
4import { Enclosure } from './enclosure';
5import { Dinosaur } from './dinosaur';
6
7describe('SecuritySystem', () => {
8  let securitySystem;
9  let mockEnclosure;
10  let mockDinosaur;
11
12  beforeEach(() => {
13    mockDinosaur = new Dinosaur('Rex', 'Tyrannosaurus', 'carnivore');
14    mockEnclosure = new Enclosure('Paddock 1', [mockDinosaur], {
15      fenceVoltage: 10000,
16      isActive: true
17    });
18    securitySystem = new SecuritySystem();
19    securitySystem.addEnclosure(mockEnclosure);
20  });
21
22  describe('addEnclosure', () => {
23    it('should add an enclosure to monitoring', () => {
24      const newEnclosure = new Enclosure('Paddock 2', [], { fenceVoltage: 5000 });
25      securitySystem.addEnclosure(newEnclosure);
26
27      expect(securitySystem.getEnclosures()).toHaveLength(2);
28    });
29
30    it('should throw error for duplicate enclosure names', () => {
31      const duplicate = new Enclosure('Paddock 1', [], { fenceVoltage: 5000 });
32
33      expect(() => securitySystem.addEnclosure(duplicate))
34        .toThrow('Enclosure with this name already exists');
35    });
36  });
37
38  describe('checkStatus', () => {
39    it('should return SAFE when all fences are active', () => {
40      expect(securitySystem.checkStatus()).toBe('SAFE');
41    });
42
43    it('should return DANGER when any fence is inactive', () => {
44      mockEnclosure.deactivateFence();
45
46      expect(securitySystem.checkStatus()).toBe('DANGER');
47    });
48
49    it('should return CRITICAL when dangerous dino escapes', () => {
50      mockEnclosure.deactivateFence();
51      mockEnclosure.markEscaped(mockDinosaur);
52
53      expect(securitySystem.checkStatus()).toBe('CRITICAL');
54    });
55  });
56
57  describe('getAlerts', () => {
58    it('should return empty array when everything is safe', () => {
59      expect(securitySystem.getAlerts()).toEqual([]);
60    });
61
62    it('should generate alert when fence voltage drops', () => {
63      mockEnclosure.setFenceVoltage(1000);
64
65      const alerts = securitySystem.getAlerts();
66
67      expect(alerts).toHaveLength(1);
68      expect(alerts[0]).toMatchObject({
69        type: 'LOW_VOLTAGE',
70        enclosure: 'Paddock 1',
71        severity: 'WARNING'
72      });
73    });
74  });
75
76  describe('emergencyProtocol', () => {
77    it('should trigger alarms for all affected zones', async () => {
78      const alarmSpy = vi.fn();
79      securitySystem.onAlarm(alarmSpy);
80
81      await securitySystem.emergencyProtocol('Paddock 1');
82
83      expect(alarmSpy).toHaveBeenCalledWith({
84        type: 'EMERGENCY',
85        enclosure: 'Paddock 1',
86        actions: ['LOCKDOWN', 'EVACUATE', 'ALERT_SECURITY']
87      });
88    });
89  });
90});

Testy mówią językiem parku: duplikat wybiegu to błąd, wyłączone ogrodzenie to stan DANGER, a uciekinier drapieżnik to CRITICAL.

Implementacja po TDD

Implementacja powstaje test po teście, aż wszystkie zaświecą się na zielono:

1// security-system.js
2export class SecuritySystem {
3  #enclosures = new Map();
4  #alarmCallbacks = [];
5
6  addEnclosure(enclosure) {
7    if (this.#enclosures.has(enclosure.name)) {
8      throw new Error('Enclosure with this name already exists');
9    }
10    this.#enclosures.set(enclosure.name, enclosure);
11  }
12
13  getEnclosures() {
14    return Array.from(this.#enclosures.values());
15  }
16
17  checkStatus() {
18    const enclosures = this.getEnclosures();
19
20    for (const enclosure of enclosures) {
21      const escaped = enclosure.getEscapedDinosaurs();
22      if (escaped.some(dino => dino.isDangerous())) {
23        return 'CRITICAL';
24      }
25    }
26
27    for (const enclosure of enclosures) {
28      if (!enclosure.isFenceActive()) {
29        return 'DANGER';
30      }
31    }
32
33    return 'SAFE';
34  }
35
36  getAlerts() {
37    const alerts = [];
38    const MINIMUM_SAFE_VOLTAGE = 5000;
39
40    for (const enclosure of this.getEnclosures()) {
41      if (enclosure.getFenceVoltage() < MINIMUM_SAFE_VOLTAGE) {
42        alerts.push({
43          type: 'LOW_VOLTAGE',
44          enclosure: enclosure.name,
45          severity: 'WARNING',
46          currentVoltage: enclosure.getFenceVoltage()
47        });
48      }
49    }
50
51    return alerts;
52  }
53
54  onAlarm(callback) {
55    this.#alarmCallbacks.push(callback);
56  }
57
58  async emergencyProtocol(enclosureName) {
59    const alarmData = {
60      type: 'EMERGENCY',
61      enclosure: enclosureName,
62      actions: ['LOCKDOWN', 'EVACUATE', 'ALERT_SECURITY']
63    };
64
65    for (const callback of this.#alarmCallbacks) {
66      await callback(alarmData);
67    }
68  }
69}

Kolejność w checkStatus nie jest przypadkowa: najpierw ucieczka, potem ogrodzenia, bo test CRITICAL wyłącza ogrodzenie i oznacza ucieczkę jednocześnie. Klasy Enclosure tu nie widać, ale jej interfejs wynika wprost z testów.

Korzyści z TDD

Pewność zmian

Najważniejsza korzyść to siatka bezpieczeństwa przy każdej zmianie:

1// Z TDD masz siatkę bezpieczeństwa: jeśli testy przechodzą, sprawdzone zachowania nadal działają

Testy chronią tylko to, co sprawdzają. Zielony wynik nie dowodzi braku błędów, ale mówi, że opisane zachowania nadal działają.

Dokumentacja w kodzie

Dobrze nazwane testy czyta się jak specyfikację parku, bez zaglądania do implementacji:

1describe('DinosaurFeeder', () => {
2  it('should feed herbivores plants only', () => {
3    // Ten test jasno dokumentuje oczekiwane zachowanie
4  });
5
6  it('should not feed carnivores to each other', () => {
7    // Reguła biznesowa zapisana w teście!
8  });
9});

Opisy w it to reguły biznesowe, które nie zdezaktualizują się po cichu jak zwykła dokumentacja, bo test przestanie przechodzić.

Lepszy design - TDD wymusza loose coupling

Klasę, która sama tworzy bazę i mailer, trudno przetestować, więc TDD naturalnie prowadzi do wstrzykiwania zależności przez konstruktor:

1// TDD prowadzi do Dependency Injection
2class GoodParkManager {
3  constructor(database, mailer) {
4    this.database = database;
5    this.mailer = mailer;
6  }
7}
8
9// Łatwo testować z mockami:
10const mockDb = { query: vi.fn() };
11const manager = new GoodParkManager(mockDb, mockMailer);

W teście podajesz atrapy (mocki), w produkcji prawdziwe obiekty, a klasa nie widzi różnicy. To zasada odwrócenia zależności z lekcji o SOLID.

TDD Anti-patterns - czego unikać

Testy też bywają złe. Porównaj dwie pary przykładów:

1// ANTI-PATTERN: Test zbyt ogólny
2it('should work', () => {
3  expect(doSomething()).toBeTruthy();
4});
5
6// POPRAWNIE: Test konkretny
7it('should return dinosaur count for specific species', () => {
8  expect(countDinosaurs('Velociraptor')).toBe(8);
9});
10
11// ANTI-PATTERN: Testowanie implementacji zamiast zachowania
12it('should call database.query', () => {
13  service.getDinosaurs();
14  expect(database.query).toHaveBeenCalled();
15});
16
17// POPRAWNIE: Testowanie zachowania
18it('should return all dinosaurs from paddock', () => {
19  const result = service.getDinosaurs();
20  expect(result).toHaveLength(5);
21});

Test sprawdzający wywołanie database.query pęka przy każdej zmianie implementacji, choć zachowanie jest poprawne. Testuj wynik, a nie sposób jego uzyskania.

Narzędzia do TDD

Vitest - nowoczesny framework

Vitest to szybki runner zgodny z API Jesta, naturalny wybór w projektach na Vite. Progi pokrycia kodu ustawiasz w polu coverage.thresholds:

1// vitest.config.js
2import { defineConfig } from 'vitest/config';
3
4export default defineConfig({
5  test: {
6    globals: true,
7    environment: 'node',
8    coverage: {
9      provider: 'v8',
10      thresholds: { statements: 80, branches: 80, functions: 80, lines: 80 }
11    }
12  }
13});

Uruchomienie testów z pokryciem zakończy się błędem, gdy wynik spadnie poniżej 80%. Traktuj ten próg jako alarm, a nie cel sam w sobie.

Jest - popularny framework

W Jest ten sam próg zapisujesz w polu coverageThreshold:

1// jest.config.js
2module.exports = {
3  testEnvironment: 'node',
4  coverageThreshold: {
5    global: { statements: 80, branches: 80, functions: 80, lines: 80 }
6  }
7};

Oba narzędzia mają podobne API (describe, it, expect), więc przesiadka jest łatwa. W nowych projektach polecam Vitest, a w istniejących zostań przy Jest, jeśli dobrze działa.

TDD to potężna technika, która zmienia sposób myślenia o kodzie. Zamiast "najpierw pisz, potem testuj" przyjmujesz podejście "najpierw zdefiniuj oczekiwania, potem implementuj". W kolejnej lekcji połączysz TypeScript z popularnymi frameworkami.

Pamiętaj: test to ogrodzenie postawione przed przywiezieniem dinozaura, a nie po jego ucieczce.

Kod do tej lekcji: index.js
1// SharedArrayBuffer i Atomics - współdzielona pamięć między wątkami
2console.log("Park Jurajski - System Współdzielonej Pamięci DNA");
3console.log("Symulacja SharedArrayBuffer i Atomics\n");
4
5// ====================================
6// 1. SHAREDARRAY BUFFER - PODSTAWY
7// ====================================
8console.log("=== 1. SharedArrayBuffer ===\n");
9
10// Tworzenie SharedArrayBuffer (16 bajtów = 4 x Int32)
11const sharedBuffer = new SharedArrayBuffer(16);
12const sharedView = new Int32Array(sharedBuffer);
13
14console.log("SharedArrayBuffer utworzony:");
15console.log("  Rozmiar:", sharedBuffer.byteLength, "bajtów");
16console.log("  Elementy Int32:", sharedView.length);
17
18// Inicjalizacja wartości
19sharedView[0] = 100; // Liczba dinozaurów
20sharedView[1] = 85;  // Poziom bezpieczeństwa
21sharedView[2] = 0;   // Licznik alertów
22sharedView[3] = 1;   // Status systemu (1 = aktywny)
23
24console.log("\nPoczątkowe wartości:");
25console.log("  Dinozaury:", sharedView[0]);
26console.log("  Bezpieczeństwo:", sharedView[1], "%");
27console.log("  Alerty:", sharedView[2]);
28console.log("  Status:", sharedView[3] === 1 ? "AKTYWNY" : "NIEAKTYWNY");
29
30// ====================================
31// 2. ATOMICS - OPERACJE ATOMOWE
32// ====================================
33console.log("\n=== 2. Operacje Atomics ===\n");
34
35// Atomics.add - dodanie wartości atomowo
36const previousAlerts = Atomics.add(sharedView, 2, 1);
37console.log("Atomics.add(alerts, 1):");
38console.log("  Poprzednia wartość:", previousAlerts);
39console.log("  Nowa wartość:", Atomics.load(sharedView, 2));
40
41// Atomics.sub - odjęcie wartości atomowo
42const previousSafety = Atomics.sub(sharedView, 1, 10);
43console.log("\nAtomics.sub(safety, 10):");
44console.log("  Poprzednia wartość:", previousSafety);
45console.log("  Nowa wartość:", Atomics.load(sharedView, 1));
46
47// Atomics.exchange - zamiana wartości
48const oldStatus = Atomics.exchange(sharedView, 3, 0);
49console.log("\nAtomics.exchange(status, 0):");
50console.log("  Stara wartość:", oldStatus);
51console.log("  Nowa wartość:", Atomics.load(sharedView, 3));
52
53// Atomics.compareExchange - warunkowa zamiana
54const result = Atomics.compareExchange(sharedView, 3, 0, 1);
55console.log("\nAtomics.compareExchange(status, expected=0, new=1):");
56console.log("  Wynik (poprzednia):", result);
57console.log("  Aktualna wartość:", Atomics.load(sharedView, 3));
58
59// ====================================
60// 3. SYMULACJA KOMUNIKACJI WATKOW
61// ====================================
62console.log("\n=== 3. Symulacja komunikacji wątków ===\n");
63
64// Symulacja producent-konsument z Atomics
65class DinoMonitorSystem {
66  constructor() {
67    this.buffer = new SharedArrayBuffer(32);
68    this.data = new Int32Array(this.buffer);
69    // Indeksy: 0=lock, 1=dinoCount, 2=alertCount, 3=safetyLevel
70  }
71
72  // Symulacja mutex z Atomics
73  acquireLock() {
74    while (Atomics.compareExchange(this.data, 0, 0, 1) !== 0) {
75      // Spin-wait (w prawdziwym kodzie: Atomics.wait)
76    }
77    console.log("  Lock acquired");
78  }
79
80  releaseLock() {
81    Atomics.store(this.data, 0, 0);
82    console.log("  Lock released");
83  }
84
85  // Dodaj dinozaura (bezpiecznie)
86  addDinosaur() {
87    this.acquireLock();
88    const current = Atomics.load(this.data, 1);
89    Atomics.store(this.data, 1, current + 1);
90    console.log(`  Dinozaur dodany. Łącznie: ${current + 1}`);
91    this.releaseLock();
92  }
93
94  // Zgłoś alert (bezpiecznie)
95  reportAlert() {
96    // Atomics.add jest sam w sobie atomowy - nie potrzeba locka
97    const prev = Atomics.add(this.data, 2, 1);
98    console.log(`  Alert #${prev + 1} zgłoszony`);
99  }
100
101  getStatus() {
102    return {
103      dinosaurs: Atomics.load(this.data, 1),
104      alerts: Atomics.load(this.data, 2),
105      safety: Atomics.load(this.data, 3)
106    };
107  }
108}
109
110const monitor = new DinoMonitorSystem();
111
112console.log("Symulacja operacji z wielu wątków:");
113monitor.addDinosaur();
114monitor.addDinosaur();
115monitor.reportAlert();
116monitor.addDinosaur();
117monitor.reportAlert();
118
119console.log("\nStan systemu:", monitor.getStatus());
120
121// ====================================
122// 4. POROWNANIE Z ZWYKLYM BUFOREM
123// ====================================
124console.log("\n=== 4. SharedArrayBuffer vs ArrayBuffer ===\n");
125
126console.log("ArrayBuffer:");
127console.log("  - Kopiowany między wątkami (structured clone)");
128console.log("  - Bezpieczny ale wolniejszy");
129console.log("  - Brak problemów z race conditions");
130
131console.log("\nSharedArrayBuffer:");
132console.log("  - Współdzielony między wątkami (zero-copy)");
133console.log("  - Szybszy ale wymaga synchronizacji");
134console.log("  - Wymaga Atomics do bezpiecznego dostępu");
135console.log("  - Wymaga nagłówków COOP/COEP");
136
137console.log("\nKiedy używać SharedArrayBuffer:");
138console.log("- Intensywne obliczenia numeryczne");
139console.log("- Przetwarzanie dużych zbiorów danych");
140console.log("- Aplikacje real-time (gry, symulacje)");
141console.log("- Współdzielenie stanu między Web Workers");

Widzisz błąd w tej lekcji?

Przydatne artykuły