Kurs JavaScript i TypeScript · Moduł 10: TypeScript w praktyce
Test-Driven Development (TDD) - Ewolucja kodu przez testy
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 cyklCzerwony 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?