Клиентский код, ходящий в сеть, тестируют, подменяя не fetch, а саму сетевую границу. Разница принципиальна: при подмене fetch тест привязан к конкретному клиенту и ломается, если завтра проект перейдёт с fetch на axios. Перехват на уровне сети оставляет код под тестом нетронутым - он делает настоящий запрос, а инструмент отвечает вместо сервера. Это и есть подход, который рекомендует Testing Library. Вдобавок в тест приходят настоящие объекты Request и Response, а не самодельные заглушки клиента, которые легко разойдутся с реальностью.
Инструмент для этого - MSW (Mock Service Worker). В Node он поднимает сервер-перехватчик через setupServer со списком обработчиков. Обработчик описывает маршрут: http.get или http.post и ответ через HttpResponse.json с телом и статусом. Код приложения зовёт обычный fetch по своему URL, а MSW ловит запрос и отдаёт заданный ответ, не подменяя ни клиент, ни сам вызов.
Жизненный цикл сервера-перехватчика прописывают вокруг тестов. beforeAll вызывает server.listen с onUnhandledRequest: 'error', чтобы незамоканный запрос падал явно, а не уходил в реальную сеть. afterEach вызывает server.resetHandlers, снимая временные переопределения. afterAll вызывает server.close. Каждый шаг закрывает свой риск: старт, изоляцию между тестами и корректное завершение.
Отдельные тесты переопределяют ответ через server.use. Он добавляет обработчик поверх базовых - можно вернуть 500, пустое тело или медленный ответ и проверить, как интерфейс ведёт себя в этом случае. Именно так тестируют ветку ошибки, не трогая код компонента: сервер отвечает иначе, а приложение проходит свой обычный путь обработки.
Это даёт полную тройку состояний: загрузка, успех и ошибка. Наивные тесты часто проверяют только успешный ответ, потому что мок fetch под ошибку писать муторно. С MSW ошибка - это одна строка server.use с нужным статусом, поэтому пустой список, 500 и таймаут проверяются так же легко, как и счастливый путь, и не остаются белым пятном.
MSW v2 построен на примитивах Fetch API. В резолвере доступен настоящий Request: тело читают через await request.json(), а ответ формируют объектом HttpResponse, а не устаревшими res и ctx. Это те же типы, что и в браузере, поэтому обработчики читаются как обычный серверный код и переносятся между тестами и разработческим моком без переписывания.
Контраст с моком http-модуля через vi.mock показателен. Мок клиента подменяет конкретную функцию и намертво связывает тест с тем, как именно код ходит в сеть; смена клиента ломает все такие тесты разом. MSW стоит на сетевой границе, ниже клиента, поэтому переживает смену библиотеки: меняется способ отправки запроса, а контракт сети остаётся тем же.
Типичный провал - забыть server.resetHandlers в afterEach. Тогда временный обработчик из одного теста протекает в следующие, и результат начинает зависеть от порядка: тест зелёный в одиночку и красный в наборе. Второй провал - всё-таки мокнуть fetch напрямую: тест становится хрупким и проверяет реализацию клиента, а не поведение приложения на границе сети.
npm i -D msw
import { setupServer } from 'msw/node'
import { http, HttpResponse } from 'msw'
const server = setupServer(
http.get('/api/cart', () =>
HttpResponse.json({ items: [{ sku: 'A', quantity: 1 }] })),
)
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())test('показывает ошибку при сбое сервера', async () => {
server.use(
http.get('/api/cart', () => HttpResponse.json(null, { status: 500 })),
)
render(<Cart />)
expect(await screen.findByRole('alert')).toHaveTextContent(/не удалось/i)
})