Тестирование компонентов на основе API в Storybook
Используйте MSW в Storybook 10 для тестирования API-компонентов в состояниях загрузки, ошибки, пустого ответа и успеха, затем превращайте истории в тесты.
Компонент, выполняющий запросы данных, зависнет на «Loading…» или выбросит исключение в Storybook, поскольку там нет бэкенда для обработки запросов — решение заключается в перехвате запросов на сетевом уровне с помощью Mock Service Worker, а не в подмене хука.
Если вы когда-либо наблюдали, как компонент застывает на «Loading…» в Storybook без каких-либо объяснений в консоли, — причина именно в этом: нет сервера, который мог бы ответить на запрос, отправляемый при монтировании. Настройте мок один раз, и каждая написанная вами история получит такую же обработку автоматически.
В этом руководстве описывается настройка msw-storybook-addon (требующего MSW 2.x) в Storybook 10, создание компонента UserList с четырьмя историями (успех, загрузка, ошибка и пустой результат), а также преобразование этих смоделированных состояний в автоматизированные интеграционные тесты.
Ключевые выводы
- Выполняйте мокирование на сетевом уровне, чтобы один набор MSW-обработчиков работал без изменений в Storybook, в юнит-тестах на базе Node и в визуальной регрессии Chromatic.
- В MSW v2 обработчик успешного ответа записывается как
http.get(url, () => HttpResponse.json(data));rest.getи сигнатура резолвера(req, res, ctx) => res(ctx.json())больше не существуют. - Смоделируйте постоянное состояние загрузки с помощью
await delay('infinite'), ошибку — черезHttpResponse.json(null, { status: 500 }), а пустой результат — возвращаяHttpResponse.json([]). - Подключите аддон один раз, добавив
mswLoaderв массивloadersв.storybook/preview, и обеспечьте раздачу воркера, указавstaticDirsв основном конфиге. - Дополните каждое смоделированное состояние функцией
play, чтобы состояние проверялось автоматически. История с функциейplayстановится компонентным тестом.
Почему компоненты с запросами данных ломаются в Storybook?
Storybook рендерит компоненты изолированно — без оболочки приложения и без сервера. «Компонент приложения», вызывающий fetch, useQuery или Apollo при монтировании, отправляет запрос, на который никто не отвечает, поэтому он либо вечно остаётся на ветке загрузки, либо выбрасывает исключение при отклонении промиса. Первый порыв — подменить хук: заменить useQuery моком, возвращающим заготовленные данные. Не делайте этого. Подмена хука привязывает историю к конкретной библиотеке данных и к внутренней структуре компонента, а сам мок становится бесполезным, как только вы захотите воспроизвести тот же сценарий в Node-тесте.
Вместо этого выполняйте мокирование на сетевом уровне. MSW регистрирует сервис-воркер, который перехватывает исходящие запросы в браузере и возвращает определённые вами ответы, — таким образом компонент выполняет свой реальный код запроса данных без изменений. Те же обработчики работают под Node через setupServer, что и обеспечивает их переносимость между Storybook, Vitest и CI. Вы описываете сценарий один раз — и он работает везде, где запускается компонент.
Discover how at OpenReplay.com.
Как настроить стек мокирования API в Storybook?
Установите оба пакета, сгенерируйте сервис-воркер, зарегистрируйте загрузчик и укажите Storybook путь к файлу воркера. Эта однократная настройка соответствует руководству Storybook по мокированию сетевых запросов.
npm install msw msw-storybook-addon --save-dev
npx msw init public/
npx msw init public/ записывает mockServiceWorker.js в вашу статическую директорию. Зарегистрируйте аддон глобально, добавив mswLoader в loaders в .storybook/preview.ts. Загрузчики запускаются до рендеринга истории, именно поэтому аддон использует загрузчик, а не декоратор:
import type { Preview } from '@storybook/react-vite';
import { initialize, mswLoader } from 'msw-storybook-addon';
initialize();
const preview: Preview = {
loaders: [mswLoader],
};
export default preview;
Затем обеспечьте раздачу сгенерированного воркера, указав папку public в staticDirs в файле .storybook/main.ts. Флаг start-storybook -s public больше не существует в Storybook 10:
import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
framework: '@storybook/react-vite',
stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'],
staticDirs: ['../public'],
};
export default config;
История успешного ответа
Вот тестируемый компонент — UserList, который получает массив данных и отображает одну из четырёх веток UI. Обратите внимание на доступные атрибуты role="status" и role="alert", по которым впоследствии будут делаться запросы в тестах.
// UserList.tsx
import { useEffect, useState } from 'react';
type User = { id: number; name: string };
const endpoint = 'https://api.example.com/users';
export function UserList() {
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
const [users, setUsers] = useState<User[]>([]);
useEffect(() => {
fetch(endpoint)
.then((res) => {
if (!res.ok) throw new Error(res.statusText);
return res.json();
})
.then((data) => {
setUsers(data);
setStatus('success');
})
.catch(() => setStatus('error'));
}, []);
if (status === 'loading') return <p role="status">Loading…</p>;
if (status === 'error') return <p role="alert">Something went wrong.</p>;
if (users.length === 0) return <p>No users yet.</p>;
return (
<ul>
{users.map((u) => (
<li key={u.id}>{u.name}</li>
))}
</ul>
);
}
Задавайте обработчики для каждой истории через parameters.msw.handlers. В MSW v2 http.get заменяет rest.get, а класс HttpResponse заменяет утилиты ctx:
// UserList.stories.tsx
import type { Meta, StoryObj } from '@storybook/react-vite';
import { http, HttpResponse, delay } from 'msw';
import { UserList } from './UserList';
const endpoint = 'https://api.example.com/users';
const meta = { component: UserList } satisfies Meta<typeof UserList>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Success: Story = {
parameters: {
msw: {
handlers: [
http.get(endpoint, () =>
HttpResponse.json([
{ id: 1, name: 'Ada Lovelace' },
{ id: 2, name: 'Alan Turing' },
]),
),
],
},
},
};
Моделирование всех состояний
На «счастливом пути» большинство руководств останавливается — а это наименее интересная история. Ценность мокирования на сетевом уровне в том, что одна замена обработчика воспроизводит любое состояние, в которое может перейти компонент. Записи сессий в API-ориентированных интерфейсах регулярно выявляют состояния, которые разработчики никогда не прорабатывали в историях: спиннер, который никогда не исчезает из-за зависшего запроса, или пустой ответ, который отображается как сломанная вёрстка вместо специального пустого состояния. Именно в Storybook с MSW вы описываете и проверяете эти состояния до выпуска в продакшн.
| Состояние | Обработчик | Что проверяет |
|---|---|---|
| Загрузка | await delay('infinite') перед ответом | Pending-интерфейс отображается и не мигает |
| Ошибка | HttpResponse.json(null, { status: 500 }) | Ветка ошибки обрабатывает 5xx |
| Пустой результат | HttpResponse.json([]) | Вёрстка для нулевого количества результатов спроектирована, а не сломана |
Функция delay принимает режим 'infinite', который удерживает запрос в состоянии ожидания бесконечно, — это надёжный способ заморозить компонент на ветке загрузки. Импортируйте delay из msw и используйте await внутри асинхронного резолвера:
export const Loading: Story = {
parameters: {
msw: {
handlers: [
http.get(endpoint, async () => {
await delay('infinite');
return HttpResponse.json([]);
}),
],
},
},
};
export const Error: Story = {
parameters: {
msw: { handlers: [http.get(endpoint, () => HttpResponse.json(null, { status: 500 }))] },
},
};
export const Empty: Story = {
parameters: {
msw: { handlers: [http.get(endpoint, () => HttpResponse.json([]))] },
},
};
MSW мокирует GraphQL аналогичным образом (graphql.query('AllUsers', () => HttpResponse.json({ data }))), поэтому паттерн применим к Apollo, urql и React Query без изменений.
От просмотра к тестированию
Смоделированная история, на которую вы только смотрите, — это документация; добавьте функцию play, и она станет автоматизированным компонентным тестом. Импортируйте expect из storybook/test (актуальный модуль, заменивший @storybook/test из Storybook 8) и проверяйте результат рендеринга:
import { expect } from 'storybook/test';
// для Success:
play: async ({ canvas }) => {
await expect(await canvas.findByText('Ada Lovelace')).toBeInTheDocument();
},
// для Loading:
play: async ({ canvas }) => {
await expect(canvas.getByRole('status')).toBeInTheDocument();
},
Для истории Loading с бесконечной задержкой проверяйте только наличие спиннера. Не ожидайте завершения запроса — он намеренно остаётся в состоянии ожидания бесконечно. Эти истории запускаются через аддон Vitest, который выполняет их как компонентные тесты в браузере Chromium от Playwright — из интерфейса Storybook, терминала или CI. Поскольку MSW-обработчики не зависят от среды выполнения, те же обработчики для состояний успеха, ошибки и пустого результата используются в отдельном Vitest-тесте через setupServer, а Chromatic делает снимок каждой истории для визуальной регрессии.
Выполняйте мокирование на сетевом уровне, моделируйте все четыре состояния и добавляйте функцию play к каждому из них — это превращает папку с историями в живой набор тестов, который выявляет баги с бесконечной загрузкой и сломанным пустым состоянием до выхода в продакшн. Начните с добавления историй для состояний загрузки, ошибки и пустого результата к одному из существующих компонентов приложения прямо сейчас. Написанные там обработчики будут повторно использоваться в ваших Vitest-тестах.
Часто задаваемые вопросы
Почему мой компонент застрял на 'Loading…' в Storybook даже после установки msw-storybook-addon?
Компонент завис, потому что либо ни один MSW-обработчик не соответствует его запросу, либо загрузчик аддона не подключён. Убедитесь, что вы добавили mswLoader в массив loaders в .storybook/preview и вызвали initialize(); что файл public был сгенерирован с помощью npx msw init и указан в staticDirs; и что обработчик в parameters.msw.handlers точно соответствует URL и методу запроса. Несовпадение URL оставляет запрос необработанным, и компонент вечно остаётся в состоянии ожидания.
В чём разница между мокированием на сетевом уровне с помощью MSW и подменой хука fetch?
Мокирование на сетевом уровне с помощью MSW перехватывает реальный исходящий запрос и возвращает ответ, поэтому компонент выполняет свой реальный код запроса данных без изменений, а одни и те же обработчики работают в браузере, в Node через setupServer и в Chromatic. Подмена хука заменяет useQuery или fetch заготовленными данными, что привязывает историю к конкретной библиотеке данных и к внутренней структуре компонента, и не позволяет повторно использовать её в Node-тесте.
Работает ли msw-storybook-addon с обработчиками MSW v1, такими как rest.get и res(ctx.json())?
Нет. Начиная с версии 2.0.0 аддон требует MSW 2.0.0 или выше, а MSW v2 удалил пространство имён rest и сигнатуру резолвера res(ctx.json()). Перепишите обработчики, используя http.get и класс HttpResponse, например: http.get(url, () => HttpResponse.json(data)). Код MSW v1 не будет работать с текущим аддоном и должен быть перенесён согласно официальному руководству по миграции с MSW 1.x на 2.x.
Почему моя история с бесконечной задержкой зависает при запуске в виде теста?
История, использующая await delay('infinite'), намеренно удерживает запрос в состоянии ожидания бесконечно, поэтому функция play, ожидающая разрешения UI, никогда не завершится. Проверяйте только наличие pending-интерфейса — например, что спиннер с role status отображается — вместо того чтобы ожидать завершения запроса. Если Node- или Vitest-тест должен завершаться корректно, используйте конечную задержку, например delay(1000), вместо бесконечного режима для этого сценария.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k