React Testing Library специально подталкивает к более “пользовательскому” стилю тестирования. Из-за этого он сначала кажется менее удобным, чем старые подходы, завязанные на внутреннюю структуру компонента. Но именно в этом и есть его сила: тесты живут дольше, когда проверяют поведение, а не детали реализации.
Проблема в том, что многие ошибки в RTL повторяются из проекта в проект. Обычно это не сложные баги, а привычки: искать элементы по testid, использовать waitFor там, где есть findBy*, вручную вызывать cleanup или гонять fireEvent, хотя сценарий должен идти через userEvent.
Ниже собран практический набор правил, который помогает быстро сделать тесты устойчивее и понятнее.
- Не думай в терминах wrapper
- Используй
screenи семантические queries - Предпочитай
userEvent, а неfireEvent - Используй
findBy*и аккуратно работай сwaitFor - Пиши доступную разметку, чтобы тесты были проще
- Подключи
jest-domи ESLint-плагины - Итог
Не думай в терминах wrapper
Возвращаемое значение render - это не “обертка” в стиле Enzyme. Это набор утилит для взаимодействия с DOM.
import { render } from "@testing-library/react";
test("rerender обновляет пропсы", () => {
const { rerender } = render(<Example prop="first" />);
rerender(<Example prop="second" />);
});Если тебе нужен только rerender, забирай именно его. Не сохраняй все в переменную wrapper просто по старой привычке.
Используй screen и семантические queries
В большинстве тестов удобнее обращаться к DOM через screen, а не через деструктуризацию каждого query из render.
import { render, screen } from "@testing-library/react";
test("показывает сообщение об ошибке", () => {
render(<Example hasError />);
expect(screen.getByRole("alert")).toHaveTextContent("Что-то пошло не так");
});Для поиска элементов сначала пытайся использовать запросы, близкие к реальному пользовательскому восприятию:
getByRolegetByLabelTextgetByPlaceholderTextgetByText
getByTestId лучше оставить на случаи, где у элемента нет хорошего доступного представления.
render(
<>
<label htmlFor="username">Username</label>
<input id="username" type="text" />
</>,
);
expect(screen.getByRole("textbox", { name: /username/i })).toBeInTheDocument();Предпочитай userEvent, а не fireEvent
fireEvent полезен для низкоуровневых сценариев, но большинство пользовательских действий лучше моделировать через userEvent.
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
test("пользователь вводит текст в поле", async () => {
const user = userEvent.setup();
render(<SearchForm />);
await user.type(screen.getByRole("textbox", { name: /search/i }), "react");
expect(screen.getByRole("textbox", { name: /search/i })).toHaveValue("react");
});Почему это лучше:
- поведение ближе к реальному взаимодействию;
- события идут в более естественном порядке;
- тест лучше отражает пользовательский сценарий.
Используй findBy* и аккуратно работай с waitFor
Если элемент появится не сразу, чаще всего нужен findBy*, а не waitFor(() => getBy...).
import { render, screen } from "@testing-library/react";
test("показывает кнопку submit после загрузки", async () => {
render(<AsyncForm />);
const submitButton = await screen.findByRole("button", { name: /submit/i });
expect(submitButton).toBeEnabled();
});waitFor нужен тогда, когда ты ждешь не сам элемент, а некоторое условие вокруг него:
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
test("вызывает API после ввода", async () => {
const user = userEvent.setup();
const fetchSpy = jest.spyOn(window, "fetch").mockResolvedValue({
ok: true,
json: async () => [],
} as Response);
render(<SearchForm />);
await user.type(screen.getByRole("textbox", { name: /search/i }), "react");
await waitFor(() => {
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
fetchSpy.mockRestore();
});Правило простое:
- не делай побочные эффекты внутри
waitFor; - не передавай в него пустой callback;
- не оборачивай им то, что уже умеет
findBy*.
Пиши доступную разметку, чтобы тесты были проще
Хорошие тесты на RTL часто начинаются с хорошей разметки.
Плохой вариант:
render(<button role="button">Save</button>);Нормальный вариант:
render(<button>Save</button>);Если элемент уже семантический, не нужно дублировать роль вручную. Это не только лишний шум, но и потенциальный источник ошибок.
Тот же принцип работает с формами, ссылками, alert-блоками и диалогами: чем ближе разметка к доступному HTML, тем проще запросы и тем устойчивее тесты.
Подключи jest-dom и ESLint-плагины
@testing-library/jest-dom делает утверждения намного понятнее:
expect(screen.getByRole("button", { name: /save/i })).toBeDisabled();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();Такие assertions читаются лучше и дают качественнее сообщения об ошибках, чем ручные проверки свойств DOM.
Чтобы не держать все правила в голове, добавь официальные плагины:
Они ловят многие типовые ошибки раньше, чем те попадут в CI.
Итог
Хороший тест на React Testing Library обычно выглядит скучно: render, screen, правильный query, одно пользовательское действие и одно понятное ожидание результата. И это хорошо. Чем меньше тест зависит от внутренних деталей компонента, тем дольше он живет без ложных падений.
Если нужен короткий ориентир, начни с четырех правил: screen, getByRole, userEvent.setup() и findBy* вместо лишнего waitFor. Уже этого достаточно, чтобы заметно улучшить большую часть тестового набора.