Kurs JavaScript i React · Moduł 13: Testowanie React
Snapshot Testing - Zdjęcie Konstelacji
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 -- -uZanim 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?
| Sytuacja | Snapshot | Celowana asercja |
|---|---|---|
| Prosty komponent UI | Tak | Tak |
| Logika biznesowa | Nie | Tak |
| Interakcje użytkownika | Nie | Tak |
| Formatowanie danych | Tak | Tak |
| Duży komponent | Nie | Tak |
| Niezamierzona zmiana struktury HTML | Tak | Nie |
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. Kiedy snapshot testing jest NAJLEPSZYM wyborem?
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: