Kurs JavaScript i React · Moduł 13: Testowanie React

Testowanie Interakcji - Symulator Pilotażu

8 min czytania
W tej lekcji4

W prawdziwym statku kosmicznym pilot naciska przyciski, obraca pokrętła i wprowadza dane na klawiaturze. W testach React musimy symulować te same interakcje, aby upewnić się, że aplikacja reaguje poprawnie. Sam test renderowania powie Ci tylko, że panel się zaświecił. Nie powie, czy po naciśnięciu "Launch" silnik faktycznie ruszy.

Do symulowania interakcji mamy dwa narzędzia: wbudowane w RTL fireEvent oraz osobną bibliotekę userEvent. Oba są opakowane w act(), więc React zdąży przerysować komponent, zanim sprawdzisz wynik. Różnią się tym, jak wiernie udają człowieka, i tę różnicę zobaczysz w tej lekcji krok po kroku.

fireEvent - Podstawowa symulacja

fireEvent to narzędzie z React Testing Library do wywoływania zdarzeń DOM. Każde wywołanie wysyła dokładnie jedno zdarzenie, np. click, do wskazanego elementu:

1import { render, screen, fireEvent } from '@testing-library/react';
2
3test('toggles engine status on button click', () => {
4  render(<EnginePanel />);
5
6  const toggleButton = screen.getByRole('button', { name: 'Toggle Engine' });
7  expect(screen.getByText('Engine: OFF')).toBeInTheDocument();
8
9  fireEvent.click(toggleButton);
10  expect(screen.getByText('Engine: ON')).toBeInTheDocument();
11
12  fireEvent.click(toggleButton);
13  expect(screen.getByText('Engine: OFF')).toBeInTheDocument();
14});

Test sprawdza stan przed kliknięciem, po pierwszym i po drugim, więc widać pełny cykl przełącznika. Nie zaglądamy do stanu komponentu, tylko czytamy napis, tak jak zrobiłby to pilot.

Zdarzenia formularzy

Pole tekstowe zmieniamy przez fireEvent.change, podając nową wartość w target.value. Do sprawdzenia, czy formularz wysłał dane, służy funkcja-atrapa jest.fn():

1test('updates pilot name input', () => {
2  render(<PilotForm />);
3
4  const nameInput = screen.getByLabelText('Pilot Name');
5  fireEvent.change(nameInput, { target: { value: 'Commander Nova' } });
6
7  expect(nameInput).toHaveValue('Commander Nova');
8});
9
10test('submits mission form', () => {
11  const mockSubmit = jest.fn();
12  render(<MissionForm onSubmit={mockSubmit} />);
13
14  fireEvent.change(screen.getByLabelText('Destination'), {
15    target: { value: 'Mars' }
16  });
17  fireEvent.click(screen.getByRole('button', { name: 'Launch' }));
18
19  expect(mockSubmit).toHaveBeenCalledWith({ destination: 'Mars' });
20});

Asercja toHaveBeenCalledWith sprawdza, że onSubmit dostał dokładnie { destination: 'Mars' }. Wewnętrzny stan MissionForm w ogóle nas nie interesuje, liczy się to, co komponent przekazał na zewnątrz.

userEvent - Realistyczna symulacja

userEvent to bardziej zaawansowana biblioteka, która symuluje zdarzenia tak, jak robi to prawdziwy użytkownik. Zamiast jednego zdarzenia change, symuluje naciśnięcia poszczególnych klawiszy. Instalujesz ją osobno jako @testing-library/user-event, a w wersji 14 zaczynasz od userEvent.setup():

1import userEvent from '@testing-library/user-event';
2
3test('types pilot name realistically', async () => {
4  const user = userEvent.setup();
5  render(<PilotForm />);
6
7  const nameInput = screen.getByLabelText('Pilot Name');
8  await user.type(nameInput, 'Commander Nova');
9
10  expect(nameInput).toHaveValue('Commander Nova');
11});

Obiekt user z setup() trzyma stan klawiatury i wskaźnika przez cały test. Wszystkie jego metody są asynchroniczne, dlatego przed każdą stoi await.

Kluczowe różnice: fireEvent vs userEvent

Porównajmy, co naprawdę dzieje się pod spodem przy obu podejściach:

1// fireEvent - szybki, ale mniej realistyczny
2fireEvent.change(input, { target: { value: 'text' } });
3// Wywołuje TYLKO zdarzenie change
4
5// userEvent - wolniejszy, ale realistyczny
6await user.type(input, 'text');
7// Najpierw klika pole (pointer, mouse, focus, click),
8// potem dla KAŻDEGO znaku: keydown, keypress, beforeinput, input, keyup
9// (onChange w React reaguje na każde zdarzenie input)

fireEvent.change od razu podmienia wartość i nie sprawdza, czy pole jest zablokowane ani czy w ogóle da się w nie kliknąć. userEvent przechodzi całą sekwencję jak człowiek i po drodze robi te same kontrole co przeglądarka: nie wpisze tekstu do pola z atrybutem disabled i zgłosi błąd, gdy klikasz element z CSS pointer-events: none. Nie zauważy za to, że jeden element zasłania drugi, bo jsdom nie liczy układu strony. Moja rekomendacja: domyślnie używaj userEvent, a fireEvent zostaw na zdarzenia, których userEvent nie obsługuje.

Klikanie i podwójne klikanie

Poza zwykłym kliknięciem userEvent obsługuje podwójne kliknięcie i dowolne przyciski myszy:

1test('handles click and double click', async () => {
2  const user = userEvent.setup();
3  render(<NavigationPanel />);
4
5  // Pojedyncze kliknięcie
6  await user.click(screen.getByRole('button', { name: 'Select' }));
7
8  // Podwójne kliknięcie
9  await user.dblClick(screen.getByText('Mission Details'));
10
11  // Kliknięcie prawym przyciskiem
12  await user.pointer({
13    keys: '[MouseRight]',
14    target: screen.getByText('Options')
15  });
16});

Metoda pointer przyjmuje opis przycisku w nawiasach kwadratowych, tu [MouseRight], czyli prawy przycisk myszy.

Interakcje z klawiaturą

Dostępna aplikacja musi działać bez myszy. user.tab() przenosi fokus jak klawisz Tab, a user.keyboard wpisuje klawisze specjalne w nawiasach klamrowych:

1test('keyboard navigation in mission list', async () => {
2  const user = userEvent.setup();
3  render(<MissionList />);
4
5  // Tab do następnego elementu
6  await user.tab();
7  expect(screen.getByText('Mission Alpha')).toHaveFocus();
8
9  // Tab do kolejnego
10  await user.tab();
11  expect(screen.getByText('Mission Beta')).toHaveFocus();
12
13  // Enter aby wybrać
14  await user.keyboard('{Enter}');
15  expect(screen.getByText('Selected: Mission Beta')).toBeInTheDocument();
16});

Matcher toHaveFocus z jest-dom sprawdza, który element ma fokus. Taki test chroni nawigację klawiaturą przed przypadkowym zepsuciem.

Czyszczenie i zaznaczanie tekstu

Czasem pole ma już wartość domyślną i trzeba ją wyczyścić przed wpisaniem nowej:

1test('clears and replaces input value', async () => {
2  const user = userEvent.setup();
3  render(<CoordinateInput defaultValue="0,0,0" />);
4
5  const input = screen.getByRole('textbox');
6  // Zaznacz wszystko i wyczyść
7  await user.clear(input);
8  expect(input).toHaveValue('');
9
10  // Wpisz nową wartość
11  await user.type(input, '42,15,88');
12  expect(input).toHaveValue('42,15,88');
13});

user.clear zaznacza całą zawartość i usuwa ją, a user.type dopisuje tekst na końcu, więc bez czyszczenia dostałbyś 0,0,042,15,88.

Lista rozwijana i pole wyboru

Listy rozwijane i pola wyboru mają swoje metody:

1test('selects planet from dropdown', async () => {
2  const user = userEvent.setup();
3  render(<PlanetSelector />);
4
5  await user.selectOptions(
6    screen.getByRole('combobox'),
7    screen.getByRole('option', { name: 'Mars' })
8  );
9
10  expect(screen.getByRole('option', { name: 'Mars' }).selected).toBe(true);
11});
12
13test('toggles autopilot checkbox', async () => {
14  const user = userEvent.setup();
15  render(<AutopilotSwitch />);
16
17  const checkbox = screen.getByRole('checkbox', { name: 'Autopilot' });
18  expect(checkbox).not.toBeChecked();
19
20  await user.click(checkbox);
21  expect(checkbox).toBeChecked();
22});

selectOptions przyjmuje element opcji albo jej wartość, a checkbox przełączamy zwykłym kliknięciem, tak jak użytkownik.

Testowanie callbacków i props

Na koniec dwa testy tego samego przycisku: raz gdy systemy są gotowe, raz gdy nie są:

1test('calls onLaunch when all systems ready', async () => {
2  const user = userEvent.setup();
3  const handleLaunch = jest.fn();
4
5  render(<LaunchButton onLaunch={handleLaunch} systemsReady={true} />);
6
7  await user.click(screen.getByRole('button', { name: 'Launch' }));
8  expect(handleLaunch).toHaveBeenCalledTimes(1);
9});
10
11test('does not call onLaunch when systems not ready', async () => {
12  const user = userEvent.setup();
13  const handleLaunch = jest.fn();
14
15  render(<LaunchButton onLaunch={handleLaunch} systemsReady={false} />);
16
17  await user.click(screen.getByRole('button', { name: 'Launch' }));
18  expect(handleLaunch).not.toHaveBeenCalled();
19});

Drugi test jest równie ważny jak pierwszy. Sprawdza, że przycisk nie wystrzeli, gdy systemsReady ma wartość false, a to często jest najbardziej krytyczne zachowanie. Test negatywny to kontrola bezpieczeństwa: sprawdza, czego system nie zrobi, gdy warunki nie są spełnione.

Ładowanie danych i przycisk ponowienia

Panel misji często musi najpierw pobrać dane, np. listę załogi. Taki komponent zawsze przechodzi przez tę samą sekwencję stanów, a test sprawdza ją krok po kroku:

  1. Stan początkowy - loading: true, na ekranie komunikat o ładowaniu.
  2. Wysłanie zapytania - efekt po pierwszym renderze woła funkcję, która pobiera dane.
  3. Odebranie odpowiedzi - Promise spełnia się z danymi albo zostaje odrzucony z błędem.
  4. Aktualizacja stanu - loading: false i data z odpowiedzią albo error z komunikatem.
  5. Ponowny render - komponent pokazuje listę albo komunikat o błędzie z przyciskiem ponowienia.

Żeby test nie wysyłał prawdziwych zapytań w kosmos, funkcję pobierającą dane komponent dostaje w propsach. W aplikacji przekazujesz prawdziwą funkcję zdefiniowaną poza komponentem, np. loadCrew={fetchCrew}, a w teście atrapę:

1function CrewLoader({ loadCrew }) {
2  const [state, setState] = useState({ loading: true, data: null, error: null });
3  const [attempt, setAttempt] = useState(0);
4
5  useEffect(() => {
6    let ignore = false;
7    loadCrew()
8      .then((data) => {
9        if (!ignore) setState({ loading: false, data, error: null });
10      })
11      .catch((err) => {
12        if (!ignore) setState({ loading: false, data: null, error: err.message });
13      });
14    return () => {
15      ignore = true;
16    };
17  }, [loadCrew, attempt]);
18
19  function retry() {
20    setState({ loading: true, data: null, error: null });
21    setAttempt((n) => n + 1);
22  }
23
24  if (state.loading) return <p>Loading crew...</p>;
25  if (state.error) {
26    return (
27      <div>
28        <p role="alert">{state.error}</p>
29        <button onClick={retry}>Retry</button>
30      </div>
31    );
32  }
33  return (
34    <ul>
35      {state.data.map((name) => <li key={name}>{name}</li>)}
36    </ul>
37  );
38}

Kliknięcie "Retry" wraca do stanu ładowania i zwiększa licznik attempt, a zmiana zależności uruchamia efekt jeszcze raz, więc komponent wysyła nowe zapytanie. Flaga ignore w funkcji sprzątającej odrzuca odpowiedź, która przyszła za późno, np. po odmontowaniu komponentu. To ważne także dlatego, że w trybie deweloperskim ze StrictMode React uruchamia każdy efekt dwa razy. Błąd ma rolę alert, więc test znajdzie go po roli.

Test przechodzi całą ścieżkę awarii i naprawy. Atrapa za pierwszym razem zwraca odrzucony Promise, a za drugim dane:

1test('shows an error and loads the crew again after Retry', async () => {
2  const user = userEvent.setup();
3  const loadCrew = jest.fn()
4    .mockRejectedValueOnce(new Error('Signal lost'))
5    .mockResolvedValueOnce(['Nova', 'Astro']);
6
7  render(<CrewLoader loadCrew={loadCrew} />);
8  expect(screen.getByText('Loading crew...')).toBeInTheDocument();
9
10  expect(await screen.findByRole('alert')).toHaveTextContent('Signal lost');
11  await user.click(screen.getByRole('button', { name: 'Retry' }));
12
13  expect(await screen.findByText('Nova')).toBeInTheDocument();
14  expect(loadCrew).toHaveBeenCalledTimes(2);
15});

mockRejectedValueOnce sprawia, że pierwsze wywołanie atrapy zwraca odrzucony Promise, a mockResolvedValueOnce, że drugie zwraca dane. findByRole i findByText czekają, aż komponent przejdzie do następnego stanu, bo odpowiedź przychodzi asynchronicznie. Na koniec liczba wywołań atrapy potwierdza, że przycisk naprawdę wysłał drugie zapytanie. Komponent nie wie, że rozmawia z atrapą, i na tym polega cała korzyść z przekazania funkcji w propsach.

W następnej lekcji przyjrzysz się czekaniu dokładniej: poznasz waitFor i nauczysz się podmieniać fetch. Pamiętaj: dobry test interakcji naciska te same przyciski co pilot i sprawdza tylko to, co pilot widzi na panelu.

Kod do tej lekcji: App.jsx
1import React, { useState } from 'react';
2
3const ROLES = [
4  { value: 'pilot', label: 'Pilot' },
5  { value: 'captain', label: 'Kapitan' },
6  { value: 'engineer', label: 'Inżynier' },
7  { value: 'scientist', label: 'Naukowiec' },
8];
9
10// Formularz, który test wypełnia przez userEvent i sprawdza wywołanie onSubmit
11function PilotForm({ onSubmit }) {
12  const [name, setName] = useState('');
13  const [role, setRole] = useState('pilot');
14  const [experience, setExperience] = useState(0);
15  const [autopilot, setAutopilot] = useState(false);
16  const [lastAdded, setLastAdded] = useState(null);
17
18  const handleSubmit = (e) => {
19    e.preventDefault();
20    if (!name.trim()) return;
21    onSubmit({ name, role, experience, autopilot });
22    setLastAdded(name);
23    setName('');
24    setRole('pilot');
25    setExperience(0);
26    setAutopilot(false);
27  };
28
29  return (
30    <form className="pilot-form" onSubmit={handleSubmit}>
31      <h2>Rejestracja członka załogi</h2>
32
33      <div className="field">
34        <label htmlFor="name">Imię:</label>
35        <input
36          id="name"
37          type="text"
38          value={name}
39          onChange={(e) => setName(e.target.value)}
40          placeholder="Wpisz imię pilota..."
41        />
42      </div>
43
44      <div className="field">
45        <label htmlFor="role">Rola:</label>
46        <select id="role" value={role} onChange={(e) => setRole(e.target.value)}>
47          {ROLES.map((r) => (
48            <option key={r.value} value={r.value}>{r.label}</option>
49          ))}
50        </select>
51      </div>
52
53      <div className="field">
54        <label htmlFor="experience">Doświadczenie (lata):</label>
55        <input
56          id="experience"
57          type="number"
58          value={experience}
59          onChange={(e) => setExperience(Number(e.target.value))}
60          min="0"
61          max="50"
62        />
63      </div>
64
65      <div className="field checkbox">
66        <label>
67          <input
68            type="checkbox"
69            checked={autopilot}
70            onChange={(e) => setAutopilot(e.target.checked)}
71          />
72          Certyfikat autopilota
73        </label>
74      </div>
75
76      <button type="submit" disabled={!name.trim()}>
77        Dodaj członka załogi
78      </button>
79
80      {lastAdded && (
81        <p className="success" role="status">Dodano: {lastAdded}</p>
82      )}
83    </form>
84  );
85}
86
87// Przełączniki, które test klika i sprawdza po napisach
88function TogglePanel() {
89  const [engines, setEngines] = useState({ left: false, right: false });
90  const [shield, setShield] = useState(0);
91
92  const status =
93    engines.left && engines.right
94      ? 'Oba silniki pracują - pełna moc!'
95      : engines.left || engines.right
96      ? 'Tryb jednego silnika - zmniejszona prędkość'
97      : 'Wszystkie silniki wyłączone';
98
99  return (
100    <div className="toggle-panel">
101      <h2>Sterowanie statkiem</h2>
102
103      <div className="controls">
104        <button
105          onClick={() => setEngines((e) => ({ ...e, left: !e.left }))}
106          className={engines.left ? 'active' : ''}
107        >
108          Lewy silnik: {engines.left ? 'WŁ.' : 'WYŁ.'}
109        </button>
110
111        <button
112          onClick={() => setEngines((e) => ({ ...e, right: !e.right }))}
113          className={engines.right ? 'active' : ''}
114        >
115          Prawy silnik: {engines.right ? 'WŁ.' : 'WYŁ.'}
116        </button>
117
118        <button onClick={() => setShield((s) => Math.min(s + 25, 100))}>
119          Osłony: {shield}%
120        </button>
121      </div>
122
123      <p className="status">{status}</p>
124    </div>
125  );
126}
127
128export default function App() {
129  const [calls, setCalls] = useState([]);
130
131  return (
132    <div className="app">
133      <h1>Testowanie interakcji - Symulator pilotażu</h1>
134      <p className="note">
135        Podgląd nie uruchamia testów. Wypełnij i wyślij formularz: test z userEvent
136        robi to samo, a potem sprawdza przez toHaveBeenCalledWith, jakie dane
137        dostała funkcja onSubmit.
138      </p>
139      <PilotForm onSubmit={(data) => setCalls((c) => [...c, data])} />
140
141      {calls.length > 0 && (
142        <div className="submissions">
143          <h3>Wywołania onSubmit ({calls.length}):</h3>
144          {calls.map((data, i) => (
145            <code key={i} className="call">{JSON.stringify(data)}</code>
146          ))}
147        </div>
148      )}
149
150      <TogglePanel />
151    </div>
152  );
153}

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 główna przewaga userEvent nad fireEvent w testowaniu React?

  2. 2. Jak sprawdzić w teście, że callback onSubmit został wywołany z poprawnymi danymi?

Zadania praktyczne w grze

  • Edytor kodu

    Dokończ PlanetLoader, który pobiera planety funkcją loadPlanets z props i pokazuje trzy stany: ładowanie (role="status"), błąd (role="alert" z przyciskiem Spróbuj ponownie) i listę planet. ___BLANK1___: po udanym pobraniu zapisz w stanie status, że dane są gotowe (np. 'success'), żeby zamiast komunikatu ładowania pojawiła się lista. ___BLANK2___: gdy obietnica zostanie odrzucona, ustaw status 'error'. ___BLANK3___: retry zwiększa licznik prób attempt o 1, dzięki czemu efekt uruchamia się ponownie i znów woła loadPlanets. W podglądzie pierwsze pobranie celowo kończy się awarią, więc zobaczysz komunikat błędu i ponowienie.

  • Układanie w pionie

    Ułóż stany komponentu asynchronicznego w kolejności, w jakiej się pojawiają:

  • Klikanie w kolejności

    Ułóż prawidłową składnię symulacji wpisywania tekstu z userEvent:

Przydatne artykuły