React mocking и тестирование API: как изолировать сеть без хрупких тестов

Практический разбор React mocking и тестирования API: где мокать модуль, где поднимать MSW, как тестировать loading/error/success и не ломать архитектуру тестов.

21 апреля 2026 г.18 минLexicon Team

Введение

Тема React mocking почти всегда всплывает в тот момент, когда тесты уже начали мешать команде. Один разработчик мокает fetch, другой подменяет целый data hook, третий стабилизирует suite через лишний waitFor, а через месяц никто не уверен, что тесты вообще ловят реальные регрессии. Если нужен общий обзор слоев тестирования, он уже разобран в материале про React тестирование целиком. Здесь фокус уже другой: как именно мокать API и зависимости так, чтобы тест проверял поведение интерфейса, а не случайную текущую реализацию.

Ключевая мысль простая: мок не должен быть центром теста. Центром должен оставаться пользовательский сценарий. Пользователь открывает экран, видит загрузку, получает данные, сталкивается с ошибкой, нажимает retry, отправляет форму, видит обновленное состояние. Если мок помогает воспроизвести этот путь, он полезен. Если тест после мока знает о внутренностях компонента больше, чем сама команда разработки, значит граница выбрана плохо.

Больше вопросов в Telegram

Ежедневные разборы и реальные кейсы с интервью

Подписаться

Что именно должен решать React mocking в API-тестах

У хорошего мока в React-тестировании не так много задач:

  1. Изолировать внешний источник нестабильности: сеть, браузерное API, аналитический SDK, feature flags.
  2. Позволить воспроизвести полезный сценарий: loading, success, empty, error, retry.
  3. Сохранить читаемость теста после рефакторинга data layer.
  4. Не привязать тест к частной структуре хука, клиента или кэша.

Поэтому фраза "надо замокать API" сама по себе слишком общая. Важно понять, на какой границе вы тестируете систему.

  • Если проверяется чистая функция mapUserResponse, сеть не нужна вообще.
  • Если проверяется API-клиент, можно мокать транспорт или HTTP-уровень.
  • Если проверяется экран React, который использует React Query, SWR или свой data hook, выгоднее мокать сетевую границу, а не подменять сам hook.
  • Если проверяется поведение роутинга, аналитики или IntersectionObserver, сетевой мок не решит проблему, нужен точечный module mock.

Именно здесь многие команды смешивают разные уровни. В результате screen test внезапно начинает вручную подменять useQuery, fetch, router и analytics одновременно. Такой тест почти всегда зеленый, но ценности у него мало.

Архитектура тестов: где проходит разумная граница между React и API

На практике полезно держать в голове простую схему ответственности.

СлойЗа что отвечаетЧем лучше тестироватьЧто обычно мокать
Доменная логикапреобразование ответа, валидация, merge данныхunitничего или маленькие фикстуры
API clientHTTP-вызов, headers, обработка статусовunit/integration lighttransport или HTTP-ответ
Data layercache, retry, invalidation, состояние запросаintegrationсеть на границе
React screenloading/error/success, интеракция пользователяintegrationHTTP через MSW, иногда browser API
Сквозной путьреальный браузер, роутинг, backend-контрактe2eминимум моков

Самый частый промах выглядит так: экранный тест опускается слишком низко и начинает мокать слой, который сам должен быть частью предмета проверки. Для React-экрана с React Query важна не абстрактная фраза "useUsers вернул массив", а то, как экран ведёт себя в состояниях pending, ошибку и повторный запрос. Эта логика особенно хорошо раскрывается в паттернах data fetching в React, где server state рассматривается как отдельный слой со своим жизненным циклом.

Поток данных в устойчивом API-тесте

Рабочий сценарий обычно выглядит так:

  1. Компонент рендерится внутри реальных провайдеров.
  2. Query layer или data hook выполняет реальный вызов своего клиента.
  3. На сетевой границе запрос перехватывает MSW.
  4. Тест наблюдает только за видимыми состояниями интерфейса.
  5. Для нестандартных зависимостей отдельно подменяются только нужные модули.

Это не самый "минимальный" путь по количеству кода, зато он лучше переживает смену fetch на axios, перестройку query-ключей и локальный рефакторинг hooks.

Сравнение подходов: что мокать и когда

Ниже полезно сравнить четыре самых частых стратегии.

ПодходЧто проверяет хорошоГлавный плюсГлавный рискКогда выбирать
Прямой мок fetchлокальную реакцию на заранее заданный ответбыстро для простого unit-кейсатест зависит от транспорта и быстро обрастает деталямидля маленького клиента или очень узкой проверки
Module mock data hookповедение родительского UI при фиксированном состояниилегко изолировать ветку рендеравы теряете реальный жизненный цикл запросакогда нужен точечный unit тест контейнера
MSW на HTTP-границеэкран, форму, список, retry, error stateтест ближе к реальному приложениюнужен аккуратный test setupдефолт для React API integration tests
E2E с реальным backendполный пользовательский путьмаксимальная реалистичностьдорого и медленно поддерживатьдля критичных бизнес-сценариев

Практический вывод обычно такой: module mock не запрещен, но не должен быть стратегией по умолчанию для тестирования React-экрана. Для UI-сценариев с API наилучший компромисс чаще всего достигается с помощью MSW. А если тема касается управления server state, эта логика естественным образом продолжается в материале React Query под капотом, потому что именно там видно, почему простая подмена хука лишает тест важной части поведения.

Пример 1: хрупкий мок fetch и почему он быстро перестает помогать

Ручной мок fetch кажется самым коротким путем. Проблема в том, что он часто заводит тест слишком глубоко в реализацию.

import { render, screen } from "@testing-library/react";
import { UsersPage } from "./UsersPage";

test("рендерит список пользователей", async () => {
  vi.spyOn(global, "fetch").mockResolvedValueOnce(
    new Response(
      JSON.stringify({ items: [{ id: "1", name: "Anna" }] }),
      { status: 200 }
    )
  );

  render(<UsersPage />);

  expect(screen.getByText(/загрузка/i)).toBeInTheDocument();
  expect(await screen.findByText("Anna")).toBeInTheDocument();
});

У такого подхода три типичные проблемы.

Первая: тест теперь знает, что внутри вообще используется fetch. Замена на axios, GraphQL client или обертку поверх транспорта (над транспортным слоем) ломает тест без изменения пользовательского поведения.

Вторая: как только появляется второй запрос, retry, фоновое обновление или зависимость от query cache, ручной мок разрастается до длинной лестницы mockResolvedValueOnce и mockRejectedValueOnce.

Третья: начинает размываться граница между тестом экрана и тестом клиента. Если экран уже зависит от разделения client state и server state, прямой мок транспорта редко остается удобным надолго.

Это не значит, что такой прием всегда плохой. Он уместен для очень узкого юнит-теста клиента или для проверки обработки статуса 401 в адаптере. Но как базовая стратегия для React API testing он обычно слишком хрупок.

Пример 2: устойчивый integration-тест через MSW и QueryClient

Для React-экрана выгоднее поднять реальные провайдеры и перехватить сеть на границе.

import { http, HttpResponse } from "msw";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { server } from "../test/server";
import { UsersPage } from "./UsersPage";

function renderUsersPage() {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: { retry: false },
      mutations: { retry: false },
    },
  });

  return render(
    <QueryClientProvider client={queryClient}>
      <UsersPage />
    </QueryClientProvider>
  );
}

test("показывает ошибку API и позволяет повторить запрос", async () => {
  const user = userEvent.setup();

  server.use(
    http.get("/api/users", () => {
      return HttpResponse.json({ message: "temporary failure" }, { status: 500 });
    })
  );

  renderUsersPage();

  expect(screen.getByText(/загрузка/i)).toBeInTheDocument();
  expect(await screen.findByText(/не удалось загрузить пользователей/i)).toBeInTheDocument();

  server.use(
    http.get("/api/users", () => {
      return HttpResponse.json({ items: [{ id: "1", name: "Anna Petrova" }] });
    })
  );

  await user.click(screen.getByRole("button", { name: /повторить/i }));

  expect(await screen.findByText(/anna petrova/i)).toBeInTheDocument();
});

Почему этот тест оказывается более устойчивым:

  • он не знает, какой транспорт используется внутри;
  • он проверяет переходы экрана, а не возвращаемый объект хука;
  • он естественно покрывает loading, error, retry и success;
  • он остается полезным даже после рефакторинга внутреннего data layer.

Такой подход особенно хорошо работает для экранов со списками, формами, фильтрами и повторными запросами. Он же логично сочетается с принципами из статьи про тестирование React-компонентов как UI-контрактов: тест по-прежнему описывает то, что видит и делает пользователь.

Пример 3: когда module mock все-таки уместен

Есть зависимости, которые бессмысленно включать в integration через HTTP. Например, аналитика, matchMedia, ResizeObserver, редкий router hook или feature-flag SDK. Здесь точечный mock уместен, потому что он изолирует именно внешний шум, а не предмет теста.

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ShareButton } from "./ShareButton";
import { trackEvent } from "../shared/analytics";

vi.mock("../shared/analytics", () => ({
  trackEvent: vi.fn(),
}));

test("отправляет аналитическое событие после успешного копирования", async () => {
  const user = userEvent.setup();
  vi.spyOn(navigator.clipboard, "writeText").mockResolvedValueOnce();

  render(<ShareButton url="https://example.dev/article" />);

  await user.click(screen.getByRole("button", { name: /скопировать ссылку/i }));

  expect(trackEvent).toHaveBeenCalledWith("share_link_copied");
  expect(screen.getByText(/ссылка скопирована/i)).toBeInTheDocument();
});

Здесь mock не ломает идею теста, потому что аналитика не является его центром. Тест все еще проверяет пользовательский эффект. Аналогичная логика работает для IntersectionObserver, matchMedia, scrollTo, геолокации и прочих браузерных API, которые отсутствуют или неудобны в использовании в тестовой среде.

Прокачай React за 7 дней

20 вопросов и разборов по React Hooks

Начать

Болевые точки в реальных проектах: почему React-моки и API-тесты перестают быть надежными

Слишком подробные моки data layer

Если test file вручную подменяет useUsers, useCreateUser, fetch, router, analytics и еще часть контекста, он почти наверняка моделирует искусственный мир. После рефакторинга такой тест остается зеленым, хотя настоящий экран уже живет по-другому.

Проверка внутренних вызовов вместо контрактов интерфейса

Ожидание вида expect(fetchUsers).toHaveBeenCalledTimes(1) не бесполезно само по себе, но редко отвечает на главный вопрос: увидит ли пользователь ошибку, останется ли кнопка retry доступной, корректно ли обновится таблица. Для UI-тестов это слабый сигнал.

Универсальный helper, который поднимает половину приложения

Иногда команда прячет все в renderApp(), который автоматически создает роутер, пользователя, feature flags, theme, query client, API mocks и еще несколько дефолтов. В короткой перспективе это удобно. В долгосрочной перспективе тест начинает скрывать контекст и становится непонятно, что именно влияет на поведение.

Случайные waitFor вместо ожидания конкретного UI-события

Если тест ждет не текст, роль, кнопку или исчезновение скелетона, а просто повторяет waitFor(() => ...), он обычно маскирует нестабильность модели состояния. Чем ближе ожидание к конкретному признаку интерфейса, тем стабильнее suite.

Разбор производительности тестового слоя

Производительность тестов важна не только ради CI. Медленный suite постепенно перестают запускать локально, а значит команда узнает о регрессиях позже.

Главные узкие места в React API testing обычно такие:

  • создание тяжелого QueryClient или глобального app wrapper в каждом тесте;
  • неочищенный кэш между тестами;
  • общий setupTests, который поднимает лишние polyfills и listeners;
  • дублирование однотипных integration-сценариев с почти одинаковыми handlers;
  • агрессивные retry-политики, которые полезны в приложении, но вредны в unit/integration suite.

Здесь есть важный компромисс. Слишком "реалистичный" тестовый стенд тоже может быть дорогим. Поэтому правило обычно такое: сохраняйте реалистичность на правильной границе, но отключайте все, что не нужно для сигнала. Для React Query это обычно retry: false и новый QueryClient на каждый кейс. Для SWR - отдельный provider и очистка кеша. Для MSW - локальные handlers без глобальной магии.

Практики, которые делают React API tests устойчивыми

  • Мокайте сеть на границе, а не подменяйте без необходимости весь data hook.
  • Держите renderWithProviders маленьким и прозрачным.
  • Для каждого теста создавайте изолированный query cache.
  • Ждите конкретных состояний интерфейса: текст, роль, aria-busy, кнопка retry, исчезновение skeleton.
  • Разделяйте тесты клиента, data layer и экрана по уровню ответственности.
  • Оставляйте module mocks только для зависимостей, которые не являются предметом сценария.
  • Не бойтесь нескольких тестовых слоев: unit для преобразований, integration для React+API, e2e для критичных путей.

Эти практики хорошо работают и в обычном RTL-стеке, и в связке Jest/Vitest с MSW. По смыслу это продолжение подхода из статьи про Jest и React Testing Library без хрупких тестов: тест должен переживать рефакторинг и по-прежнему ловить баги, а не только подтверждать текущую форму кода.

Частые ошибки

Ошибка 1. Считать любой мок признаком плохого теста

Плох не сам мок, а плохо выбранная граница. Мок analytics может быть разумным. Подмена useQuery внутри экранного теста, где важны retry и stale state, обычно уже сомнительна.

Ошибка 2. Проверять только happy path

API-сценарии ломаются не на идеальном ответе 200, а на 401, 403, 404, 422, 500, таймауте, пустом списке и повторной попытке после ошибки. Если тесты не покрывают эти ветки, слой защиты остается декоративным.

Ошибка 3. Дублировать одну и ту же проверку на трех уровнях

Если один и тот же кейс про список пользователей полностью повторяется в unit, integration и e2e, поддержка быстро дорожает. Логичнее выбрать, где именно этот риск ловится дешевле и надежнее.

Ошибка 4. Смешивать server state и локальный UI state в одном тесте без границ

Когда тест одновременно проверяет фильтр, optimistic update, toast, query invalidation и поведение формы, он становится слишком широким. На этом уровне полезно заранее понимать, кто владеет данными и как устроен жизненный цикл server state.

Как отвечать на интервью

Сильный ответ на тему React mocking и тестирование API обычно звучит примерно так:

  1. Я разделяю уровень теста: клиент, data layer, экран и e2e.
  2. Для React-экранов по умолчанию предпочитаю MSW, потому что он мокает HTTP-контракт, а не внутренности hooks.
  3. Module mocks использую точечно для analytics, browser API, router hooks и прочих внешних зависимостей, которые не являются предметом сценария.
  4. В тесте проверяю переходы loading/error/success, retry и пользовательский результат, а не только факт вызова функции.
  5. Если слой server state сложный, отдельно слежу за очисткой кэша, retry-политикой и читаемостью test setup.

Если хотите усилить ответ до middle или senior уровня, добавьте компромисс: ручной мок fetch не "запрещен", просто его зона применения уже. Он подходит для точечных unit-кейсов клиента, но хуже масштабируется на экранные integration-сценарии.

Практика реальных технических собеседований по React

Разберите mocking, API testing, React Query, MSW и архитектурные trade-off на живых React-кейсах без шаблонных ответов

Начать подготовку

FAQ

Нужно ли в каждом React-тесте поднимать MSW?

Нет. Для чистых unit-тестов это избыточно. Но для сценариев, где экран взаимодействует с сетью, кэш и переходы состояний, MSW обычно дает лучший баланс реалистичности и устойчивости.

Что делать, если в проекте уже много тестов с ручным моком fetch?

Не обязательно переписывать все сразу. Обычно полезно начать с самых хрупких экранов: списков, форм, retry-сценариев и мест, где тесты часто ломаются после рефакторинга data layer.

Можно ли одновременно использовать MSW и module mocks?

Да. Это нормальная комбинация, если они отвечают за разные границы. Например, HTTP мокается через MSW, а matchMedia или analytics - через точечный mock модуля или browser API.

Как тестировать polling, background refetch и optimistic update?

Там особенно важно не подменять целиком data hook. Лучше сохранять реальный query lifecycle, контролировать сетевые ответы и проверять видимое состояние UI после обновления, ошибки и возможного отката.

Что хуже всего влияет на читаемость React API tests?

Скрытая магия в глобальных helper'ах, слишком подробные моки и тесты, которые зависят от конкретной реализации транспорта вместо пользовательского поведения экрана.

Итоги

React mocking приносит пользу не тогда, когда в тесте использовано как можно больше моков, а тогда, когда команда выбрала правильную границу изоляции. Для экранов и форм с API это чаще всего означает мок сети на HTTP-уровне и проверку пользовательских состояний. Для инфраструктурных зависимостей - точечные module mocks. Для критичных бизнес-путей - короткий набор e2e.

Если четко соблюдать эту границу, API-тесты перестают быть хрупкой имитацией и начинают реально страховать рефакторинг: смену клиента, перестройку hooks, изменение кэша и эволюцию UI. Именно в этом и состоит зрелый подход к React mocking и тестированию API.

Больше вопросов в Telegram

Ежедневные разборы и реальные кейсы с интервью

Подписаться

Автор

Lexicon Team

Читайте также