Kurs JavaScript i React · Moduł 13: Testowanie React

Snapshot Testing - Zdjęcie Konstelacji

7 min czytania
W tej lekcji8

Wyobraź sobie, że po każdej zmianie w kodzie musisz ręcznie sprawdzać, czy plakietka statusu misji nadal ma te same klasy, tekst i strukturę. Przy dziesiątkach komponentów nikt tego nie zrobi. Snapshot testing robi to za Ciebie: "fotografuje" wynik renderowania komponentu i porównuje go z zapisanym wzorcem. To jak zdjęcie konstelacji gwiazd - jeśli przy następnym spojrzeniu któraś gwiazda się przesunęła, test to wykryje.

Jak działają snapshoty?

Mechanizm jest prosty. Przy pierwszym uruchomieniu testu Jest renderuje komponent i zapisuje wynikowy HTML do pliku .snap w folderze __snapshots__, obok pliku testu. Przy kolejnych uruchomieniach Jest ponownie renderuje komponent i porównuje wynik z zapisanym wzorcem. Jeśli coś się zmieniło, test nie przechodzi. To jak porównywanie dwóch zdjęć konstelacji zrobionych w odstępie czasu - jeśli gwiazdy się przesunęły, od razu to zauważysz.

Oto podstawowy przykład. Komponent renderujemy przez render z React Testing Library, a do toMatchSnapshot() przekazujemy pierwszy element z kontenera:

1import { render } from '@testing-library/react';
2import StatusBadge from './StatusBadge';
3
4test('renders active status badge', () => {
5  const { container } = render(<StatusBadge status="active" />);
6  expect(container.firstChild).toMatchSnapshot();
7});

container to element div, do którego RTL wstawia komponent, a container.firstChild to korzeń samego komponentu, czyli tutaj znacznik span. Zamiennie możesz napisać expect(asFragment()).toMatchSnapshot(): funkcja asFragment z wyniku render zwraca cały wyrenderowany fragment, opakowany w <DocumentFragment>.

W starszych poradnikach zobaczysz snapshoty robione przez react-test-renderer (renderer.create(...).toJSON()). W React 19 ten pakiet jest przestarzały i wypisuje ostrzeżenie, a zespół React poleca zamiast niego React Testing Library. Dlatego w tym kursie snapshot zawsze powstaje z wyniku render.

Wygenerowany plik snapshot (.snap)

Po pierwszym uruchomieniu w folderze __snapshots__ pojawi się plik o nazwie pliku testu z końcówką .snap. Jest zapisuje w nim każdy snapshot pod nazwą testu i numerem kolejnym:

1// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
2
3exports[`renders active status badge 1`] = `
4<span
5  class="badge badge-active"
6>
7  Active
8</span>
9`;

Plik .snap dodajesz do repozytorium razem z testem, bo to on jest wzorcem, z którym porówna się każde kolejne uruchomienie. Zauważ, że zapisany jest gotowy HTML z atrybutem class, a nie JSX z className.

Snapshoty inline

Zamiast osobnego pliku snapshot może być zapisany bezpośrednio w teście. Weźmy wskaźnik paliwa, który składa napis w jeden tekst:

1function FuelDisplay({ level }) {
2  return (
3    <div className="fuel-display">
4      <span>{`Fuel: ${level}%`}</span>
5    </div>
6  );
7}

Napis budujemy szablonem, bo zapis Fuel: {level}% dałby w snapshocie trzy osobne linie: każdy fragment tekstu w JSX to osobny węzeł. Test z toMatchInlineSnapshot wygląda tak:

1test('renders fuel display', () => {
2  const { container } = render(<FuelDisplay level={75} />);
3  expect(container.firstChild).toMatchInlineSnapshot(`
4    <div
5      class="fuel-display"
6    >
7      <span>
8        Fuel: 75%
9      </span>
10    </div>
11  `);
12});

Przy pierwszym uruchomieniu wystarczy napisać samo toMatchInlineSnapshot(), a Jest wpisze wzorzec do pliku testu za Ciebie. Snapshot inline jest wygodny przy małych komponentach, bo wzorzec widać od razu obok testu.

Aktualizowanie snapshotów

Gdy celowo zmieniasz komponent, stary wzorzec przestaje pasować i test pada. Wtedy aktualizujesz snapshoty flagą --updateSnapshot (skrót -u). Podwójny myślnik przekazuje tę flagę przez npm test dalej, do Jesta:

1# Aktualizacja wszystkich snapshotów
2npm test -- --updateSnapshot
3
4# Lub skrótowo
5npm test -- -u

Zanim zaktualizujesz, przeczytaj różnicę, którą pokazał Jest. Aktualizacja nadpisuje wzorzec bez pytania, więc jeśli zmiana była błędem, właśnie go zatwierdzisz.

Kiedy używać snapshotów?

Dobre przypadki użycia

  • Komponenty prezentacyjne - proste komponenty bez logiki
  • Wynikowe drzewo JSX - sprawdzenie struktury HTML
  • Dane serializowalne - obiekty, tablice, JSON

Karta planety to wzorcowy kandydat: dostaje propsy i tylko je wyświetla, więc ten sam zestaw propsów zawsze daje ten sam HTML:

1// Dobry snapshot: prosty komponent
2test('renders planet card', () => {
3  const { container } = render(
4    <PlanetCard name="Mars" distance="225M km" color="red" />
5  );
6  expect(container.firstChild).toMatchSnapshot();
7});

Złe przypadki użycia

  • Komponenty z dynamiczną logiką - snapshot nie testuje zachowania
  • Duże komponenty - snapshoty stają się trudne do przeglądania
  • Komponenty z datami/losowymi ID - snapshot zawsze się zmienia

Najczęstszy błąd to snapshot całego ekranu. Plik ma wtedy setki linii i przy każdej drobnej zmianie ktoś musi je przejrzeć:

1// ZŁY snapshot: za duży, zbyt dynamiczny
2test('renders entire dashboard', () => {
3  const { container } = render(<Dashboard />); // 500 linii HTML!
4  expect(container).toMatchSnapshot(); // Nikt tego nie przeczyta!
5});

Taki test pada przy każdej zmianie i nie mówi, co się zepsuło, więc szybko zaczyna się go aktualizować bez czytania.

Snapshot czy celowane asercje?

To fundamentalne porównanie, które każdy programista React powinien rozumieć. Snapshot sprawdza całą strukturę komponentu - każdy tag, klasę i atrybut. Celowana asercja (ang. targeted assertion) sprawdza konkretne zachowanie - czy tekst jest widoczny, czy przycisk jest aktywny, czy element ma odpowiednią klasę. Różnica jest jak między zrobieniem zdjęcia całego panelu sterowania a sprawdzeniem, czy konkretna kontrolka świeci na zielono.

1// Snapshot: sprawdza CAŁĄ strukturę
2expect(container.firstChild).toMatchSnapshot();
3
4// Celowana asercja: sprawdza KONKRETNE zachowanie (zalecane!)
5expect(screen.getByText('Active')).toHaveClass('badge-active');
6expect(screen.getByRole('button')).toBeEnabled();

Celowana asercja mówi też wprost o stanie, którego snapshot nie wyjaśni. W tabeli z sortowaniem nagłówek kolumny przekazuje czytnikom ekranu kierunek sortowania atrybutem aria-sort, a test sprawdza go jednym zdaniem:

1expect(screen.getByRole('columnheader', { name: /nazwa/i }))
2  .toHaveAttribute('aria-sort', 'ascending');

Snapshot też zapisałby aria-sort, ale zginąłby wśród setek innych atrybutów. Celowana asercja nazywa zachowanie, które chronisz, a jej komunikat błędu od razu mówi, co się zepsuło.

Zasada: Preferuj celowane asercje nad snapshoty. Snapshoty łatwo stają się "niewidoczne" - programiści często aktualizują je automatycznie komendą -u bez sprawdzania, co się zmieniło. Celowane asercje natomiast wymuszają świadome sprawdzenie konkretnego zachowania. Snapshoty są dobre jako dodatkowe zabezpieczenie przed nieoczekiwanymi zmianami w strukturze HTML, ale nie powinny być głównym sposobem testowania. Pamiętaj też, że snapshot DOM nie widzi stylów CSS, więc zmiany wyglądu wyłapują dopiero testy porównujące zrzuty ekranu.

Dane, które zmieniają się przy każdym uruchomieniu

Losowe identyfikatory i bieżąca data sprawiają, że snapshot jest inny przy każdym uruchomieniu. Dla obiektów Jest ma na to dopasowania właściwości (property matchers): w miejscu zmiennych pól podajesz typ zamiast wartości. Funkcja createMissionLog tworzy wpis dziennika z losowym id i datą utworzenia:

1test('creates a mission log', () => {
2  const log = createMissionLog('Artemis III');
3
4  expect(log).toMatchSnapshot({
5    id: expect.any(String),
6    createdAt: expect.any(Date),
7  });
8});

W pliku .snap zamiast konkretnych wartości pojawią się Any<String> i Any<Date>, a pozostałe pola, jak nazwa misji, nadal są porównywane dokładnie. W komponencie, który pokazuje dzisiejszą datę, zamroź zegar przez jest.useFakeTimers() i jest.setSystemTime(new Date('2030-07-20')), żeby każde uruchomienie widziało ten sam dzień.

Drugim narzędziem są serializery, które decydują, jak wartość zostanie zapisana w pliku .snap. Poniższy zamienia numery statków w napisach na stały znacznik:

1// W pliku jest.setup.js
2expect.addSnapshotSerializer({
3  test: (val) => typeof val === 'string' && /^ship-\d+$/.test(val),
4  print: () => '"ship-<id>"',
5});

Funkcja test wybiera wartości, którymi serializer się zajmie, a print zwraca ich zapis. Uważaj na zbyt szerokie test: serializer, który przejmie każdy element z klasą CSS, zapisze zamiast całej struktury komponentu jedną linijkę i snapshot przestanie czegokolwiek pilnować.

Snapshot wybranego fragmentu

Nie musisz fotografować całego komponentu. Gdy pilnujesz tylko jednej części, na przykład listy linków w nawigacji, zrób snapshot samego tego elementu:

1test('renders navigation with correct structure', () => {
2  const routes = [
3    { path: '/mars', label: 'Mars' },
4    { path: '/europa', label: 'Europa' },
5  ];
6  render(<SpaceNavigation routes={routes} />);
7
8  // Snapshot tylko części komponentu
9  expect(screen.getByRole('navigation')).toMatchSnapshot();
10});

getByRole('navigation') znajduje element <nav> tak, jak widzi go czytnik ekranu, więc snapshot obejmuje tylko menu. Mniejszy wzorzec łatwiej przejrzeć, a zmiany w reszcie komponentu go nie ruszą.

Podsumowanie - kiedy snapshot, kiedy nie?

SytuacjaSnapshotCelowana asercja
Prosty komponent UITakTak
Logika biznesowaNieTak
Interakcje użytkownikaNieTak
Formatowanie danychTakTak
Duży komponentNieTak
Niezamierzona zmiana struktury HTMLTakNie

Spójrz też na drogę, którą przeszedłeś w tym module. Zaczęliśmy od testu czystej funkcji, potem był test renderowania komponentu, test interakcji użytkownika i test komponentu asynchronicznego z mockowanym API. Najwyżej stoi test integracyjny, który sprawdza kilka komponentów naraz - zbudujesz go w projekcie głównym. Snapshot jest dodatkiem do każdego z tych poziomów, a nie ich zamiennikiem.

Moja rada: snapshot traktuj jak zdjęcie kontrolne, a nie jak dowód poprawności, i każdą różnicę przeczytaj, zanim wpiszesz -u. Pamiętaj: snapshot pilnuje, żeby konstelacja się nie przesunęła, ale to celowane asercje mówią, czy statek leci we właściwą stronę.

Kod do tej lekcji: App.jsx
1import React, { useState, useRef, useLayoutEffect } from 'react';
2
3// Komponenty prezentacyjne: dostają propsy i tylko je wyświetlają,
4// dlatego dobrze nadają się do snapshotów
5
6const STATUS_LABELS = {
7  active: 'aktywna',
8  completed: 'zakończona',
9  planned: 'planowana',
10  critical: 'krytyczna',
11};
12
13function StatusBadge({ status, outlined = false }) {
14  const className = `status-badge badge-${status}${outlined ? ' outlined' : ''}`;
15  return <span className={className}>{STATUS_LABELS[status].toUpperCase()}</span>;
16}
17
18function PlanetCard({ name, distance, diameter, moons }) {
19  return (
20    <div className="planet-card">
21      <div className="planet-icon">{name[0]}</div>
22      <div className="planet-info">
23        <h3>{name}</h3>
24        <div className="planet-stats">
25          <span>Odległość: {distance}</span>
26          <span>Średnica: {diameter}</span>
27          <span>Księżyce: {moons}</span>
28        </div>
29      </div>
30    </div>
31  );
32}
33
34function CrewTable({ members }) {
35  return (
36    <table className="crew-table">
37      <thead>
38        <tr>
39          <th>Imię</th>
40          <th>Rola</th>
41          <th>Status misji</th>
42        </tr>
43      </thead>
44      <tbody>
45        {members.map(m => (
46          <tr key={m.name}>
47            <td>{m.name}</td>
48            <td>{m.role}</td>
49            <td><StatusBadge status={m.status} /></td>
50          </tr>
51        ))}
52      </tbody>
53    </table>
54  );
55}
56
57// Laboratorium: pierwszy test zapisuje wzorzec, kolejne go porównują
58function SnapshotLab() {
59  const [outlined, setOutlined] = useState(false);
60  const [snap, setSnap] = useState(null);
61  const [markup, setMarkup] = useState('');
62  const [result, setResult] = useState('Test nie był jeszcze uruchamiany.');
63  const previewRef = useRef(null);
64
65  useLayoutEffect(() => {
66    setMarkup(previewRef.current.innerHTML);
67  }, [outlined]);
68
69  const runTest = () => {
70    if (snap === null) {
71      setSnap(markup);
72      setResult('Pierwsze uruchomienie: Jest zapisał wzorzec w pliku .snap.');
73    } else if (snap === markup) {
74      setResult('PASS: wynik zgodny z zapisanym snapshotem.');
75    } else {
76      setResult('FAIL: wynik różni się od snapshotu. Błąd czy celowa zmiana?');
77    }
78  };
79
80  const updateSnapshot = () => {
81    setSnap(markup);
82    setResult('Snapshot zaktualizowany (npm test -- -u).');
83  };
84
85  return (
86    <div className="section">
87      <h2>Laboratorium snapshotów</h2>
88      <div ref={previewRef} className="lab-preview">
89        <StatusBadge status="active" outlined={outlined} />
90      </div>
91      <pre className="markup">{markup}</pre>
92      <div className="btn-row">
93        <button onClick={runTest}>Uruchom test</button>
94        <button onClick={() => setOutlined(o => !o)}>Zmień kod komponentu</button>
95        <button onClick={updateSnapshot} disabled={snap === null}>Zaktualizuj snapshot</button>
96      </div>
97      <p className={result.startsWith('FAIL') ? 'lab-result fail' : 'lab-result'} role="status">
98        {result}
99      </p>
100    </div>
101  );
102}
103
104export default function App() {
105  const planets = [
106    { name: 'Mars', distance: '225 mln km', diameter: '6779 km', moons: 2 },
107    { name: 'Jowisz', distance: '778 mln km', diameter: '139 820 km', moons: 95 },
108    { name: 'Saturn', distance: '1,4 mld km', diameter: '116 460 km', moons: 146 },
109  ];
110
111  const crew = [
112    { name: 'Komandor Nova', role: 'kapitan', status: 'active' },
113    { name: 'Astro', role: 'pilot', status: 'planned' },
114    { name: 'Inżynier Bolt', role: 'inżynier', status: 'completed' },
115  ];
116
117  return (
118    <div className="app">
119      <h1>Snapshot testing</h1>
120      <p className="subtitle">Zdjęcie konstelacji: porównanie struktury komponentu</p>
121
122      <SnapshotLab />
123
124      <div className="section">
125        <h2>Plakietki statusu</h2>
126        <div className="badge-row">
127          <StatusBadge status="active" />
128          <StatusBadge status="completed" />
129          <StatusBadge status="planned" />
130          <StatusBadge status="critical" />
131        </div>
132      </div>
133
134      <div className="section">
135        <h2>Karty planet</h2>
136        {planets.map(p => <PlanetCard key={p.name} {...p} />)}
137      </div>
138
139      <div className="section">
140        <h2>Tabela załogi</h2>
141        <CrewTable members={crew} />
142      </div>
143
144      <div className="hint">
145        Snapshot sprawdza się przy prostych komponentach prezentacyjnych, takich jak powyższe.
146        Zachowanie (kliknięcia, dane z API) sprawdzaj celowanymi asercjami.
147      </div>
148    </div>
149  );
150}

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. Kiedy snapshot testing jest NAJLEPSZYM wyborem?

  2. 2. Jaka jest główna filozofia React Testing Library?

Zadania praktyczne w grze

  • Układanie w pionie

    Ułóż cykl życia snapshot testu od pierwszego uruchomienia:

  • Układanie w poziomie

    Ułóż składnię snapshot testu w Jest:

  • Klikanie w kolejności

    Ułóż komendę do aktualizacji snapshotów w npm:

  • Układanie w pionie

    Ułóż selektory RTL od najlepszego (zalecanego) do ostateczności:

  • Układanie w poziomie

    Ułóż składnię użycia waitFor do oczekiwania na element:

  • Klikanie w kolejności

    Ułóż składnię włączenia fake timerów i przewinięcia czasu w teście:

  • Układanie w pionie

    Ułóż typy testów od najprostszych do najbardziej złożonych:

  • Klikanie w kolejności

    Ułóż składnię aktualizacji stanu hooka w teście:

  • Edytor kodu

    Dokończ tabelę DataTable katalogu planet. Nagłówki kolumn to przyciski w th, a th ma aria-sort ('ascending', 'descending' albo 'none'). ___BLANK1___: przed sortowaniem zrób kopię tablicy rows, bo sort() zmienia tablicę, na której go wywołasz, a props nie wolno zmieniać. ___BLANK2___: drugie kliknięcie tego samego nagłówka zmienia kierunek na 'descending' (kolejne znów na 'ascending'). ___BLANK3___: gdy tablica rows jest pusta, zamiast tabeli widać komunikat z role="status". Test klika nagłówki, czyta kolejność komórek wierszy (role row i cell) i sprawdza aria-sort.

  • Układanie w pionie

    Uporządkuj kroki przygotowania środowiska do testowania aplikacji React:

Przydatne artykuły