React Testing Library специально подталкивает к более “пользовательскому” стилю тестирования. Из-за этого он сначала кажется менее удобным, чем старые подходы, завязанные на внутреннюю структуру компонента. Но именно в этом и есть его сила: тесты живут дольше, когда проверяют поведение, а не детали реализации.

Проблема в том, что многие ошибки в RTL повторяются из проекта в проект. Обычно это не сложные баги, а привычки: искать элементы по testid, использовать waitFor там, где есть findBy*, вручную вызывать cleanup или гонять fireEvent, хотя сценарий должен идти через userEvent.

Ниже собран практический набор правил, который помогает быстро сделать тесты устойчивее и понятнее.

Не думай в терминах 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("Что-то пошло не так");
});

Для поиска элементов сначала пытайся использовать запросы, близкие к реальному пользовательскому восприятию:

  • getByRole
  • getByLabelText
  • getByPlaceholderText
  • getByText

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. Уже этого достаточно, чтобы заметно улучшить большую часть тестового набора.