Kurs JavaScript i React · Moduł 13: Testowanie React

React Testing Library - Panel Kontroli Misji

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

ZnacznikRola
<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-insensitive

Zwykł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):

  1. getByRole - dostępność, jak użytkownik widzi element
  2. getByLabelText - formularze
  3. getByPlaceholderText - gdy brak etykiety
  4. getByText - elementy nieinteraktywne
  5. getByDisplayValue - aktualna wartość pola
  6. getByAltText - obrazy
  7. getByTitle - atrybut title
  8. 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. 1. Co robi funkcja cleanup() w React Testing Library i kiedy jest automatycznie wywoływana?

  2. 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 }).

Przydatne artykuły