Kurs JavaScript i TypeScript · Moduł 11: Testowanie z Jest
Test-Driven Development (TDD)
W tej lekcji4
Zwykle najpierw piszesz kod, a potem, jeśli starczy czasu, testy. Tyle że czasu rzadko starcza, a testy pisane po fakcie mają skłonność do potwierdzania tego, co kod już robi, łącznie z jego błędami. TDD odwraca tę kolejność.
TDD to metodologia tworzenia oprogramowania, w której najpierw piszesz testy, a dopiero potem kod produkcyjny. W Parku Jurajskim to jak najpierw zaprojektowanie wszystkich systemów bezpieczeństwa, a dopiero potem sprowadzenie dinozaurów. Metodę spopularyzował Kent Beck, autor książki Test-Driven Development: By Example.
Cykl Red-Green-Refactor
TDD opiera się na trzech powtarzających się krokach. Kolory pochodzą z raportu testów: czerwony oznacza alarm, a zielony, że wszystko działa.
1. RED - Napisz test, który nie przechodzi
Zaczynamy od testu dla funkcji, która jeszcze nie istnieje. Opisujemy w nim, jak chcemy jej używać: createFence ma przyjąć strefę i napięcie, a zwrócić aktywne ogrodzenie:
1// KROK 1: Piszemy test PRZED kodem produkcyjnym
2describe('FenceSystem', () => {
3 it('should create a fence with voltage', () => {
4 const fence = createFence('Zone A', 10000);
5
6 expect(fence.zone).toBe('Zone A');
7 expect(fence.voltage).toBe(10000);
8 expect(fence.isActive).toBe(true);
9 });
10});
11// Wynik: RED - createFence nie istnieje!Test pada z błędem ReferenceError, bo createFence nie istnieje, i to jest w porządku: porażka z powodu brakującej funkcji też liczy się jako czerwony stan. Ważne, by zobaczyć czerwień na własne oczy. Test, który nigdy nie był czerwony, może nie sprawdzać niczego.
2. GREEN - Napisz minimum kodu, żeby test przeszedł
Teraz piszemy najprostszy kod, który zadowoli test, bez żadnego zapasu na przyszłość ani funkcji "na wszelki wypadek":
1// KROK 2: Piszemy najprostszy kod, który przejdzie test
2function createFence(zone, voltage) {
3 return {
4 zone,
5 voltage,
6 isActive: true
7 };
8}
9// Wynik: GREEN - test przechodzi!Implementacja jest wręcz naiwna, ale test przechodzi. Na tym etapie nie liczy się elegancja, tylko zielone światło.
3. REFACTOR - Popraw kod zachowując zielone testy
Mając zielony test jako siatkę bezpieczeństwa, możemy przebudować kod. Zamieniamy literał obiektu na klasę Fence, a createFence staje się cienką fabryką:
1// KROK 3: Ulepszamy kod (testy nadal zielone!)
2class Fence {
3 constructor(zone, voltage) {
4 this.zone = zone;
5 this.voltage = voltage;
6 this.isActive = true;
7 }
8
9 getStatus() {
10 return this.isActive ? 'ACTIVE' : 'OFFLINE';
11 }
12}
13
14function createFence(zone, voltage) {
15 return new Fence(zone, voltage);
16}
17// Wynik: GREEN - testy nadal przechodzą po refaktorze!Test nie zmienił się ani o znak i nadal przechodzi, bo refaktor zmienia strukturę kodu, a nie jego zachowanie. Uczciwie przyznam jednak, że metoda getStatus przemknęła tu bez testu. W czystym TDD refaktor nie dodaje nowych funkcji: na getStatus powinien najpierw przyjść osobny czerwony test.
Praktyczny przykład TDD
Zbudujmy system karmienia dinozaurów krok po kroku. Każda iteracja dokłada jeden test i tylko tyle kodu, ile ten test wymusza.
Iteracja 1: Podstawowe karmienie
Pierwszy test opisuje najprostszy przypadek: T-Rex dostaje mięso i karmienie się udaje. DinoFeeder to klasa, której metoda feed przyjmuje gatunek i pokarm:
1// RED: Piszemy test
2test('carnivore eats meat', () => {
3 const feeder = new DinoFeeder();
4 const result = feeder.feed('T-Rex', 'meat');
5 expect(result.success).toBe(true);
6});Teraz najprostsza implementacja, która zamienia czerwień w zieleń. Metoda feed na razie w ogóle nie patrzy na argumenty:
1// GREEN: Minimalna implementacja
2class DinoFeeder {
3 feed(species, food) {
4 return { success: true };
5 }
6}
7// Test przechodzi - ale implementacja jest zbyt prosta!Zwracanie zawsze success: true wygląda jak oszustwo, ale to celowe minimum. Kolejne testy zmuszą implementację, żeby zmądrzała, i właśnie o to chodzi w TDD.
Iteracja 2: Walidacja diety
Drugi test zamyka lukę: mięsożerca nie może przyjąć roślin, a wynik ma podać powód odmowy:
1// RED: Dodajemy test dla niepoprawnego jedzenia
2test('carnivore rejects plants', () => {
3 const feeder = new DinoFeeder();
4 const result = feeder.feed('T-Rex', 'plants');
5 expect(result.success).toBe(false);
6 expect(result.reason).toBe('Wrong diet');
7});Żeby go spełnić, klasa potrzebuje mapy diet dla gatunków. Konstruktor zapisuje ją w this.diets, a feed sprawdza, czy pokarm pasuje do diety:
1// GREEN: Rozszerzamy implementację
2class DinoFeeder {
3 constructor() {
4 this.diets = {
5 'T-Rex': 'carnivore',
6 'Triceratops': 'herbivore',
7 'Velociraptor': 'carnivore',
8 };
9 }
10
11 feed(species, food) {
12 const diet = this.diets[species];
13
14 if (diet === 'carnivore' && food !== 'meat') {
15 return { success: false, reason: 'Wrong diet' };
16 }
17 if (diet === 'herbivore' && food !== 'plants') {
18 return { success: false, reason: 'Wrong diet' };
19 }
20
21 return { success: true };
22 }
23}Oba testy są teraz zielone. Pierwszy nadal chroni pierwszy scenariusz: gdyby nowa walidacja zepsuła karmienie mięsem, od razu byśmy się o tym dowiedzieli.
Iteracja 3: Nieznany gatunek
Trzeci test sprawdza wyjątek, więc wywołanie opakowujemy w funkcję strzałkową, tak jak przy matcherze toThrow:
1// RED: Test dla nieznanego gatunku
2test('unknown species throws error', () => {
3 const feeder = new DinoFeeder();
4 expect(() => feeder.feed('Unknown', 'meat')).toThrow('Unknown species');
5});Implementacja dopisuje jeden warunek na początku feed. Komentarz "poprzedni kod" oznacza konstruktor z mapą diet z iteracji 2, który się nie zmienia:
1// GREEN: Dodajemy obsługę błędów
2class DinoFeeder {
3 // ... poprzedni kod ...
4
5 feed(species, food) {
6 const diet = this.diets[species];
7
8 if (!diet) {
9 throw new Error('Unknown species');
10 }
11
12 if (diet === 'carnivore' && food !== 'meat') {
13 return { success: false, reason: 'Wrong diet' };
14 }
15 if (diet === 'herbivore' && food !== 'plants') {
16 return { success: false, reason: 'Wrong diet' };
17 }
18
19 return { success: true };
20 }
21}Po trzech iteracjach dwa bliźniacze warunki dla carnivore i herbivore aż proszą się o refaktor, na przykład o mapę "dieta - dozwolony pokarm". Trzy zielone testy dają odwagę, by go przeprowadzić.
Zasady TDD
Cykl opiera się na kilku zasadach. Trzy pierwsze to w skrócie tak zwane trzy prawa TDD sformułowane przez Roberta C. Martina:
- Nie pisz kodu produkcyjnego bez czerwonego testu - zawsze najpierw test
- Napisz minimum testu - tyle, ile potrzeba do pokazania błędu
- Napisz minimum kodu - tyle, ile potrzeba do przejścia testu
- Refaktoruj często - ulepszaj kod przy zielonych testach
- Małe kroki - każda iteracja dodaje jedną małą funkcjonalność
Korzyści TDD
Co zyskujesz, pracując w tym rytmie? Zebraliśmy to w obiekcie, jak przystało na laboratorium parku:
1// TDD prowadzi do:
2const tddBenefits = {
3 betterDesign: 'Kod jest projektowany pod testy = lepszy design',
4 lessDebugging: 'Błędy wyłapywane natychmiast',
5 documentation: 'Testy dokumentują zachowanie kodu',
6 confidence: 'Pewność przy zmianach i refaktorze',
7 coverage: 'Bardzo wysokie pokrycie kodu testami (ale nie gwarancja 100%)'
8};Uwaga na ostatnią pozycję: TDD zwykle daje bardzo wysokie pokrycie, bo każda linijka powstaje w odpowiedzi na test, ale nie gwarantuje 100%, a samo pokrycie nie świadczy jeszcze o jakości testów. Moja rada: stosuj TDD tam, gdzie wiesz, jaki ma być wynik, czyli w logice biznesowej, walidacji i obliczeniach. Przy szybkich prototypach sztywny cykl potrafi spowalniać.
W ćwiczeniach po tej lekcji zbudujesz metodą TDD system biletów i harmonogram karmienia, a w następnej lekcji przeniesiesz te nawyki do TypeScriptu. W laboratorium poniżej prześledzisz symulację kolejnych iteracji.
Pamiętaj: w TDD najpierw budujesz ogrodzenie, a dopiero potem wpuszczasz dinozaura - czerwony test to projekt, zielony to gotowy wybieg.
Kod do tej lekcji: index.js
1// TDD - Test-Driven Development
2console.log("=== Park Jurajski - Protokoly Bezpieczenstwa TDD ===\n");
3
4// Symulacja TDD
5let testsPassed = 0, testsFailed = 0;
6
7function assert(condition, msg) {
8 if (condition) {
9 console.log(" [PASS] " + msg);
10 testsPassed++;
11 } else {
12 console.log(" [FAIL] " + msg);
13 testsFailed++;
14 }
15}
16
17function assertThrows(fn, expectedMsg) {
18 try {
19 fn();
20 console.log(" [FAIL] Expected throw: " + expectedMsg);
21 testsFailed++;
22 } catch (e) {
23 if (e.message === expectedMsg) {
24 console.log(" [PASS] Threw: " + e.message);
25 testsPassed++;
26 } else {
27 console.log(" [FAIL] Wrong error: " + e.message + " (expected: " + expectedMsg + ")");
28 testsFailed++;
29 }
30 }
31}
32
33// --- Cykl Red-Green-Refactor ---
34console.log("--- Cykl TDD: Red -> Green -> Refactor ---\n");
35console.log("1. RED - Napisz test ktory FAILUJE");
36console.log("2. GREEN - Napisz minimum kodu zeby PRZESZEDL");
37console.log("3. REFACTOR - Ulepsz kod (testy nadal zielone)\n");
38
39// --- Iteracja 1: Tworzenie ogrodzenia ---
40console.log("=== Iteracja 1: Tworzenie ogrodzenia ===\n");
41
42// RED -> GREEN
43function createFence(zone, voltage) {
44 return {
45 zone,
46 voltage,
47 isActive: true,
48 getStatus() { return this.isActive ? "ACTIVE" : "OFFLINE"; }
49 };
50}
51
52assert(createFence("Zone A", 10000).zone === "Zone A", "fence has zone");
53assert(createFence("Zone A", 10000).voltage === 10000, "fence has voltage");
54assert(createFence("Zone A", 10000).isActive === true, "fence is active");
55
56// --- Iteracja 2: DinoFeeder ---
57console.log("\n=== Iteracja 2: System Karmienia (TDD) ===\n");
58
59class DinoFeeder {
60 constructor() {
61 this.diets = {
62 "T-Rex": "carnivore",
63 "Triceratops": "herbivore",
64 "Velociraptor": "carnivore",
65 "Gallimimus": "omnivore",
66 };
67 this.feedLog = [];
68 }
69
70 feed(species, food) {
71 const diet = this.diets[species];
72
73 if (!diet) {
74 throw new Error("Unknown species");
75 }
76
77 if (diet === "carnivore" && food !== "meat") {
78 return { success: false, reason: "Wrong diet" };
79 }
80 if (diet === "herbivore" && food !== "plants") {
81 return { success: false, reason: "Wrong diet" };
82 }
83
84 this.feedLog.push({ species, food, time: Date.now() });
85 return { success: true };
86 }
87
88 getFeedCount() {
89 return this.feedLog.length;
90 }
91}
92
93// Test 1: Karnienie miesozercow
94console.log("Test: Karmienie miesozercow");
95const feeder = new DinoFeeder();
96const result1 = feeder.feed("T-Rex", "meat");
97assert(result1.success === true, "T-Rex eats meat");
98
99// Test 2: Odrzucenie zlego jedzenia
100console.log("\nTest: Odrzucenie zlego jedzenia");
101const result2 = feeder.feed("T-Rex", "plants");
102assert(result2.success === false, "T-Rex rejects plants");
103assert(result2.reason === "Wrong diet", "reason is Wrong diet");
104
105// Test 3: Roslinozerce
106console.log("\nTest: Karmienie roslinozercow");
107const result3 = feeder.feed("Triceratops", "plants");
108assert(result3.success === true, "Triceratops eats plants");
109
110// Test 4: Nieznany gatunek
111console.log("\nTest: Nieznany gatunek");
112assertThrows(() => feeder.feed("Unknown", "meat"), "Unknown species");
113
114// Test 5: Licznik karmienia
115console.log("\nTest: Licznik karmienia");
116assert(feeder.getFeedCount() === 2, "2 successful feedings logged");
117
118// --- Podsumowanie TDD ---
119console.log("\n=== Zasady TDD ===");
120console.log("1. NIE pisz kodu bez czerwonego testu");
121console.log("2. Pisz minimum testu (jeden assert)");
122console.log("3. Pisz minimum kodu (zeby przeszlo)");
123console.log("4. Refaktoruj czesto");
124console.log("5. Male kroki - jedna funkcjonalnosc na iteracje");
125
126console.log(`\n=== Wyniki: ${testsPassed} passed, ${testsFailed} failed ===`);Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Jaka jest prawidłowa kolejność kroków w cyklu TDD?
Zadania praktyczne w grze
- Układanie w pionie
Ułóż kroki jednej iteracji TDD w prawidłowej kolejności:
- Edytor kodu
Napisz testy dla systemu biletów parku, a następnie implementację spełniającą testy.
- Klikanie w kolejności
Ułóż elementy jednej iteracji TDD:
- Układanie w poziomie
Ułóż elementy testu sprawdzającego wyjątek:
- Edytor kodu
Zbuduj FeedingScheduler iteracyjnie: test -> kod -> refaktor.