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

Test-Driven Development (TDD)

6 min czytania
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:

  1. Nie pisz kodu produkcyjnego bez czerwonego testu - zawsze najpierw test
  2. Napisz minimum testu - tyle, ile potrzeba do pokazania błędu
  3. Napisz minimum kodu - tyle, ile potrzeba do przejścia testu
  4. Refaktoruj często - ulepszaj kod przy zielonych testach
  5. 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. 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.

Przydatne artykuły