React Testing Library testuje komponent tak, jak widzi go użytkownik: szukasz elementów po roli, etykiecie i tekście (getByRole,getByLabelText), klikasz i wpisujesz przezuserEvent, a wynik sprawdzasz matcherami zjest-dom. Dzięki temu test nie zależy od klas CSS ani wewnętrznego stanu, więc przeżywa refaktoryzację komponentu. Dane z API mockujesz w Jest, a asynchroniczne zmiany czekasz przezfindBy.
Pierwsze testy komponentów Reacta bywają frustrujące z dwóch powodów: nie wiadomo, jakiego zapytania użyć, i testy pękają po każdej zmianie wyglądu. Ten artykuł pokazuje, jak napisać testy, które przeżywają refaktoryzację. Wszystkie przykłady uruchomiłem lokalnie w Node 22, Jest 30, React 19 i React Testing Library 16.
Zakładam, że znasz podstawy Reacta (komponenty, stan, useEffect). Jeśli nie, zacznij od artykułu o tym, czy jesteś gotowy na Reacta. Samo Jest i kod asynchroniczny opisuje osobny artykuł o testowaniu kodu asynchronicznego w JavaScript.
Czym jest React Testing Library i dlaczego nie testuje implementacji?
React Testing Library (RTL) to biblioteka do testowania komponentów w środowisku, które udaje przeglądarkę (jsdom). Jej zasada przewodnia, z dokumentacji: im bardziej testy przypominają sposób używania oprogramowania, tym większą dają pewność.
W praktyce oznacza to:
- Szukasz elementów tak, jak robi to użytkownik: po roli (przycisk, pole tekstowe), etykiecie i widocznym tekście, a nie po klasie CSS czy nazwie komponentu.
- Nie sprawdzasz stanu ani wywołań
useState. Sprawdzasz to, co pojawia się na ekranie, i to, co komponent przekazuje na zewnątrz (np. wywołanie funkcji z propsów). - Rola Jest jest inna niż rola RTL. Jest uruchamia testy, dostarcza
expecti mocki (jest.fn()). RTL renderuje komponent i daje zapytania do jego wyniku.
Jak skonfigurować Jest i React Testing Library?
Zainstaluj pakiety:
npm i -D jest jest-environment-jsdom babel-jest @babel/core @babel/preset-env @babel/preset-react \
@testing-library/react @testing-library/user-event @testing-library/jest-dom
Wersje, na których uruchamiałem przykłady: Jest 30.5.2, RTL 16.3.3 (obsługuje React 18 i 19), user-event 14.6.8, jest-dom 7.0.1. W projekcie założonym narzędziem takim jak Vite albo Create React App część konfiguracji (Babel, jsdom) może być już gotowa, więc nie dubluj jej na ślepo.
Trzy pliki konfiguracyjne:
{
"presets": [
["@babel/preset-env", { "targets": { "node": "current" } }],
["@babel/preset-react", { "runtime": "automatic" }]
]
}
To jest babel.config.json. Dalej jest.config.js:
module.exports = {
testEnvironment: "jsdom",
setupFilesAfterEnv: ["<rootDir>/jest.setup.js"],
};
oraz jest.setup.js, który dodaje matchery z jest-dom (np. toBeInTheDocument):
import "@testing-library/jest-dom";
Gdy go zabraknie, test kończy się błędem TypeError: expect(...).toBeInTheDocument is not a function. To jeden z najczęstszych problemów początkujących, wracam do niego w tabeli błędów niżej.
Jak napisać pierwszy test komponentu z formularzem?
Komponent zapisu do newslettera: pole e-mail, przycisk i komunikat błędu, gdy adres nie zawiera @.
import { useState } from "react";
export function NewsletterForm({ onSubmit }) {
const [email, setEmail] = useState("");
const [error, setError] = useState("");
function handleSubmit(event) {
event.preventDefault();
if (!email.includes("@")) {
setError("Podaj poprawny adres e-mail");
return;
}
setError("");
onSubmit(email);
}
return (
<form onSubmit={handleSubmit} className="newsletter-form">
<label htmlFor="email">Adres e-mail</label>
<input
id="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
/>
{error && <p className="newsletter-error" role="alert">{error}</p>}
<button type="submit" className="btn btn-primary">Zapisz się</button>
</form>
);
}
Dwa testy: poprawny adres wywołuje onSubmit, a adres bez @ pokazuje błąd i niczego nie wysyła.
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { NewsletterForm } from "./NewsletterForm";
test("wysyła poprawny adres e-mail", async () => {
const user = userEvent.setup();
const onSubmit = jest.fn(); // atrapa (mock) funkcji z propsów
render(<NewsletterForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText("Adres e-mail"), "ala@example.com");
await user.click(screen.getByRole("button", { name: "Zapisz się" }));
expect(onSubmit).toHaveBeenCalledWith("ala@example.com");
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
test("pokazuje błąd przy adresie bez @", async () => {
const user = userEvent.setup();
const onSubmit = jest.fn();
render(<NewsletterForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText("Adres e-mail"), "ala");
await user.click(screen.getByRole("button", { name: "Zapisz się" }));
expect(screen.getByRole("alert")).toHaveTextContent("Podaj poprawny adres e-mail");
expect(onSubmit).not.toHaveBeenCalled();
});
Wynik: oba testy przechodzą. Zwróć uwagę na wzorzec każdego testu: przygotuj (render), zrób to, co zrobiłby użytkownik (user.type, user.click), sprawdź to, co widać (expect). Ten sam rytm opisuje artykuł o TDD i BDD w pytest, tylko w Pythonie.
Którego zapytania użyć: getBy, queryBy czy findBy?
Pierwszy człon nazwy mówi, co ma się stać, gdy elementu nie ma albo gdy jest ich kilka:
| Zapytanie | Gdy nie znajdzie elementu | Kiedy użyć |
|---|---|---|
getBy… | rzuca błąd od razu | element musi być teraz na ekranie |
queryBy… | zwraca null | sprawdzasz, że elementu nie ma |
findBy… | czeka (domyślnie do 1 sekundy), potem rzuca błąd | element pojawi się po chwili (np. po fetch) |
Do tego dochodzą wersje …AllBy…, gdy oczekujesz wielu elementów (np. getAllByRole("listitem")).
Drugi człon mówi, po czym szukasz. Dokumentacja zaleca taką kolejność:
| Priorytet | Zapytanie | Kiedy |
|---|---|---|
| 1 | ByRole (z opcją name) | prawie zawsze: przyciski, pola, nagłówki, listy |
| 2 | ByLabelText | pola formularza z etykietą |
| 3 | ByPlaceholderText | tylko gdy nie ma etykiety (placeholder nie zastępuje etykiety) |
| 4 | ByText | elementy nieinteraktywne: akapity, span, div |
| 5 | ByDisplayValue | pole z wpisaną wartością |
| 6 | ByAltText, ByTitle | obrazki, tytuły |
| 7 | ByTestId | ostateczność, gdy nic innego nie pasuje |
Jeśli nie możesz znaleźć elementu przez ByRole, bywa, że interfejs jest niedostępny dla czytników ekranu. Warto to naprawić w komponencie, zamiast zmieniać test. Dostępnością cyfrową zajmuje się nasz kurs Audytor WCAG.
Czym różni się userEvent od fireEvent?
fireEvent wysyła pojedyncze zdarzenie DOM, np. change. userEvent symuluje całą sekwencję, jaką wykonuje użytkownik: przy user.type jest to fokus, naciśnięcia klawiszy i zdarzenia wejścia dla każdego znaku, a przy user.click ruch kursora i kliknięcie. Dokumentacja RTL zaleca userEvent jako domyślny wybór. Wszystkie jego metody są asynchroniczne, więc używaj await i tworzenia instancji przez userEvent.setup() przed render.
Jak testować komponent, który pobiera dane z API?
Komponent pobiera listę użytkowników w useEffect:
import { useEffect, useState } from "react";
export function UserList() {
const [users, setUsers] = useState(null);
useEffect(() => {
fetch("/api/users")
.then((response) => response.json())
.then(setUsers);
}, []);
if (users === null) return <p>Ładowanie…</p>;
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
W teście nie wołamy prawdziwego API. Podmieniamy fetch na atrapę (mock) i czekamy na wynik przez findBy:
import { render, screen } from "@testing-library/react";
import { UserList } from "./UserList";
beforeEach(() => {
global.fetch = jest.fn().mockResolvedValue({
json: () => Promise.resolve([{ id: 1, name: "Ala" }, { id: 2, name: "Ola" }]),
});
});
test("najpierw pokazuje ładowanie, potem użytkowników", async () => {
render(<UserList />);
expect(screen.getByText("Ładowanie…")).toBeInTheDocument();
expect(await screen.findByText("Ala")).toBeInTheDocument();
expect(screen.getAllByRole("listitem")).toHaveLength(2);
expect(screen.queryByText("Ładowanie…")).not.toBeInTheDocument();
});
Ten test przechodzi bez ostrzeżeń. findByText("Ala") czeka, aż dane się pojawią, więc nie trzeba ręcznie opakowywać niczego w act. Podobne mocki do zewnętrznych usług opisuje artykuł o testowaniu kodu z API LLM, tam w Pythonie.
Jak czekać na zmiany w teście: findBy, waitFor czy waitForElementToBeRemoved?
Trzy narzędzia robią podobną rzecz (czekają, aż coś się zmieni na ekranie), ale do różnych sytuacji:
| Narzędzie | Do czego | Przykład |
|---|---|---|
findBy… | czekasz, aż pojawi się jeden element | await screen.findByText("Ala") |
waitFor(() => …) | czekasz, aż spełni się dowolna asercja | await waitFor(() => expect(onSave).toHaveBeenCalled()) |
waitForElementToBeRemoved(…) | czekasz, aż element zniknie | spinner, komunikat „Ładowanie…” |
findBy… to w rzeczywistości getBy… zawinięte w waitFor, więc gdy czekasz na element, wybierz findBy. waitFor zostaw na przypadki, w których nie ma elementu do znalezienia, a sprawdzasz coś innego.
Dlaczego nie klikać wewnątrz waitFor?
waitFor wywołuje funkcję wielokrotnie, aż przestanie rzucać błąd albo skończy się czas. Jeśli w środku jest akcja, powtórzy się razem z asercją. Komponent zapisuje dane i po 200 ms pokazuje „Zapisano”:
import { useState } from "react";
export function SaveButton({ onSave }) {
const [saved, setSaved] = useState(false);
function handleClick() {
onSave();
setTimeout(() => setSaved(true), 200);
}
return (
<div>
<button onClick={handleClick}>Zapisz</button>
{saved && <p>Zapisano</p>}
</div>
);
}
Zły test wkłada kliknięcie do waitFor:
test("ZŁY: kliknięcie wewnątrz waitFor", async () => {
const onSave = jest.fn();
render(<SaveButton onSave={onSave} />);
await waitFor(() => {
fireEvent.click(screen.getByText("Zapisz"));
expect(screen.getByText("Zapisano")).toBeInTheDocument();
});
expect(onSave).toHaveBeenCalledTimes(1);
});
Wynik: test pada, bo zapis wywołał się 6 razy zamiast jednego (Expected number of calls: 1, Received number of calls: 6). Każdy kolejny obieg waitFor klikał przycisk jeszcze raz. W prawdziwej aplikacji to by były 6 zapisów. Poprawnie: akcja przed waitFor, w środku tylko asercja.
test("DOBRY: akcja przed waitFor, w środku tylko asercja", async () => {
const onSave = jest.fn();
render(<SaveButton onSave={onSave} />);
fireEvent.click(screen.getByText("Zapisz"));
await waitFor(() => expect(screen.getByText("Zapisano")).toBeInTheDocument());
expect(onSave).toHaveBeenCalledTimes(1);
});
Jak przetestować, że serwer zwrócił błąd?
Ścieżka błędu bywa pomijana, a to właśnie tam aplikacje najczęściej się psują. Rozszerzamy komponent o obsługę odpowiedzi z błędem:
import { useEffect, useState } from "react";
export function UserListSafe() {
const [users, setUsers] = useState(null);
const [error, setError] = useState(false);
useEffect(() => {
fetch("/api/users")
.then((response) => {
if (!response.ok) throw new Error("HTTP " + response.status);
return response.json();
})
.then(setUsers)
.catch(() => setError(true));
}, []);
if (error) return <p role="alert">Nie udało się pobrać użytkowników</p>;
if (users === null) return <p>Ładowanie…</p>;
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
I dwa testy: odpowiedź 500 oraz odrzucony fetch (np. brak sieci). W drugim czekamy, aż zniknie „Ładowanie…”:
import { render, screen, waitForElementToBeRemoved } from "@testing-library/react";
import { UserListSafe } from "./UserListSafe";
test("pokazuje błąd, gdy serwer zwraca 500", async () => {
global.fetch = jest.fn().mockResolvedValue({ ok: false, status: 500 });
render(<UserListSafe />);
expect(await screen.findByRole("alert")).toHaveTextContent("Nie udało się pobrać użytkowników");
expect(screen.queryByText("Ładowanie…")).not.toBeInTheDocument();
});
test("pokazuje błąd, gdy fetch zostaje odrzucony", async () => {
global.fetch = jest.fn().mockRejectedValue(new Error("Network down"));
render(<UserListSafe />);
await waitForElementToBeRemoved(() => screen.queryByText("Ładowanie…"));
expect(screen.getByRole("alert")).toBeInTheDocument();
});
Oba przechodzą, bez ostrzeżeń act(). Zwróć uwagę na waitForElementToBeRemoved(() => screen.queryByText(…)): przekazujemy funkcję, a nie gotowy element. Dzięki temu biblioteka sama sprawdza, czy element jeszcze istnieje.
Do bardziej realistycznych mocków sieci używa się też biblioteki MSW (Mock Service Worker), która przechwytuje żądania zamiast podmieniać fetch. Nie uruchamiałem jej w tym artykule, więc szczegóły znajdziesz w jej dokumentacji.
Dlaczego dobry test przeżywa refaktoryzację, a zły nie?
Napiszmy ten sam test błędu formularza dwa razy.
Zły, oparty na klasach CSS i strukturze:
import { render, fireEvent } from "@testing-library/react";
import { NewsletterForm } from "../src/NewsletterForm";
test("ZŁY: błąd przy adresie bez @ (selektory CSS)", () => {
const { container } = render(<NewsletterForm onSubmit={() => {}} />);
fireEvent.change(container.querySelector(".newsletter-form input"), { target: { value: "ala" } });
fireEvent.click(container.querySelector(".btn-primary"));
expect(container.querySelector(".newsletter-error").textContent).toBe("Podaj poprawny adres e-mail");
});
Dobry, oparty na roli i etykiecie:
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { NewsletterForm } from "../src/NewsletterForm";
test("DOBRY: błąd przy adresie bez @ (rola i etykieta)", async () => {
const user = userEvent.setup();
render(<NewsletterForm onSubmit={() => {}} />);
await user.type(screen.getByLabelText("Adres e-mail"), "ala");
await user.click(screen.getByRole("button", { name: "Zapisz się" }));
expect(screen.getByRole("alert")).toHaveTextContent("Podaj poprawny adres e-mail");
});
Przed zmianą oba testy przechodzą. Teraz refaktoryzacja, która nie zmienia zachowania: zmieniamy nazwy klas na zgodne z konwencją BEM (form, button button--primary, form__error), pole opakowujemy w div i zamieniamy <p> na <span>:
<form onSubmit={handleSubmit} className="form">
<label htmlFor="email">Adres e-mail</label>
<div className="field">
<input
id="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
/>
</div>
{error && <span className="form__error" role="alert">{error}</span>}
<button type="submit" className="button button--primary">Zapisz się</button>
</form>
Wynik uruchomienia po refaktoryzacji: zły test pada, dobry przechodzi. Zły kończy się komunikatem:
Unable to fire a "change" event - please provide a DOM element.
Formularz działa tak samo, ale selektor .newsletter-form input już nic nie znajduje. To dokładnie ten problem, który czyni testy źródłem frustracji: kosztują czas przy każdej zmianie, a nie wykryły żadnego błędu. Testy z poprzedniej sekcji (src/) nadal przechodzą wszystkie trzy.
Wniosek: test powinien pękać, gdy zmienia się zachowanie, a nie wygląd kodu.
Jakie błędy w testach React Testing Library są najczęstsze i jak je naprawić?
Poniższe komunikaty odtworzyłem w opisanym wyżej projekcie.
| Komunikat | Przyczyna | Poprawka |
|---|---|---|
Unable to find an element with the text: Ala… | test szuka danych, które jeszcze się nie pojawiły (getByText jest synchroniczne) | użyj await screen.findByText("Ala") |
Unable to find an accessible element with the role "alert" | komponent nie renderuje takiego elementu (jeszcze lub wcale), a użyto getBy | gdy chcesz sprawdzić brak: queryByRole("alert") i not.toBeInTheDocument(); gdy element ma być, sprawdź rolę i stan, w którym go oczekujesz |
Found multiple elements with the role "listitem" | zapytanie getBy znalazło więcej niż jeden element | getAllByRole("listitem") albo zawęź przez name lub within(...) |
TypeError: expect(...).toBeInTheDocument is not a function | nie załadowano matcherów jest-dom | dodaj import "@testing-library/jest-dom" w pliku z setupFilesAfterEnv |
An update to UserList inside a test was not wrapped in act(...) | stan zmienia się po zakończeniu testu, bo test nie poczekał na asynchroniczny wynik | czekaj na efekt przez await screen.findBy… albo await waitFor(...) |
Unable to find an element with the text: Zbyszek… po dłuższym czasie, z waitFor | element nigdy się nie pojawia (zły tekst, dane z mocka, inny stan komponentu) | sprawdź, co faktycznie wyrenderowano (screen.debug()) i czy mock zwraca te dane |
The element(s) given to waitForElementToBeRemoved are already removed… lub Timed out in waitForElementToBeRemoved. | element zniknął, zanim zaczęło się czekanie (pierwszy komunikat), albo nigdy nie znika (drugi) | przekaż funkcję () => screen.queryBy… i najpierw upewnij się przez getBy…, że element istnieje; dla drugiego sprawdź, czy mock w ogóle się rozwiązuje |
Zwróć uwagę na ostatni wiersz. W moim eksperymencie test z tym ostrzeżeniem przechodził, a ostrzeżenie pojawiało się tylko w konsoli. To pułapka: zielony wynik nie oznacza, że test czeka na wszystko, co ważne.
Czego nie testować w komponentach React?
- Stanu wewnętrznego i wywołań
useState. Sprawdzaj wynik widoczny na ekranie. - Klas CSS i struktury DOM, o ile nie są częścią zachowania (jak
roleczy atrybuty dostępności). - Cudzych bibliotek. Nie testujesz, czy React Router przechodzi między stronami, tylko czy Twój komponent reaguje na zmianę adresu.
- Snapshotów jako jedynego testu. Zmieniają się przy każdej drobnej zmianie i uczą ślepego zatwierdzania.
Jeśli test nie przechodzi i nie wiesz dlaczego, wypisz aktualny DOM funkcją screen.debug() i zobacz, co naprawdę zostało wyrenderowane. Ogólne techniki szukania błędów opisuje artykuł o debugowaniu w JavaScript.
Jak zacząć testować komponenty, jeśli dopiero uczysz się Reacta?
Zacznij od jednego, małego komponentu z formularzem, tak jak powyżej. Napisz test przed lub zaraz po komponencie i zawsze sprawdź, czy test pada przy zepsutym zachowaniu (np. po usunięciu walidacji), bo test, który nigdy nie pada, niczego nie sprawdza. Ten sam komponent, który zbudujesz w teście, możesz potem wyrenderować dynamicznie, zob. dynamiczne renderowanie komponentów w React.
Z RTL pracujemy także w kursie. Testowanie komponentów (Jest, React Testing Library, Error Boundaries, najczęstsze błędy początkujących) to osobny moduł React: Testowanie w ramach mentoringu z code review.
Podsumowanie
- React Testing Library testuje komponent tak, jak widzi go użytkownik: role, etykiety, widoczny tekst.
- Konfiguracja: Jest + jsdom +
@testing-library/jest-domwsetupFilesAfterEnv. getBygdy element jest teraz,queryBygdy go nie ma,findBygdy pojawi się później.- Nie wkładaj akcji (
click,type) dowaitFor: callback wywołuje się wielokrotnie, więc akcja też. - Preferuj
getByRole(zname) igetByLabelText, agetByTestIdzostaw na koniec. userEventzamiastfireEvent, zawsze zawait.- Test oparty na klasach CSS pęka przy refaktoryzacji, dobry test przeżywa ją bez zmian.
- Zielony test z ostrzeżeniem
act()nie jest w pełni zielony: poczekaj na efekt przezfindBy.

