open atlas
↑ К треку
React с нуля до senior RCT · 07 · 02

Асинхронные тесты и MSW: мокайте сеть, а не fetch

findBy — это getBy плюс waitFor: таймаут 1 с, опрос каждые 50 мс — асинхронный UI ждут, а не усыпляют тест. MSW 2.x перехватывает на сетевом шве: http.get плюс HttpResponse, server.use для оверрайдов в тесте. Мок fetch привязан к клиенту; MSW переживает переезд на react-query.

RCT Senior ◷ 19 min
Уровень
ОсновыJuniorMiddleSenior

Четырнадцать красных прогонов CI за неделю, все в одном спеке, ни один не воспроизводится локально. Команда дашборда гоняла тесты на 10-ядерных ноутбуках, где грид заказов рендерился через 90 мс после резолва замоканного fetch; CI крутил четыре воркера Vitest на расшаренном раннере с 2 vCPU, где тот же рендер занимал 1,3 секунды под насыщенным CPU — а waitFor сдавался на дефолтной 1 000 мс. Первый «фикс» удвоил таймаут глобально. Через месяц зафлейкал другой тест — по противоположной причине: он проверял спиннер загрузки через getByRole("status") после await клика, и на быстрых прогонах ответ уже приземлился — спиннер исчезал до проверки. Третий инцидент оказался дорогим: тест завернул fireEvent.click(submit) внутрь колбэка waitFor «для надёжности». waitFor перезапускает колбэк каждые 50 мс, пока тот не перестанет бросать, — форма отправлялась четырежды за прогон, баг дедупликации, который это маскировало, уехал в продакшен, и клиента списали дважды. Три флейка, один корень: команда боролась с асинхронностью слипами и ретраями вместо того, чтобы её моделировать — ждите нужное состояние, не кладите побочные эффекты в цикл опроса и мокайте на шве, который не двигается.

findBy и waitFor: опрос, а не сон

Через десять минут вы будете точно знать, почему CI-джоба из Hook повисла, почему проверка спиннера была гонкой и — самое важное — почему retry: 2 скрыл бы все три инцидента. У каждой проблемы однострочный диагноз; он работает только если понимаешь модель.

findByRole(...) — это буквально getByRole, завёрнутый в waitFor: запрос перезапускается каждые 50 мс, пока не выполнится или пока не сгорит бюджет в 1 000 мс, и возвращает промис. Это весь асинхронный инструментарий для «данные появились»: await screen.findByRole("row", { name: /order #1042/i }) выражает «пользователь в конце концов это видит» без сна, без произвольной задержки и с точным падением (таймаут с последней ошибкой запроса, а не безликий промах проверки). Обе ручки — честная конфигурация, а не магия: на вызов — findByRole("row", {}, { timeout: 3000 }) — или на весь сьют через configure({ asyncUtilTimeout }), когда раннеры CI действительно медленнее ноутбуков. А они медленнее: расшаренный раннер с 2 vCPU под параллельными воркерами стабильно тратит на рендер в 5–10 раз больше, чем dev-машина, и ровно этот множитель превращает комфортные 90 мс в пробитие односекундного бюджета на 1,3 с.

У самого waitFor три правила, предотвращающие инциденты из Hook. Одна проверка на колбэк — waitFor ретраит, пока колбэк не перестанет бросать, поэтому колбэк с пятью ожиданиями сообщает о падении того, что выполнилось последним, а первые четыре перевыполняются на каждом опросе. Никаких побочных эффектов внутри колбэкаfireEvent или user.click внутри waitFor перестреливает на каждом 50-мс ретрае; именно так форма из Hook отправилась четыре раза и спрятала баг дедупликации. Никогда не пустойawait waitFor(() => {}) это сон с лишними движениями: он ждёт ровно один тик и ничего не проверяет, замазывая порядок, который вы не поняли. Чтобы проверить, что элемент исчез, инвертируйте: await waitForElementToBeRemoved(() => screen.queryByRole("status")) — заметьте queryBy, вариант, возвращающий null вместо исключения: правильный зонд для отсутствия.

У проверки состояния загрузки своя ловушка: после await user.click(submit) ответ может уже приземлиться — мок-серверы отвечают в микротасках. Проверяйте спиннер синхронно сразу после действия, запускающего запрос, через getByRole("status") (он обязан быть на месте немедленно, если компонент рендерит его немедленно), либо добавьте хендлеру явную задержку, когда промежуточное состояние и есть предмет теста.

Викторина

Тест делает: await waitFor(() => { fireEvent.click(submitButton); expect(screen.getByText(/saved/i)).toBeInTheDocument(); }). Он проходит. Что произошло на самом деле?

MSW 2.x: мокайте шов, который не двигается

Спросите себя: когда команда мигрирует на react-query в следующем квартале, сколько тестов сломается? Если ответ зависит от выбранной стратегии мока — вы выбрали не тот шов.

Мок fetch привязывает тест к транспортной функции. В день, когда команда мигрирует с голого fetch на axios или заворачивает доступ к данным в react-query, каждый тест с vi.spyOn(global, "fetch") ломается — не потому, что поведение изменилось, а потому, что шов переехал. MSW (Mock Service Worker — библиотека перехвата запросов на уровне сети) перехватывает на сетевой границе: хендлеры описывают HTTP-трафик — метод, путь, ответ, — а компонент под тестом выполняет свой настоящий код загрузки данных, каким бы он ни был в этом квартале. Рефакторинг с fetch на axios и далее на react-query меняет ноль тестов — свойство, делающее сьют на 1 000 тестов жизнеспособным.

// хендлеры, общие для тестов и Storybook
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";

export const server = setupServer(
  http.get("/api/orders", () =>
    HttpResponse.json([{ id: 1042, status: "shipped" }]),
  ),
);

// файл настройки тестов
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

Три несущие детали. onUnhandledRequest: "error" превращает каждый запрос, который вы забыли замокать, в громкое падение вместо тихого зависания, которое потом всплывёт таймаутом waitFor, — лучший выключатель флейков во всём стеке. server.resetHandlers() в afterEach сбрасывает пер-тестовые оверрайды, чтобы чей-то 500 не протёк в следующий тест — пропуск этой строки входит в тройку главных источников падений, зависящих от порядка. А server.use(...) подставляет хендлеры только для текущего теста:

it("показывает error boundary на 500", async () => {
  server.use(http.get("/api/orders", () =>
    new HttpResponse(null, { status: 500 }),
  ));
  render(<Orders />);
  expect(await screen.findByRole("alert")).toHaveTextContent(/could not load/i);
});

it("отличает сетевой сбой от 500", async () => {
  server.use(http.get("/api/orders", () => HttpResponse.error()));
  render(<Orders />);
  expect(await screen.findByRole("alert")).toHaveTextContent(/offline/i);
});

Эта пара — дисциплина состояний ошибок: 500 и сетевой сбой (HttpResponse.error()) — разные пользовательские переживания: «сервер отказал» против «сервер недостижим», и компонент, схлопывающий их в одно сообщение, — настоящая находка, видимая только потому, что шов позволяет выразить оба случая.

Викторина

Команда мигрирует загрузку данных с голого fetch на react-query. Сьют A мокал fetch через vi.spyOn(global, 'fetch'); сьют B использовал хендлеры MSW. Что произойдёт с каждым и почему?

Зелёное локально, красное в CI: постмортем таймингов

Прогоните три инцидента из Hook через правила — и каждый разрешается. Рендер за 1,3 с, пробивающий бюджет в 1 с, — не флейки, а корректный тест с неверным бюджетом для среды; поднимите asyncUtilTimeout осознанно для железа класса CI, не сыпьте слипы. Проверка спиннера, гоняющаяся с собственным исчезновением, — баг порядка в тесте: промежуточные состояния проверяются синхронно в момент, когда они обязаны существовать, либо делаются детерминированными хендлером с задержкой. Клик внутри waitFor — единственный настоящий дефект, и он спрятал продакшен-баг; отсюда общий урок: большинство «флейков в асинхронных тестах» — это тесты с неверной моделью времени, и лечение никогда не retry: 2. Полезная эвристика аудита унаследованного сьюта, по порядку: grep по колбэкам waitFor длиннее одного выражения; grep по любому диспатчу события внутри waitFor; grep по setTimeout/sleep в тестах; затем проверка, что onUnhandledRequest стоит в error. Эти четыре grep нашли 31 латентный флейк в 600-тестовом сьюте из Hook за один вечер.

Почему это работает

Почему один файл хендлеров обслуживает тесты, Storybook и локальную разработку? Потому что за единым API хендлеров у MSW два движка перехвата. В Node (Vitest, Jest) setupServer патчит примитивы запросов — fetch, http, XMLHttpRequest — на уровне модулей: перехват внутрипроцессный и стоит сильно меньше миллисекунды на запрос. В браузере setupWorker регистрирует Service Worker, перехватывающий настоящие сетевые запросы на прокси-уровне платформы — реальные запросы, видимые в DevTools, на которые отвечают ваши хендлеры. Одно определение http.get("/api/orders", ...) — две среды исполнения. Эта симметрия и делает вложение в хорошую библиотеку хендлеров троекратно окупаемым: компонентные тесты, фикстуры Storybook и офлайн-разработка потребляют один источник правды о том, «что делает API».

Вспомните перед уходом
  1. 01
    Изложите механику findBy и три правила waitFor — с отказом, который каждое правило предотвращает.
  2. 02
    Почему мок на сетевом шве через MSW переживает мок fetch, и какие три детали настройки убирают целые категории флейков?
Итог

У асинхронного тестирования ровно один здравый примитив: ждать видимый пользователю результат. findBy воплощает его — getBy, завёрнутый в waitFor, опрос каждые 50 мс внутри дефолтного бюджета в 1 000 мс; обе ручки настраиваются на вызов или на сьют, когда железо CI честно медленнее ноутбуков — а расшаренный раннер с 2 vCPU под параллельными воркерами медленнее в 5–10 раз. waitFor несёт три правила с последствиями уровня инцидентов: одна проверка на колбэк, потому что цикл перевыполняет всё до тишины исключений; никаких побочных эффектов, потому что клик внутри колбэка перестреливает на каждом опросе — четырёхкратная отправка формы из Hook, замаскировавшая баг дедупликации вплоть до двойного списания; никогда не пустой, потому что waitFor от пустой функции — это сон с пристёгнутым ремнём. Исчезновение проверяется через waitForElementToBeRemoved над queryBy, а состояния загрузки — синхронно в момент, когда они обязаны существовать, потому что замоканные ответы приземляются в микротасках. На стороне моков долговечное решение — шов. Шпионаж за fetch привязывает тесты к транспортной функции, которую следующий рефакторинг переименует; MSW перехватывает на сетевой границе — http.get плюс HttpResponse описывают трафик, а не места вызова, — и миграция fetch → axios → react-query меняет ноль тестов. Трио настройки выполняет профилактику флейков: onUnhandledRequest error заставляет забытые моки падать громко, resetHandlers в afterEach не даёт оверрайдам протечь в порядко-зависимость, а server.use ограничивает 500 или сетевой сбой одним тестом — два переживания ошибки, которые серьёзный компонент различает. Одни хендлеры работают в Node через пропатченные примитивы и в браузере через Service Worker — потому одна библиотека хендлеров кормит тесты, Storybook и офлайн-разработку. И когда CI краснеет при зелёной локали, читайте это как баг модели времени в тесте, а не как погоду: бюджет, порядок или побочный эффект в цикле опроса — и никогда не тянитесь за retry. Теперь, когда встретите флейкующий асинхронный тест на ревью, первый вопрос — к какому из трёх механизмов он относится, а не сколько ретраев добавить.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.