React mocking и тестирование API: как изолировать сеть без хрупких тестов
Практический разбор React mocking и тестирования API: где мокать модуль, где поднимать MSW, как тестировать loading/error/success и не ломать архитектуру тестов.
- Введение
- Что именно должен решать React mocking в API-тестах
- Архитектура тестов: где проходит разумная граница между React и API
- Поток данных в устойчивом API-тесте
- Сравнение подходов: что мокать и когда
- Пример 1: хрупкий мок fetch и почему он быстро перестает помогать
- Пример 2: устойчивый integration-тест через MSW и QueryClient
- Пример 3: когда module mock все-таки уместен
- Болевые точки в реальных проектах: почему React-моки и API-тесты перестают быть надежными
- Слишком подробные моки data layer
- Проверка внутренних вызовов вместо контрактов интерфейса
- Универсальный helper, который поднимает половину приложения
- Случайные waitFor вместо ожидания конкретного UI-события
- Разбор производительности тестового слоя
- Практики, которые делают React API tests устойчивыми
- Частые ошибки
- Ошибка 1. Считать любой мок признаком плохого теста
- Ошибка 2. Проверять только happy path
- Ошибка 3. Дублировать одну и ту же проверку на трех уровнях
- Ошибка 4. Смешивать server state и локальный UI state в одном тесте без границ
- Как отвечать на интервью
- FAQ
- Нужно ли в каждом React-тесте поднимать MSW?
- Что делать, если в проекте уже много тестов с ручным моком fetch?
- Можно ли одновременно использовать MSW и module mocks?
- Как тестировать polling, background refetch и optimistic update?
- Что хуже всего влияет на читаемость React API tests?
- Итоги
Введение
Тема React mocking почти всегда всплывает в тот момент, когда тесты уже начали мешать команде. Один разработчик мокает fetch, другой подменяет целый data hook, третий стабилизирует suite через лишний waitFor, а через месяц никто не уверен, что тесты вообще ловят реальные регрессии. Если нужен общий обзор слоев тестирования, он уже разобран в материале про React тестирование целиком. Здесь фокус уже другой: как именно мокать API и зависимости так, чтобы тест проверял поведение интерфейса, а не случайную текущую реализацию.
Ключевая мысль простая: мок не должен быть центром теста. Центром должен оставаться пользовательский сценарий. Пользователь открывает экран, видит загрузку, получает данные, сталкивается с ошибкой, нажимает retry, отправляет форму, видит обновленное состояние. Если мок помогает воспроизвести этот путь, он полезен. Если тест после мока знает о внутренностях компонента больше, чем сама команда разработки, значит граница выбрана плохо.
Больше вопросов в Telegram
Ежедневные разборы и реальные кейсы с интервью
Что именно должен решать React mocking в API-тестах
У хорошего мока в React-тестировании не так много задач:
- Изолировать внешний источник нестабильности: сеть, браузерное API, аналитический SDK, feature flags.
- Позволить воспроизвести полезный сценарий:
loading,success,empty,error, retry. - Сохранить читаемость теста после рефакторинга data layer.
- Не привязать тест к частной структуре хука, клиента или кэша.
Поэтому фраза "надо замокать 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 client | HTTP-вызов, headers, обработка статусов | unit/integration light | transport или HTTP-ответ |
| Data layer | cache, retry, invalidation, состояние запроса | integration | сеть на границе |
| React screen | loading/error/success, интеракция пользователя | integration | HTTP через MSW, иногда browser API |
| Сквозной путь | реальный браузер, роутинг, backend-контракт | e2e | минимум моков |
Самый частый промах выглядит так: экранный тест опускается слишком низко и начинает мокать слой, который сам должен быть частью предмета проверки. Для React-экрана с React Query важна не абстрактная фраза "useUsers вернул массив", а то, как экран ведёт себя в состояниях pending, ошибку и повторный запрос. Эта логика особенно хорошо раскрывается в паттернах data fetching в React, где server state рассматривается как отдельный слой со своим жизненным циклом.
Поток данных в устойчивом API-тесте
Рабочий сценарий обычно выглядит так:
- Компонент рендерится внутри реальных провайдеров.
- Query layer или data hook выполняет реальный вызов своего клиента.
- На сетевой границе запрос перехватывает MSW.
- Тест наблюдает только за видимыми состояниями интерфейса.
- Для нестандартных зависимостей отдельно подменяются только нужные модули.
Это не самый "минимальный" путь по количеству кода, зато он лучше переживает смену 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 обычно звучит примерно так:
- Я разделяю уровень теста: клиент, data layer, экран и e2e.
- Для React-экранов по умолчанию предпочитаю MSW, потому что он мокает HTTP-контракт, а не внутренности hooks.
- Module mocks использую точечно для analytics, browser API, router hooks и прочих внешних зависимостей, которые не являются предметом сценария.
- В тесте проверяю переходы
loading/error/success, retry и пользовательский результат, а не только факт вызова функции. - Если слой 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
Читайте также
frontend
Jest + React Testing Library: как тестировать React без хрупких тестов
Практический разбор Jest и React Testing Library: архитектура тестов, async-сценарии, MSW, user-event, моки и ошибки, из-за которых тесты перестают ловить регрессии.
frontend
React testing: 18 сложных вопросов, которые реально задают на интервью
18 сложных вопросов по тестированию React: StrictMode, Suspense, optimistic update, fake timers, MSW, accessibility, cache и границы между component, integration и e2e.
frontend
React тестирование: что нужно знать, чтобы писать полезные тесты
Разбираем React тестирование на практике: unit, integration, e2e, Testing Library, Vitest/Jest, частые ошибки, performance и ответы для собеседования.