Тест на компонент, который грузит данные, рано или поздно упирается в один и тот же вопрос. Откуда взять ответ сервера, если самого сервера в тесте нет. Подсунуть заглушку клиенту, перехватить вызов, вернуть заранее заготовленный объект. Способ подмены кажется деталью реализации, но именно он определяет, насколько тесты переживут смену библиотеки запросов, насколько легко переиспользовать заготовки между фронтендом и бэкендом и насколько правдоподобно поведение моков по сравнению с настоящей сетью.

На рынке JavaScript исторически боролись два подхода. Старая школа в лице nock перехватывает исходящие запросы на уровне модуля Node.js, подменяя нижележащую реализацию HTTP. Новая школа в лице MSW перехватывает запросы на уровне сети, то есть в той точке, где приложение уже отправило запрос наружу, но он ещё не ушёл в реальную инфраструктуру. Разница в точке перехвата выглядит академической ровно до момента, когда команда пытается переиспользовать моки в браузере и в Node разом, мигрировать с одного клиента на другой или объяснить, почему две заготовки одного и того же эндпоинта тихо разъехались по форме ответа.

Дальше идёт честное сравнение двух инструментов под реальные задачи React-приложений и Node-тестов. Кода ровно столько, сколько нужно увидеть, чтобы понять механику, и весь он вынесен в отдельные блоки с пояснениями вокруг. Сразу обозначу вывод, к которому пришла индустрия, чтобы дальше его обосновать, а не интриговать впустую. Для нового TypeScript-проекта, где есть и браузерные, и серверные тесты, MSW стал стандартом 2026 года. Но это не значит, что nock мёртв, и ниже будет видно, где он по-прежнему уместнее.

Две точки перехвата запроса и почему уровень сети честнее уровня модуля

Корень всех различий в том, на какой высоте инструмент ловит запрос. Nock встраивается в модуль HTTP самого Node.js и подменяет его внутренности. Когда код вызывает запрос, до настоящей сети дело не доходит, потому что nock перехватил вызов на уровне модуля и вернул заготовку. Подход работает много лет и остаётся быстрым, но у него есть врождённое ограничение. Он живёт только внутри Node, потому что подменяет именно его модуль. В браузере такого модуля нет, и nock там бесполезен.

MSW ловит запрос ниже, на сетевой границе. В браузере он регистрирует настоящий сервис-воркер, который перехватывает уходящие запросы ровно там, где они покидают приложение. В Node он дополняет стандартные сетевые модули через низкоуровневую прослойку перехватчиков. Принципиальное следствие в том, что приложение делает настоящий вызов запроса своим обычным клиентом, а MSW отвечает на сетевой границе, как если бы ответ пришёл с сервера. Код приложения при этом не меняется ни на строку ради моков.

Из разницы точек перехвата вытекает ключевое практическое свойство. MSW не зависит от того, каким клиентом сделан запрос. Нативный fetch, Axios, Apollo, React Query, что угодно. Поскольку перехват происходит ниже клиента, одни и те же заготовки работают независимо от библиотеки запросов. Nock же иногда вынужден опираться на дополнительные адаптеры под конкретные клиенты, и связка с Axios тут классический пример трения, о который спотыкаются команды.

Образно говоря, nock перекрывает воду в трубе конкретной квартиры, а MSW перекрывает её на вводе в дом. Если приложение однажды переедет с одного клиента запросов на другой, моки на уровне сети переезд переживут, а моки, прибитые к реализации клиента, придётся переписывать. Это и есть та устойчивость к смене инструмента, ради которой стоит выбирать точку перехвата осознанно.

Базовая настройка MSW для React через обработчики и серверную обёртку для тестов

Сердце MSW это обработчики запросов. Они описывают сеть один раз и переиспользуются везде. Обработчик состоит из двух частей. Предикат решает, какие запросы перехватывать, по методу и адресу. Резолвер решает, что вернуть в ответ. Актуальный API второй версии строится вокруг пространства имён для методов и помощника для формирования ответа.

// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw'

export const handlers = [
  http.get('https://api.example.com/user', () => {
    return HttpResponse.json({
      id: 'abc-123',
      firstName: 'John',
      lastName: 'Maverick',
    })
  }),
]

Маленькая, но важная оговорка про версии. Много устаревших материалов всё ещё показывают старый синтаксис первой версии с другими именами функций. Если в туториале встречается формирование ответа через контекстные функции вместо помощника ответа, это материал устаревшей эпохи, и опираться на него не стоит. Вторая версия сбросила API начисто и требует свежей линейки Node.

Сами по себе обработчики ничего не делают. Их нужно передать в одну из двух адаптерных функций под конкретное окружение. Для браузера это обёртка воркера, для Node это серверная обёртка. Прелесть в том, что список обработчиков один и тот же, меняется только адаптер.

// src/mocks/server.ts - серверная обёртка для тестов
import { setupServer } from 'msw/node'
import { handlers } from './handlers'

export const server = setupServer(...handlers)
// src/mocks/browser.ts - воркер для разработки и Storybook
import { setupWorker } from 'msw/browser'
import { handlers } from './handlers'

export const worker = setupWorker(...handlers)

Вот она, главная выгода MSW одной картинкой. Один файл обработчиков питает и серверную обёртку для тестов под Vitest или Jest, и воркер для режима разработки и витрины компонентов. Команды, переходившие с nock, регулярно обнаруживают при миграции, что у них было два набора моков, которые незаметно разъехались. Браузерный мок возвращал одну форму ответа, а серверный мок на nock другую. Консолидация в единый файл вскрывает это расхождение и лечит его в корне.

Подключение серверной обёртки к жизненному циклу тестов и чистый сброс между ними

Поднять обёртку мало, её нужно аккуратно встроить в жизненный цикл тестового набора. Перехват включается перед всеми тестами, обработчики сбрасываются после каждого теста, а перехват выключается после всех. Сброс между тестами это не формальность, а условие независимости. Без него обработчик, переопределённый в одном тесте, протечёт в следующий и отравит его.

import { beforeAll, afterEach, afterAll } from 'vitest'
import { server } from './mocks/server'

beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

Тест компонента после такой настройки читается естественно. Компонент рендерится, внутри его жизненного цикла происходит настоящий запрос, MSW перехватывает его на сетевой границе и отдаёт заготовку, а тест проверяет, что на экране появились ожидаемые данные. Именно этот сценарий объясняет, почему сдвиг индустрии к тестам уровня компонента так подружился с MSW. Запрос случается глубоко внутри жизненного цикла компонента, в месте, куда трудно дотянуться заглушкой клиента, а сетевой перехват достаёт туда прозрачно.

import { render, screen, waitFor } from '@testing-library/react'
import Dashboard from '../src/components/Dashboard'

test('показывает список постов', async () => {
  render(<Dashboard />)
  await waitFor(() => {
    expect(screen.getByText('Avoid Nesting When Testing')).toBeInTheDocument()
  })
})

Переопределение ответа под конкретный тест и проверка сценариев ошибок

Базовые обработчики описывают счастливый путь, но настоящая ценность тестов в проверке углов. Что покажет интерфейс при ошибке сервера, при пустом ответе, при медленной сети. MSW решает это переопределением обработчика на лету в рамках одного теста. Метод добавления обработчиков к текущей обёртке временно переопределяет поведение, а сброс после теста возвращает всё к базовым заготовкам.

import { http, HttpResponse } from 'msw'
import { server } from './mocks/server'

test('показывает ошибку при пятисотке', async () => {
  server.use(
    http.get('https://api.example.com/user', () => {
      return new HttpResponse(null, { status: 500 })
    })
  )
  render(<Profile />)
  await waitFor(() => {
    expect(screen.getByText('Что-то пошло не так')).toBeInTheDocument()
  })
})

Такой подход отделяет описание базовой сети от точечных отклонений. Базовый контракт лежит в общем файле обработчиков, а конкретный тест лишь объявляет своё локальное отклонение от него. Это чище, чем заводить отдельный набор заглушек под каждый сценарий, и заметно понятнее читается спустя полгода, когда к тесту возвращается уже другой человек.

Где nock остаётся уместнее и в чём его сильные стороны для чистого Node

Списывать nock со счетов было бы несправедливо и неверно. Инструмент существует около пятнадцати лет, и его еженедельная популярность по загрузкам всё ещё превышает популярность MSW, потому что он встроен в гигантское число существующих кодовых баз. Для чистого Node-проекта без браузерной части, где нужно просто перехватить исходящие вызовы во внешний API и проверить реакцию на разные ответы, nock остаётся быстрым и понятным выбором. Он легко ставится, легко читается и не тащит за собой концепцию сервис-воркера, лишнюю там, где браузера нет в принципе.

const nock = require('nock')

nock('https://api.example.com')
  .get('/user')
  .reply(200, { id: 'abc-123', firstName: 'John' })

У nock есть и приятная фишка, которой у MSW исторически не было в таком виде. Режим записи перехватывает настоящие исходящие запросы и автоматически генерирует из них заготовки, избавляя от ручного описания. Для покрытия тестами легаси-кода, который ходит в десяток внешних сервисов, это экономит уйму времени на первоначальную настройку.

Слабое место nock проявляется в двух плоскостях. Первая это упомянутая привязка к Node и трение с некоторыми клиентами через адаптеры. Вторая это типобезопасность. Nock не проверяет тело ответа против какой-либо схемы на уровне типов, поэтому заготовки на нём со временем уязвимее к дрейфу, когда реальный API эволюционировал, а мок остался прежним и тихо лжёт. MSW во второй версии, наоборот, делает ответы типобезопасными через помощник формирования ответа, и для проектов на контрактах вроде типизированных API-клиентов заготовки можно даже генерировать прямо из контракта, чтобы они не разъезжались с реальностью.

Практический выбор между инструментами по типу проекта и составу тестов

Свести всё к одной рекомендации заманчиво, но честнее держать границу по типу задачи. Чтобы не размывать выбор в сплошной текст, ниже единственный список в этой статье, расставляющий ориентиры по приоритету:

  1. Новый TypeScript-проект с фронтендом и бэкендом разом, где хочется один набор моков на всё, тяготеет к MSW как к стандарту, консолидирующему заготовки между браузером и Node;
  2. Существующая Node-кодовая база с уже написанными тестами на nock не требует срочной миграции, потому что nock остаётся хорош для чистого серверного перехвата и переписывание ради переписывания не окупится;
  3. Проект с тестами уровня компонента на React Testing Library и Vitest почти безальтернативно ведёт к MSW, потому что запрос внутри жизненного цикла компонента ловится прозрачно только на сетевой границе;
  4. Покрытие легаси-кода, который ходит во множество внешних сервисов, разумно начать с режима записи nock ради скорости старта, а потом при необходимости переехать на MSW;
  5. Проект на строгих контрактах, где важно ловить расхождение формы ответа на этапе компиляции, выигрывает от типобезопасных ответов MSW и генерации обработчиков из контракта.

Главный практический сигнал к миграции звучит так. Если в кодовой базе одновременно живут браузерные и серверные тесты, которые поддерживают два отдельных набора моков, переход на MSW окупается почти всегда. Единый файл обработчиков питает и витрину компонентов в режиме разработки, и интеграционные тесты, и дублирующиеся заготовки перестают тихо расходиться по форме ответа. Там же, где браузера нет и не предвидится, а тесты давно стоят на nock и всех устраивают, торопиться некуда.

Тонкости, о которые спотыкаются на практике, и как их обойти

Несколько граблей встречаются достаточно часто, чтобы упомянуть их отдельно. Первая касается смешения версий MSW. Свежий проект может случайно подтянуть устаревшие туториалы с синтаксисом первой версии, и тогда обработчики просто не заработают, потому что API сменился целиком. Лечится сверкой с актуальной документацией и проверкой, что код использует пространство имён методов и помощник ответа, а не старые контекстные функции.

Вторая грабля это переиспользование браузерных и серверных обработчиков один в один. Концепция единого файла обработчиков прекрасна, но у неё есть предел. Браузерные API вроде хранилища или объекта местоположения недоступны в Node, поэтому обработчик, который дёргает их внутри резолвера, в серверном окружении упадёт. Граница проста: общая логика ответа переиспользуется свободно, а специфичные для браузера вызовы выносятся отдельно.

Третья тонкость про чистоту состояния между тестами. Забытый сброс обработчиков после каждого теста это самая частая причина плавающих падений на MSW. Тест переопределил ответ под себя, не сбросил, и следующий тест получил чужую заготовку. Привычка ставить сброс в колбэк после каждого теста снимает целый класс таких загадок раз и навсегда.

Скорость, отладка и наблюдаемость моков в обоих инструментах

Помимо архитектуры выбор инструмента влияет на повседневный комфорт отладки, и тут у подходов разный характер. Nock работает синхронно близко к коду и при провале матчинга выдаёт довольно прямое сообщение о том, какой запрос остался неперехваченным. Это удобно для быстрой локализации в чистом Node, где весь поток запросов на виду. Обратная сторона в том, что при сложной комбинации перехватчиков и реальных вызовов легко получить запутанную картину, особенно если забыть проверить, что все объявленные перехваты действительно сработали.

MSW в браузере даёт то, чего у nock нет в принципе. Перехваченные запросы видны в инструментах разработчика как настоящие сетевые вызовы, потому что перехват идёт через сервис-воркер на сетевом уровне. Разработчик открывает вкладку сети и видит мок так же, как видел бы реальный ответ сервера. Это резко упрощает понимание, почему компонент показал именно эти данные. По умолчанию неперехваченные запросы MSW пропускает к реальной сети, но поведение настраивается: строгий режим заставляет падать на любом необъявленном запросе, что полезно, чтобы тест не уходил случайно в настоящий внешний сервис.

По скорости оба инструмента работают в памяти процесса и не поднимают настоящих серверов, поэтому накладные расходы у обоих невелики по сравнению с реальной сетью. Разница в граммах, а не в килограммах, и на выбор почти не влияет. Решает не скорость, а переносимость заготовок и независимость от клиента запросов, о которых шла речь выше. Тем, кто строит мок-архитектуру с нуля на TypeScript, осмысленно сразу закладывать единый файл обработчиков и типобезопасные ответы, потому что переписывать разъехавшиеся моки потом дороже, чем устроить их правильно один раз.

Итог укладывается в одну мысль. Точка перехвата запроса это не деталь реализации, а фундамент устойчивости тестов. Nock ловит на уровне модуля Node и потому быстр и хорош внутри чистого сервера, но прибит к нему и уязвим к смене клиента и дрейфу моков. MSW ловит на уровне сети и потому одинаково работает в браузере и в Node, не зависит от клиента запросов, переиспользует один набор заготовок между тестами и разработкой и даёт типобезопасные ответы. Для нового кода с обеими сторонами стека выбор очевиден в сторону MSW. Для устоявшегося Node-сервера на nock спешка не нужна. А настоящая зрелость приходит, когда команда выбирает инструмент не по моде, а по тому, где именно её код пересекает сетевую границу.