Компонент Cart показывает содержимое корзины и кнопку оформить заказ, а данные берёт из сети: GET /api/cart за товарами и POST /api/orders при подтверждении покупки. Мок сети - это подмена не вашего кода, а самого HTTP-обмена: тестируемый компонент честно вызывает fetch, но запрос перехватывают и возвращают заранее заданный ответ. Граница подмены здесь - сеть, а не функция клиента, и это решает всё.
Наивный ход - замокать сам fetch. Одна строка global.fetch = jest.fn() с mockResolvedValue, и тест получает любой ответ. Это естественно: fetch - глобальная функция, и кажется, что ответ под контролем.
Ломается это на стыке с реальностью. Тест привязан к клиенту: замените fetch на axios, и мок развалится, хотя поведение то же. И заглушка - не настоящий ответ: нет полей ok, status, headers и честного json, приходится вручную лепить объект, который лишь притворяется Response, и легко забыть поле, которое код читает. Граница подменена слишком рано - часть пути от запроса до разбора ответа не исполняется.
MSW - Mock Service Worker - разворачивает подход: он перехватывает запрос на границе сети, а не внутри кода. Модуль под тестом вызывает fetch без правок, MSW ловит запрос и отдаёт ответ, который вы описали. Именно это рекомендует Testing Library: чем меньше кода трогаешь ради теста, тем ближе он к реальному использованию. Смените fetch на axios - тесты не заметят, оба ходят в одну сеть.
В Node MSW поднимается через setupServer - не сервер на порту, а перехватчик исходящих запросов. Вы описываете обработчики: http.get и http.post принимают путь и резолвер, возвращающий HttpResponse.json с телом и статусом. Список обработчиков задаёт счастливый путь - корзину и успешный заказ.
Обвязка жизненного цикла - три строки, у каждой своя причина. server.listen в beforeAll включает перехват до первого теста; опция onUnhandledRequest: 'error' роняет тест на запросе без обработчика - на значении по умолчанию warn такой запрос лишь получил бы предупреждение в логе и ушёл бы в настоящую сеть. server.resetHandlers в afterEach снимает переопределения теста. server.close в afterAll выключает перехват, чтобы он не тёк в другие файлы.
Базовые обработчики описывают штатную работу. Крайние случаи задаются точечно через server.use - он кладёт обработчик поверх базовых на время теста. Так проверяются три состояния: загрузка, пока ответ в пути; успех с данными; ошибка, когда сервер вернул 500. Тем же приёмом задаётся медленный ответ через задержку, чтобы поймать спиннер.
Альтернатива - замокать http-модуль через jest.mock('axios'). Формально работает, но возвращает к той же связке: тест снова знает имя клиента и снова получает не настоящий Response. Для сетевой границы MSW строго предпочтительнее - он изолирует по реальному стыку, а не по библиотеке. Отсюда главные грабли: забыли resetHandlers - переопределение из одного теста утекает в следующий, давая порядок-зависимый флак; оставили onUnhandledRequest по умолчанию warn - опечатка в пути не роняет тест, а превращается в предупреждение, которое легко проглядеть.
MSW версии 2 построен на примитивах Fetch API - те же Request и Response, что и в браузере, а HttpResponse - тонкая обёртка над Response. Тело читается через await request.json, а ответ собирается так, как его увидит настоящий код. Провальный сценарий теперь ловится: мокая fetch, тест на 500 проверял бы вашу же выдумку об ошибке; с MSW код проходит настоящий разбор, и если обработка статуса сломана, тест падает там же, где сломался бы у пользователя.
npm i -D msw
import { setupServer } from 'msw/node'
import { http, HttpResponse } from 'msw'
export const server = setupServer(
http.get('/api/cart', () => {
return HttpResponse.json({ items: [{ id: 'sku-1', qty: 2 }] })
}),
http.post('/api/orders', async ({ request }) => {
const order = await request.json()
return HttpResponse.json({ id: 'order-1', ...order }, { status: 201 })
}),
)
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())test('показывает ошибку, если заказ не оформился', async () => {
server.use(
http.post('/api/orders', () => {
return HttpResponse.json({ error: 'Server error' }, { status: 500 })
}),
)
const user = userEvent.setup()
render(<Cart />)
await user.click(
await screen.findByRole('button', { name: /оформить заказ/i }),
)
expect(await screen.findByRole('alert')).toHaveTextContent(
/не удалось оформить заказ/i,
)
})