API Mocking in Storybook with MSW: REST, GraphQL, and Play Functions

API Mocking in Storybook with MSW: REST, GraphQL, and Play Functions

Components that fetch data are among the hardest to develop in isolation. Without a running backend, you cannot see loading states, you cannot force error conditions, and you cannot test how the UI behaves when an API returns unexpected data. Mock Service Worker (MSW) solves this by intercepting network requests at the service worker level — meaning your components make real fetch calls that get intercepted before they hit the network, with no changes to the component code itself.

The msw-storybook-addon integrates MSW directly into Storybook, giving you per-story network mocking that works in both the browser UI and in the test runner.

Why MSW Over Manual Mocking

Before MSW, the options were: mock the fetch/axios module in each story (fragile, couples stories to implementation), use a fixture library that returns canned data (doesn't test the actual data flow), or point stories at a real staging API (slow, unreliable, requires a running server).

MSW intercepts at the network layer. Your component calls fetch('/api/users') the same way it always does. MSW's service worker intercepts that request before it leaves the browser and returns whatever response you configured. The component sees a real HTTP response with proper status codes and headers. No mocking of modules, no stubs, no changes to component code.

Installation

npm install --save-dev msw msw-storybook-addon

Initialize the MSW service worker:

npx msw init public/ --save

This creates public/mockServiceWorker.js. Commit this file — it's not generated code that changes between runs, and your teammates need it.

Storybook Configuration

In .storybook/preview.js (or .storybook/preview.ts):

// .storybook/preview.ts
import { initialize, mswLoader } from 'msw-storybook-addon';

// Initialize MSW
initialize({
  onUnhandledRequest: 'warn', // or 'bypass' to silently pass through
});

const preview = {
  loaders: [mswLoader],
  parameters: {
    // Global handlers that apply to ALL stories
    msw: {
      handlers: [
        // You can add global fallback handlers here
      ],
    },
  },
};

export default preview;

The onUnhandledRequest: 'warn' option is valuable during development — it logs a warning in the console whenever a story makes a network request that has no handler defined. This helps you discover which API calls your components are making.

Basic REST Handler

Here is a component story that mocks a user profile endpoint:

// UserProfile.stories.ts
import type { Meta, StoryObj } from '@storybook/react';
import { http, HttpResponse } from 'msw';
import { UserProfile } from './UserProfile';

const meta: Meta<typeof UserProfile> = {
  component: UserProfile,
};

export default meta;
type Story = StoryObj<typeof UserProfile>;

export const Loaded: Story = {
  parameters: {
    msw: {
      handlers: [
        http.get('/api/users/:id', () => {
          return HttpResponse.json({
            id: '123',
            name: 'Jane Smith',
            email: 'jane@example.com',
            role: 'admin',
            avatar: 'https://i.pravatar.cc/150?img=1',
          });
        }),
      ],
    },
  },
};

Per-story handlers in parameters.msw.handlers override global handlers for that story only. When the story unmounts, MSW removes those handlers. This isolation means your stories don't interfere with each other's network behavior.

Testing Loading, Error, and Empty States

This is where MSW shines. You can force any state the backend might return:

// UserProfile.stories.ts (continued)

export const Loading: Story = {
  parameters: {
    msw: {
      handlers: [
        http.get('/api/users/:id', async () => {
          // Never resolve — the component stays in loading state forever
          await new Promise(() => {});
        }),
      ],
    },
  },
};

export const NetworkError: Story = {
  parameters: {
    msw: {
      handlers: [
        http.get('/api/users/:id', () => {
          return HttpResponse.error();
        }),
      ],
    },
  },
};

export const ServerError: Story = {
  parameters: {
    msw: {
      handlers: [
        http.get('/api/users/:id', () => {
          return HttpResponse.json(
            { message: 'Internal server error', code: 'ERR_SERVER' },
            { status: 500 }
          );
        }),
      ],
    },
  },
};

export const NotFound: Story = {
  parameters: {
    msw: {
      handlers: [
        http.get('/api/users/:id', () => {
          return new HttpResponse(null, { status: 404 });
        }),
      ],
    },
  },
};

export const EmptyState: Story = {
  parameters: {
    msw: {
      handlers: [
        http.get('/api/users', () => {
          return HttpResponse.json({ users: [], total: 0 });
        }),
      ],
    },
  },
};

Each of these is a distinct story in your Storybook. Designers can review the loading skeleton, the error message, and the empty state independently. QA can verify each state visually. And your visual regression tool captures all of them.

GraphQL Mocking

MSW supports GraphQL queries and mutations through the graphql handler:

// Dashboard.stories.ts
import { graphql, HttpResponse } from 'msw';

export const DashboardLoaded: Story = {
  parameters: {
    msw: {
      handlers: [
        graphql.query('GetDashboardData', () => {
          return HttpResponse.json({
            data: {
              metrics: {
                totalUsers: 1234,
                activeToday: 89,
                conversionRate: 3.2,
              },
              recentActivity: [
                { id: '1', type: 'signup', timestamp: '2024-01-15T10:30:00Z' },
                { id: '2', type: 'purchase', timestamp: '2024-01-15T09:15:00Z' },
              ],
            },
          });
        }),

        graphql.mutation('UpdateUserRole', ({ variables }) => {
          return HttpResponse.json({
            data: {
              updateUserRole: {
                id: variables.userId,
                role: variables.newRole,
                updatedAt: new Date().toISOString(),
              },
            },
          });
        }),
      ],
    },
  },
};

You can also simulate GraphQL errors:

export const GraphQLError: Story = {
  parameters: {
    msw: {
      handlers: [
        graphql.query('GetDashboardData', () => {
          return HttpResponse.json({
            errors: [
              {
                message: 'Unauthorized',
                extensions: { code: 'UNAUTHENTICATED' },
              },
            ],
          });
        }),
      ],
    },
  },
};

Request Inspection in Play Functions

Play functions can assert on what requests were made, not just on the resulting DOM state. This is useful for verifying that form submissions send the correct data:

// CreateUserForm.stories.ts
import { expect, userEvent, within } from '@storybook/test';
import { http, HttpResponse } from 'msw';

let capturedRequestBody: unknown = null;

export const SuccessfulSubmit: Story = {
  parameters: {
    msw: {
      handlers: [
        http.post('/api/users', async ({ request }) => {
          capturedRequestBody = await request.json();
          return HttpResponse.json(
            { id: 'new-user-id', ...capturedRequestBody },
            { status: 201 }
          );
        }),
      ],
    },
  },
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);

    await userEvent.type(canvas.getByLabelText('Name'), 'Alice Johnson');
    await userEvent.type(canvas.getByLabelText('Email'), 'alice@example.com');
    await userEvent.selectOptions(canvas.getByLabelText('Role'), 'editor');
    await userEvent.click(canvas.getByRole('button', { name: 'Create User' }));

    // Assert the success message appears
    await expect(
      canvas.getByText('User created successfully')
    ).toBeInTheDocument();

    // Assert the request contained the correct data
    expect(capturedRequestBody).toEqual({
      name: 'Alice Johnson',
      email: 'alice@example.com',
      role: 'editor',
    });
  },
};

The combination of DOM assertions and request body assertions gives you confidence that both the UI feedback and the API call are correct.

Simulating Response Delays

Real APIs have latency. Testing with instant responses can hide timing bugs — components that don't handle slow responses correctly, race conditions when users click submit twice, etc.

import { delay } from 'msw';

export const SlowNetwork: Story = {
  parameters: {
    msw: {
      handlers: [
        http.get('/api/data', async () => {
          await delay(2000); // 2 second delay
          return HttpResponse.json({ items: mockItems });
        }),
      ],
    },
  },
};

export const RealWorldLatency: Story = {
  parameters: {
    msw: {
      handlers: [
        http.get('/api/data', async () => {
          await delay('real'); // MSW's realistic delay simulation
          return HttpResponse.json({ items: mockItems });
        }),
      ],
    },
  },
};

The delay('real') mode simulates realistic network timing based on response payload size. This is useful for catching loading states that flash too briefly to be seen with instant responses.

Composing Handlers for Complex Scenarios

As your handler library grows, it helps to compose handlers from shared pieces:

// stories/handlers/users.ts
import { http, HttpResponse } from 'msw';

export const userHandlers = {
  success: http.get('/api/users/:id', ({ params }) => {
    return HttpResponse.json(mockUserById(params.id as string));
  }),

  notFound: http.get('/api/users/:id', () => {
    return new HttpResponse(null, { status: 404 });
  }),

  serverError: http.get('/api/users/:id', () => {
    return new HttpResponse(null, { status: 500 });
  }),
};

// stories/handlers/posts.ts
export const postHandlers = {
  list: http.get('/api/posts', () => {
    return HttpResponse.json({ posts: mockPosts, total: mockPosts.length });
  }),
};

Then in stories:

// UserPosts.stories.ts — a component that fetches both user and posts
import { userHandlers, postHandlers } from '../handlers';

export const Default: Story = {
  parameters: {
    msw: {
      handlers: [userHandlers.success, postHandlers.list],
    },
  },
};

export const UserNotFound: Story = {
  parameters: {
    msw: {
      handlers: [userHandlers.notFound],
    },
  },
};

This composition pattern prevents handler duplication across stories and makes it obvious what each story's network behavior is.

Handler Execution Count Assertions

For scenarios like retry logic, you might want to assert that the component retried a failed request exactly once. MSW doesn't provide this out of the box, but you can instrument handlers:

let requestCount = 0;

export const RetryOnFailure: Story = {
  parameters: {
    msw: {
      handlers: [
        http.get('/api/data', () => {
          requestCount++;
          if (requestCount === 1) {
            return new HttpResponse(null, { status: 503 });
          }
          return HttpResponse.json({ data: 'success' });
        }),
      ],
    },
  },
  play: async ({ canvasElement }) => {
    // Reset counter
    requestCount = 0;

    const canvas = within(canvasElement);

    // Wait for the retry to complete
    await expect(await canvas.findByText('Data loaded')).toBeInTheDocument();

    // Verify exactly 2 requests were made (1 failure + 1 retry)
    expect(requestCount).toBe(2);
  },
};

Using with the Test Runner

Stories with MSW handlers work in the test runner without any additional configuration. The test runner runs stories in a real Chromium browser where service workers are fully supported, so MSW intercepts requests exactly as it does in the browser UI.

One gotcha: the service worker file (public/mockServiceWorker.js) must be served by your static file server. When running build-storybook and serving the output, the public/ directory contents are copied to the build output, so this works automatically.

Comparing msw-storybook-addon vs storybook-addon-mock

There are two popular options for API mocking in Storybook:

msw-storybook-addon uses real service workers and the full MSW API. It works at the network layer, supports all HTTP methods, supports GraphQL, can simulate real delays, and the same handlers can be reused in Jest/Vitest tests. The trade-off is a slightly more involved setup (service worker initialization).

storybook-addon-mock is simpler to set up but works by monkey-patching fetch and XMLHttpRequest. It doesn't support service workers, doesn't work in Node environments (so it can't be used with the test runner), and has a less expressive handler API. It's a reasonable choice for simple cases in browser-only Storybook viewing, but if you want test runner support, stick with MSW.

For most teams, the msw-storybook-addon is the better long-term choice. The service worker approach is more reliable, more featureful, and aligns with how MSW is used in application tests.

Taking It Further

Stories with comprehensive MSW handlers become executable specifications for your API contract. They document exactly what data shape each component expects and how it handles edge cases. When your backend changes, broken stories are early warnings that the frontend needs to update.

For production monitoring — ensuring that these carefully tested API interaction patterns hold up against your real backend — HelpMeTest provides continuous end-to-end test execution that runs your full application flows against real infrastructure, catching integration regressions that isolated story tests cannot detect.

The combination of MSW-mocked stories (fast, isolated, comprehensive) and production monitoring (real network, real auth, real data) gives you defense in depth across your entire testing strategy.

Read more

Start now free