Kurs JavaScript i React · Moduł 13: Testowanie React
React Testing Library - Panel Kontroli Misji
W tej lekcji9
Wyobraź sobie test, który sprawdza, czy w stanie komponentu zmienna isOpen ma wartość true. Zmieniasz nazwę zmiennej na expanded, aplikacja działa identycznie, a test pada. Taki test pilnuje szczegółów implementacji, a nie tego, co widzi pilot. Po kilku refaktoryzacjach zespół przestaje ufać testom, bo czerwone światło zapala się bez powodu.
React Testing Library (RTL) to biblioteka, która zmienia sposób, w jaki testujemy komponenty React. Zamiast testować wewnętrzną implementację, RTL skupia się na tym, co użytkownik widzi i robi - dokładnie jak panel kontroli misji, który pokazuje pilotowi tylko to, co istotne.
Główna zasada RTL
"Im bardziej Twoje testy przypominają sposób, w jaki oprogramowanie jest używane, tym więcej pewności Ci dają." - Kent C. Dodds
Z tej zasady wynika cała reszta: szukamy elementów tak jak użytkownik, po roli, etykiecie i tekście, a nie po klasach CSS czy nazwach zmiennych.
Renderowanie komponentów
Funkcja render to podstawa RTL - montuje komponent w dokumencie jsdom, czyli implementacji DOM działającej w Node.js (to nie jest wirtualny DOM Reacta). Obiekt screen daje dostęp do zapytań na całym dokumencie:
1import { render, screen } from '@testing-library/react';
2import SpaceshipDashboard from './SpaceshipDashboard';
3
4test('renders spaceship dashboard', () => {
5 render(<SpaceshipDashboard pilotName="Nova" />);
6 // Komponent jest teraz dostępny do testowania
7 expect(screen.getByText('Welcome, Nova!')).toBeInTheDocument();
8});Po render komponent jest w dokumencie, a screen.getByText szuka go tak, jak czytałby go użytkownik. Nie musisz po sobie sprzątać: RTL po każdym teście automatycznie wywołuje cleanup, czyli odmontowuje komponenty, gdy test runner udostępnia globalną funkcję afterEach. Jest robi to zawsze, a Vitest po włączeniu opcji globals: true. Dzięki temu każdy test zaczyna z pustym dokumentem i nie widzi pozostałości poprzednich.
Zapytania (queries) - Wyszukiwanie Elementów
RTL oferuje trzy rodziny zapytań, każda do innego celu. Różnią się zachowaniem, gdy elementu nie ma.
getBy - Szuka i wymaga znalezienia
Używaj getBy, gdy element musi istnieć. Zapytanie rzuca błąd, jeśli go nie ma, a także wtedy, gdy pasuje więcej niż jeden element:
1// Rzuca błąd, jeśli element NIE istnieje
2const heading = screen.getByText('Mission Control');
3const input = screen.getByRole('textbox');
4const img = screen.getByAltText('spaceship');Dla wielu elementów istnieje wariant getAllBy, który zwraca tablicę. Razem z queryAllBy i within poznasz go bliżej w dalszej części lekcji.
queryBy - Szuka, ale nie wymaga
queryBy przydaje się do sprawdzania, że czegoś nie ma na ekranie:
1// Zwraca null, jeśli element NIE istnieje (nie rzuca błędu)
2const error = screen.queryByText('Error occurred');
3expect(error).not.toBeInTheDocument();Gdybyś użył tu getByText, test padłby na samym wyszukiwaniu, zanim dotarłby do asercji.
findBy - Szuka asynchronicznie
findBy czeka, aż element się pojawi, więc zwraca Promise i wymaga await:
1// Czeka na pojawienie się elementu (zwraca Promise)
2const data = await screen.findByText('Data loaded');
3expect(data).toBeInTheDocument();Pod spodem to połączenie getBy z waitFor. Domyślnie czeka do 1000 ms, a potem zgłasza błąd.
Rodzaje selektorów
Każda rodzina zapytań ma warianty. Nazwa zapytania to rodzina plus sposób szukania, np. getByRole czy findByText.
ByRole - Najlepsza praktyka!
Rola ARIA to to, czym element jest dla czytnika ekranu: przycisk, nagłówek, pole tekstowe. Opcja name odpowiada dostępnej nazwie, czyli najczęściej tekstowi lub etykiecie:
1// Szuka po roli ARIA - najlepszy sposób!
2screen.getByRole('button', { name: 'Launch' });
3screen.getByRole('heading', { level: 1 });
4screen.getByRole('textbox', { name: /pilot name/i });
5screen.getByRole('checkbox', { checked: true });
6screen.getByRole('link', { name: 'Dashboard' });Jeśli getByRole nie znajduje przycisku, często oznacza to problem z dostępnością w samym komponencie, a nie w teście. Żeby korzystać z tego zapytania świadomie, musisz wiedzieć dwie rzeczy: skąd element ma rolę i skąd ma nazwę.
Role wbudowane w znaczniki
Test ma znaleźć listę misji, a w kodzie komponentu nie ma żadnego atrybutu role? Nie musi go być. Większość znaczników HTML ma rolę domyślną (niejawną), którą przeglądarka i RTL odczytują same:
| Znacznik | Rola |
|---|---|
<button> | button |
<a href="..."> | link |
<h1> do <h6> | heading (opcja level to numer nagłówka) |
<input type="text">, <textarea> | textbox |
<input type="checkbox"> | checkbox |
<select> | combobox |
<ul> lub <ol> oraz <li> | list oraz listitem |
<table>, <tr>, <th>, <td> | table, row, columnheader, cell |
Dwie pułapki warto zapamiętać. Link bez atrybutu href nie ma roli link, a lista rozwijana <select> jest dla czytnika ekranu polem combobox, więc szukasz jej przez getByRole('combobox'), a nie "select".
Role nadawane atrybutem role
Wskaźnik paliwa, komunikat ostrzegawczy czy zakładki budujesz zwykle z elementów div i p, które nie mają własnej roli. Wtedy nadajesz ją jawnie atrybutem role, a stan opisujesz atrybutami aria-*:
1function ShieldMeter({ level }) {
2 return (
3 <div>
4 <div
5 role="progressbar"
6 aria-label="Shield level"
7 aria-valuenow={level}
8 aria-valuemin={0}
9 aria-valuemax={100}
10 />
11 {level < 20 && <p role="alert">Shields critical!</p>}
12 </div>
13 );
14}role="progressbar" zamienia zwykły div we wskaźnik postępu: aria-valuenow, aria-valuemin i aria-valuemax podają jego wartość i zakres, a aria-label nadaje mu nazwę. role="alert" oznacza pilny komunikat, który czytnik ekranu przeczyta od razu. Spokojniejsze informacje, np. "Skanowanie zakończone", dostają role="status", a zakładki trzy role naraz: tablist na kontenerze, tab na każdym przycisku i tabpanel na treści.
Test sprawdza wskaźnik i ostrzeżenie tak, jak odczytałby je czytnik ekranu:
1test('warns when shields are critical', () => {
2 render(<ShieldMeter level={15} />);
3
4 const meter = screen.getByRole('progressbar', { name: 'Shield level' });
5 expect(meter).toHaveAttribute('aria-valuenow', '15');
6 expect(screen.getByRole('alert')).toHaveTextContent('Shields critical!');
7});Zauważ, że komunikatu nie szukamy opcją name. Role alert i status nie biorą nazwy z treści, więc tekst sprawdza matcher toHaveTextContent, a toHaveAttribute porównuje wartość atrybutu. Oba pochodzą z jest-dom, o którym za chwilę.
Skąd element ma nazwę
Opcja name porównuje dostępną nazwę elementu, czyli to, co czytnik ekranu przeczyta razem z rolą. Nazwa pochodzi najczęściej z jednego z trzech źródeł:
1// 1. Z treści elementu: przyciski, linki, nagłówki
2// <button>Launch</button>
3screen.getByRole('button', { name: 'Launch' });
4
5// 2. Z aria-label, gdy element nie ma czytelnego tekstu
6// <button aria-label="Close panel">×</button>
7screen.getByRole('button', { name: 'Close panel' });
8
9// 3. Z etykiety powiązanej przez htmlFor i id...
10// <label htmlFor="pilot">Pilot name</label>
11// <input id="pilot" type="text" />
12screen.getByRole('textbox', { name: 'Pilot name' });
13
14// ...albo z etykiety, która otacza pole
15// <label><input type="checkbox" /> Autopilot</label>
16screen.getByRole('checkbox', { name: 'Autopilot' });Dla RTL pole bez etykiety ma pustą nazwę, nawet jeśli ma placeholder, więc zapytanie z opcją name go nie znajdzie. To dobra wiadomość: test od razu wskazuje brak etykiety, a placeholder znika po wpisaniu tekstu, więc etykiety nie zastąpi.
Filtrowanie po stanie: checked, pressed, selected
Gdy na ekranie jest kilka elementów tej samej roli, możesz wybrać ten w określonym stanie:
1// Zaznaczone pole wyboru (checkbox albo radio)
2screen.getByRole('checkbox', { name: 'Autopilot', checked: true });
3
4// Wciśnięty przełącznik: <button aria-pressed="true">Orbit</button>
5screen.getByRole('button', { pressed: true });
6
7// Wybrana zakładka albo wybrana opcja listy rozwijanej
8screen.getByRole('tab', { selected: true });
9screen.getByRole('option', { name: 'Mars', selected: true });checked czyta stan pola wyboru, pressed atrybut aria-pressed przycisków działających jak włącznik, a selected atrybut aria-selected zakładek albo zaznaczenie opcji w <select>. Gdy zapytanie z takim filtrem nie znajduje elementu, wiesz od razu, że komponent pokazuje inny stan, niż zakładał test.
ByText - Szuka po tekście
Tekst sprawdza się przy elementach nieinteraktywnych, jak komunikaty i akapity:
1screen.getByText('Mission Status: Active');
2screen.getByText(/mission/i); // regex, case-insensitiveZwykły napis musi pasować w całości, a wyrażenie regularne pozwala szukać fragmentu bez względu na wielkość liter.
ByLabelText - Szuka po etykiecie formularza
Pola formularza najlepiej znajdować po etykiecie, tak jak robi to użytkownik:
1// <label htmlFor="speed">Speed</label>
2// <input id="speed" />
3screen.getByLabelText('Speed');Zapytanie działa tylko wtedy, gdy label jest poprawnie powiązany z polem, więc test przy okazji pilnuje dostępności.
ByPlaceholderText - Szuka po placeholderze
Placeholder to podpowiedź w pustym polu:
1screen.getByPlaceholderText('Enter coordinates');Traktuj go jako zapasowy wybór, bo placeholder znika po wpisaniu tekstu i nie zastępuje etykiety.
ByTestId - Ostateczność
Atrybut data-testid to znacznik widoczny tylko dla testów:
1// <div data-testid="fuel-gauge">...</div>
2screen.getByTestId('fuel-gauge');Użytkownik go nie widzi, więc test oparty na nim mówi najmniej o prawdziwym doświadczeniu.
Priorytet selektorów
RTL zaleca następujący priorytet (od najlepszego):
- getByRole - dostępność, jak użytkownik widzi element
- getByLabelText - formularze
- getByPlaceholderText - gdy brak etykiety
- getByText - elementy nieinteraktywne
- getByDisplayValue - aktualna wartość pola
- getByAltText - obrazy
- getByTitle - atrybut title
- getByTestId - ostateczność, gdy nic innego nie pasuje
Wiele elementów i zapytania wewnątrz elementu
Lista misji ma kilka kart, tabela kilka wierszy, a powiadomienia często mają przyciski o tej samej nazwie. getBy rzuciłby tu błąd, bo pasuje więcej niż jeden element. Na takie sytuacje każda rodzina zapytań ma wariant All, a funkcja within zawęża szukanie do wnętrza jednego elementu. Załóżmy, że CrewTable pokazuje tabelę z wierszem nagłówka i jednym wierszem na każdego członka załogi:
1import { render, screen, within } from '@testing-library/react';
2
3test('lists the crew in a table', () => {
4 const crew = [
5 { name: 'Nova', role: 'Pilot' },
6 { name: 'Astro', role: 'Engineer' },
7 ];
8 render(<CrewTable crew={crew} />);
9
10 // getAllBy zwraca tablicę i rzuca błąd, gdy nic nie pasuje
11 const rows = screen.getAllByRole('row');
12 expect(rows).toHaveLength(3); // nagłówek i dwie osoby
13
14 // within ogranicza zapytania do wnętrza jednego wiersza
15 const cells = within(rows[1]).getAllByRole('cell');
16 expect(cells[0]).toHaveTextContent('Nova');
17
18 // queryAllBy zwraca pustą tablicę zamiast błędu
19 expect(screen.queryAllByRole('alert')).toHaveLength(0);
20});getAllBy (i jego asynchroniczny odpowiednik findAllBy) rzuca błąd, gdy nie znajdzie ani jednego elementu, a queryAllBy zwraca wtedy pustą tablicę, dlatego nadaje się do sprawdzenia, że elementy zniknęły. within(element) daje te same zapytania co screen, ale szuka tylko w środku wskazanego elementu. Tak samo znajdziesz przycisk zamknięcia w jednym konkretnym powiadomieniu: within(screen.getByRole('alert')).getByRole('button', { name: 'Close' }). Funkcję within importujesz z @testing-library/react, razem z render i screen.
Matchery jest-dom
Biblioteka @testing-library/jest-dom dodaje specjalne matchery. Podłączasz ją raz, importując @testing-library/jest-dom w pliku jest.setup.js, który w pierwszej lekcji wskazałeś w opcji setupFilesAfterEnv:
1// Widoczność
2expect(element).toBeVisible();
3expect(element).toBeInTheDocument();
4
5// Atrybuty i stan
6expect(button).toBeDisabled();
7expect(input).toBeRequired();
8expect(input).toHaveValue('Apollo');
9expect(checkbox).toBeChecked();
10expect(link).toHaveAttribute('href', '/dashboard');
11
12// Klasy CSS
13expect(element).toHaveClass('active');
14
15// Tekst
16expect(element).toHaveTextContent('Mission');
17
18// Style
19expect(element).toHaveStyle({ color: 'rgb(0, 128, 0)' });toBeInTheDocument sprawdza tylko obecność w dokumencie, a toBeVisible dodatkowo, czy element nie jest ukryty np. przez display: none. toHaveStyle porównuje styl obliczony przez jsdom, który zwraca kolory w zapisie rgb(...), dlatego kolor podajesz jako rgb(0, 128, 0) albo #008000: sama nazwa green nie przejdzie.
Renderowanie z kontekstem
Często komponenty potrzebują providerów (Router, Theme, Store). Zamiast powtarzać je w każdym teście, tworzymy własną funkcję renderującą z opcją wrapper:
1function renderWithProviders(ui, options = {}) {
2 function Wrapper({ children }) {
3 return (
4 <ThemeProvider theme="dark">
5 <MissionProvider>
6 {children}
7 </MissionProvider>
8 </ThemeProvider>
9 );
10 }
11 return render(ui, { wrapper: Wrapper, ...options });
12}
13
14// Użycie
15test('renders themed dashboard', () => {
16 renderWithProviders(<Dashboard />);
17 expect(screen.getByText('Dark Mode')).toBeInTheDocument();
18});renderWithProviders przyjmuje te same argumenty co render, więc testy wyglądają niemal identycznie, a konfiguracja siedzi w jednym miejscu.
Debug - kiedy testy nie przechodzą
Gdy test nie przechodzi i nie wiesz dlaczego, użyj screen.debug() do podejrzenia aktualnego stanu DOM:
1test('shows mission status', () => {
2 render(<MissionPanel />);
3
4 // Wyświetla aktualny stan DOM w konsoli
5 screen.debug();
6
7 // Możesz też debugować konkretny element
8 const panel = screen.getByRole('main');
9 screen.debug(panel);
10});screen.debug() wyświetli HTML całego wyrenderowanego komponentu, co pomaga zrozumieć, dlaczego selektor nie znajduje elementu. To jak system diagnostyczny na statku kosmicznym - gdy coś nie działa, najpierw sprawdzasz logi!
Moja rada: zaczynaj od getByRole i schodź niżej w priorytecie tylko wtedy, gdy musisz. W kolejnej lekcji dodamy do tego symulację kliknięć i pisania.
React Testing Library to fundament nowoczesnego testowania komponentów React. Zamiast testować detale implementacji, testujesz to, co naprawdę ważne - doświadczenie użytkownika.
Kod do tej lekcji: App.jsx
1import React, { useState } from 'react';
2
3const MISSIONS = [
4 { id: 1, name: 'Apollo 11', status: 'completed', crew: 3 },
5 { id: 2, name: 'Artemis I', status: 'active', crew: 0 },
6 { id: 3, name: 'Mars Explorer', status: 'planned', crew: 6 },
7 { id: 4, name: 'Jupiter Probe', status: 'active', crew: 4 },
8];
9
10const STATUS_LABELS = {
11 completed: 'zakończona',
12 active: 'aktywna',
13 planned: 'planowana',
14};
15
16// Zapytanie RTL, które znajdzie element powyżej w jego obecnym stanie
17function Query({ code }) {
18 return <code className="query">{code}</code>;
19}
20
21function MissionCard({ mission, selected, onSelect }) {
22 return (
23 <li className={'mission-card status-' + mission.status}>
24 <h3>{mission.name}</h3>
25 <p>Status: <span className="badge">{STATUS_LABELS[mission.status]}</span></p>
26 <p>Załoga: {mission.crew}</p>
27 <button
28 aria-label={'Wybierz misję ' + mission.name}
29 aria-pressed={selected}
30 onClick={onSelect}
31 >
32 Wybierz
33 </button>
34 </li>
35 );
36}
37
38function MissionList() {
39 const [query, setQuery] = useState('');
40 const [onlyActive, setOnlyActive] = useState(false);
41 const [selected, setSelected] = useState(null);
42
43 const visible = MISSIONS.filter(
44 (m) =>
45 m.name.toLowerCase().includes(query.toLowerCase()) &&
46 (!onlyActive || m.status === 'active')
47 );
48 const statusText = selected ? 'Wybrana misja: ' + selected : 'Nie wybrano misji';
49 const pressedVisible = visible.some((m) => m.name === selected);
50
51 return (
52 <div className="mission-list">
53 <div className="field">
54 <label htmlFor="search">Szukaj misji</label>
55 <input
56 id="search"
57 type="text"
58 value={query}
59 placeholder="Wpisz nazwę misji..."
60 onChange={(e) => setQuery(e.target.value)}
61 />
62 <Query code="screen.getByRole('textbox', { name: 'Szukaj misji' })" />
63 </div>
64
65 <div className="field">
66 <label className="checkbox">
67 <input
68 type="checkbox"
69 checked={onlyActive}
70 onChange={(e) => setOnlyActive(e.target.checked)}
71 />
72 Tylko aktywne
73 </label>
74 <Query code={"screen.getByRole('checkbox', { name: 'Tylko aktywne', checked: " + onlyActive + " })"} />
75 </div>
76
77 <p role="status" className="selected-info">{statusText}</p>
78 <Query code={"expect(screen.getByRole('status')).toHaveTextContent('" + statusText + "')"} />
79 <Query
80 code={pressedVisible
81 ? "screen.getByRole('button', { name: 'Wybierz misję " + selected + "', pressed: true })"
82 : "expect(screen.queryByRole('button', { pressed: true })).toBeNull()"}
83 />
84
85 {visible.length > 0 ? (
86 <>
87 <Query code={"expect(screen.getAllByRole('listitem')).toHaveLength(" + visible.length + ")"} />
88 <ul className="cards">
89 {visible.map((m) => (
90 <MissionCard
91 key={m.id}
92 mission={m}
93 selected={selected === m.name}
94 onSelect={() => setSelected(m.name)}
95 />
96 ))}
97 </ul>
98 </>
99 ) : (
100 <>
101 <p className="no-results">Nie znaleziono misji</p>
102 <Query code="expect(screen.queryAllByRole('listitem')).toHaveLength(0)" />
103 </>
104 )}
105 </div>
106 );
107}
108
109export default function App() {
110 return (
111 <div className="app">
112 <h1>React Testing Library - Panel kontroli</h1>
113 <p className="note">
114 Podgląd nie uruchamia Jest. Pod elementami widzisz zapytania RTL, które
115 znajdą je w obecnym stanie: wpisz nazwę, zaznacz pole albo wybierz misję
116 i patrz, jak zmieniają się opcje checked i pressed.
117 </p>
118 <MissionList />
119 </div>
120 );
121}Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Co robi funkcja cleanup() w React Testing Library i kiedy jest automatycznie wywoływana?
2. Jaka jest kluczowa różnica między getBy a queryBy w React Testing Library?
To 2 z 4 pytań do tej lekcji. Pozostałe rozwiążesz w grze.
Zadania praktyczne w grze
- Edytor kodu
Dokończ wskaźnik paliwa tak, żeby test znalazł go tak jak użytkownik czytnika ekranu. ___BLANK1___: isLow jest prawdą, gdy poziom paliwa jest mniejszy niż 20. ___BLANK2___: pasek ma rolę progressbar, więc test znajdzie go przez getByRole('progressbar', { name: 'Poziom paliwa' }) (nazwę daje aria-label, a poziom aria-valuenow). ___BLANK3___: ostrzeżenie o niskim poziomie ma rolę alert, którą czytnik ekranu ogłasza od razu. ___BLANK4___: przycisk Zatankuj dodaje 25 punktów procentowych, ale poziom nie może przekroczyć 100.
- Układanie w poziomie
Ułóż poprawną składnię wyszukiwania przycisku Submit za pomocą getByRole:
- Edytor kodu
Dokończ formularz rekrutacji załogi CrewForm tak, żeby dało się go przetestować przez React Testing Library. ___BLANK1___: etykieta Imię załoganta wskazuje przez htmlFor id pola tekstowego (crew-name), więc test znajdzie je przez getByLabelText('Imię załoganta'). ___BLANK2___: błąd imienia pojawia się, gdy imię po obcięciu spacji z początku i końca ma mniej niż 2 znaki. ___BLANK3___: błąd roli pojawia się, gdy nie wybrano żadnej roli (pusta wartość listy). Błędy mają role="alert", a przy poprawnych danych formularz wywołuje onSubmit({ name, role }) z obciętym imieniem i czyści pola.
- Układanie w pionie
Ułóż składnię renderowania i sprawdzania komponentu w RTL:
- Edytor kodu
Dokończ dwa komponenty panelu sterowania. ToggleSwitch to checkbox w etykiecie, więc test znajdzie go przez getByRole('checkbox', { name: 'Osłony' }). ___BLANK1___: checkbox jest kontrolowany, jego zaznaczenie pochodzi ze stanu on. ___BLANK2___: po kliknięciu stan przyjmuje nowe zaznaczenie z obiektu zdarzenia (pole checked elementu event.target), a status obok zmienia się na włączone albo wyłączone. NavigationList pokazuje przyciski celów podróży. ___BLANK3___: aria-pressed przycisku jest true tylko dla wybranego celu, a dla pozostałych false, więc test znajdzie wybrany cel przez getByRole('button', { pressed: true }).