Web Development
ReactTestingMSWStorybookVitestReact Testing LibraryCI/CD

React Component Contract Testing: MSW + Storybook as Living API Mocks

AO
Adrijan Omićević
·14 min read

# What You’ll Build and Why It Matters#

React component contract testing means validating a component against a realistic set of API behaviors: success, empty states, validation errors, timeouts, and authorization failures. The focus is not only on rendering but on the contract between UI and backend.

This matters because UI regressions often come from “mock drift”: Storybook uses one fake response, tests use another, and production uses a third. The result is flaky UI, inconsistent states, and bugs that only show up after release.

This guide shows a practical setup where MSW handlers are the single source of truth for both Storybook and tests. You get living API mocks, reviewable in PRs, and enforced in CI.

If you want the broader testing pyramid and toolchain context, read our baseline strategy first: React testing strategy with Vitest, React Testing Library, and MSW.

# Prerequisites#

RequirementVersionNotes
Node.js18+LTS recommended
React18+Works with 19 too, but examples use 18 conventions
Storybook7.6+Examples assume modern Storybook config
MSW2+Uses http and HttpResponse APIs
Vitest1.5+Works with Vite-based React apps
React Testing Library14+For user-focused tests

# What “Contract Testing” Means at Component Level#

Component contract testing is not about verifying backend schema correctness. It’s about verifying UI behavior given known API contracts.

A component contract includes:

Contract elementExampleWhat to assert
Request shapeGET /api/orders?page=1Component requests expected URL and params
Response shapeitems, total, paginationUI renders data and pagination correctly
Error behavior401, 500, 422Correct messages, retry actions, and navigation
Timingslow responsesLoading skeletons and disabled actions
Edge casesempty arrays, missing optional fieldsEmpty state copy and fallback UI

A good contract test suite usually covers 6 to 12 “UI-meaningful” API scenarios per feature. That is enough to prevent most regressions without exploding maintenance cost.

🎯 Key Takeaway: Treat MSW handlers as versioned, reviewable “API contracts” for the UI, not as throwaway test stubs.

# Folder Structure: One Source of Truth for Mocks#

The simplest way to prevent drift is to put handlers in a shared module and import them from both Storybook and test setup.

A practical structure:

PathPurpose
src/mocks/handlers/Shared request handlers, grouped by domain
src/mocks/scenarios/Composed handler sets for specific states
src/mocks/browser.tsMSW worker for Storybook and local dev
src/mocks/server.tsMSW server for unit and integration tests
src/mocks/test-data/Factories and fixtures used by handlers
src/components/...Components and stories

This separation keeps “what the API does” in handlers, and “what state we want” in scenarios.

# Step 1: Define a Domain Handler Set (Shared)#

Create a handler file per domain. Example: orders.

TypeScript
// src/mocks/handlers/orders.handlers.ts
import { http, HttpResponse, delay } from 'msw';
 
type Order = { id: string; status: 'paid' | 'pending'; totalCents: number };
 
const orders: Order[] = [
  { id: 'ord_1', status: 'paid', totalCents: 1299 },
  { id: 'ord_2', status: 'pending', totalCents: 4999 },
];
 
export const ordersHandlers = [
  http.get('/api/orders', async () => {
    await delay(150);
    return HttpResponse.json({ items: orders, total: orders.length });
  }),
];

A few practical rules:

  1. 1
    Keep handlers close to how the real API behaves. If production returns items and total, do not return data.
  2. 2
    Add small delays by default. Many UI bugs are timing-related and never appear with instant mocks.
  3. 3
    Use realistic identifiers and values, not id: 1. You want to catch string formatting and copy edge cases.

⚠️ Warning: Avoid writing handlers that are “too perfect”. If your production API sometimes returns empty lists or partial optional fields, model that in scenarios. Perfect mocks produce false confidence.

# Step 2: Compose Scenarios Instead of Duplicating Handlers#

Scenarios are named sets of handlers that represent UI states. This is where contract testing becomes maintainable.

TypeScript
// src/mocks/scenarios/orders.scenarios.ts
import { http, HttpResponse, delay } from 'msw';
import { ordersHandlers } from '../handlers/orders.handlers';
 
export const ordersScenario = {
  default: () => [...ordersHandlers],
 
  empty: () => [
    http.get('/api/orders', async () => {
      await delay(100);
      return HttpResponse.json({ items: [], total: 0 });
    }),
  ],
 
  serverError: () => [
    http.get('/api/orders', async () => {
      await delay(100);
      return HttpResponse.json({ message: 'Internal error' }, { status: 500 });
    }),
  ],
 
  unauthorized: () => [
    http.get('/api/orders', async () => {
      await delay(100);
      return HttpResponse.json({ message: 'Unauthorized' }, { status: 401 });
    }),
  ],
};

Why this helps:

  • Storybook stories can map cleanly to scenarios: “OrdersList Empty”, “OrdersList Error”.
  • Tests can reuse scenarios without rewriting mocks.
  • When the API changes, you update scenarios and all consumers see the change.

# Step 3: MSW Setup for Tests (Vitest)#

Create a test server setup that is imported by your test runner.

TypeScript
// src/mocks/server.ts
import { setupServer } from 'msw/node';
import { ordersScenario } from './scenarios/orders.scenarios';
 
export const server = setupServer(...ordersScenario.default());

Then wire it into Vitest.

TypeScript
// src/test/setup.ts
import '@testing-library/jest-dom/vitest';
import { afterAll, afterEach, beforeAll } from 'vitest';
import { server } from '../mocks/server';
 
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

This configuration does two important things:

  • onUnhandledRequest: 'error' turns missing mocks into failures. That’s a contract enforcement mechanism.
  • resetHandlers() prevents cross-test contamination, reducing flakiness.

# Step 4: MSW Setup for Storybook (Same Handlers)#

In Storybook, use the MSW addon and load the same handler scenarios.

TypeScript
// .storybook/preview.ts
import type { Preview } from '@storybook/react';
import { initialize, mswLoader } from 'msw-storybook-addon';
import { ordersScenario } from '../src/mocks/scenarios/orders.scenarios';
 
initialize({ onUnhandledRequest: 'bypass' });
 
const preview: Preview = {
  loaders: [mswLoader],
  parameters: {
    msw: {
      handlers: ordersScenario.default(),
    },
  },
};
 
export default preview;

The choice of onUnhandledRequest differs:

  • In tests, you want “error” to enforce coverage.
  • In Storybook, “bypass” can be acceptable while building new components, but teams often switch to “warn” once mature.

ℹ️ Note: Storybook uses the Service Worker environment, while Vitest uses the Node interceptor. The same handler definitions work in both because MSW abstracts the runtime differences.

# Step 5: Stories as Living Contract Documentation#

Now you can write stories that explicitly declare which scenario they represent. This makes your Storybook a contract catalog.

TypeScript
// src/components/OrdersList/OrdersList.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { OrdersList } from './OrdersList';
import { ordersScenario } from '../../mocks/scenarios/orders.scenarios';
 
const meta: Meta<typeof OrdersList> = {
  title: 'Orders/OrdersList',
  component: OrdersList,
};
export default meta;
 
type Story = StoryObj<typeof OrdersList>;
 
export const Default: Story = {
  parameters: { msw: { handlers: ordersScenario.default() } },
};
 
export const Empty: Story = {
  parameters: { msw: { handlers: ordersScenario.empty() } },
};
 
export const ServerError: Story = {
  parameters: { msw: { handlers: ordersScenario.serverError() } },
};

Practical benefits:

  • Product and QA can review UI states without needing backend seed data.
  • Developers can reproduce tricky error states instantly.
  • The handler scenarios become a “living spec” for how the UI expects the API to behave.

If you maintain a design system, align these states with design tokens, loading patterns, and empty/error components. It’s easier to keep consistency when tokens and UI primitives are standardized: Design system tokens with Tailwind and Radix.

# Step 6: Contract Tests that Reuse Scenarios#

A contract test should not rebuild mocks. It should select scenarios and assert user-visible behavior.

Example using React Testing Library:

TypeScript
// src/components/OrdersList/OrdersList.test.tsx
import { render, screen } from '@testing-library/react';
import { server } from '../../mocks/server';
import { ordersScenario } from '../../mocks/scenarios/orders.scenarios';
import { OrdersList } from './OrdersList';
 
it('renders orders from the API', async () => {
  server.use(...ordersScenario.default());
  render(<OrdersList />);
 
  expect(await screen.findByText('ord_1')).toBeInTheDocument();
  expect(screen.getByText('ord_2')).toBeInTheDocument();
});
 
it('shows an empty state when there are no orders', async () => {
  server.use(...ordersScenario.empty());
  render(<OrdersList />);
 
  expect(await screen.findByText(/no orders/i)).toBeInTheDocument();
});
 
it('shows an error message on server error', async () => {
  server.use(...ordersScenario.serverError());
  render(<OrdersList />);
 
  expect(await screen.findByText(/try again/i)).toBeInTheDocument();
});

A few rules that reduce flakiness:

  • Assert on text users see, not implementation details.
  • Use findBy... for async content.
  • Keep tests focused on behavior per scenario, not every subcomponent.

# How Sharing Handlers Prevents Drift and Flaky UI#

Drift happens when you have multiple sources of truth:

  • Tests mock fetch with items: [...].
  • Storybook returns data: [...].
  • Production returns items: [...] plus total.

The UI “works” in one environment and fails in another.

Sharing handlers prevents this because:

  • Any response shape change is a single diff in src/mocks/handlers or src/mocks/scenarios.
  • Storybook and tests both break when the contract breaks, which forces alignment.
  • Reviewers can spot API-shape changes in PRs, not in bug reports.

In mature teams, handler changes are treated like API changes: they require review, and often require updating the UI and tests in the same PR.

# Add Contract Strictness: Fail on Unhandled Requests#

Strictness is the difference between “nice demo mocks” and contract testing.

Recommended defaults:

EnvironmentonUnhandledRequestWhy
VitesterrorForces every request to be in the contract
Storybook localwarn or bypassHelps dev speed, but warn is better long-term
CI Storybook testswarn or errorPrevents silently missing handlers in reviewed states

If your components use generated API clients, strict MSW coverage catches “surprise calls” early, such as a new endpoint being hit due to a refactor.

# Workflow: CI, Visual Review, and Regression Prevention#

A good contract testing workflow combines:

  1. 1
    Automated contract tests in Vitest.
  2. 2
    Visual review of stories for critical states.
  3. 3
    Regression prevention through snapshots and reviewed mock changes.

CI Pipeline Example (Practical and Fast)#

A pragmatic CI approach for a React app:

StepTypical runtimeWhat it catches
Typecheck and lint1 to 3 minutesUnsafe refactors, rule violations
Unit plus contract tests2 to 6 minutesUI-data regressions, error handling, loading states
Storybook build2 to 5 minutesBroken stories, missing assets
Visual test run3 to 10 minutesCSS regressions, layout shifts, theming issues

To keep the pipeline fast, scope visual tests to key stories, not the entire library.

CI Commands (Example)#

These commands show a common baseline.

Bash
# tests
pnpm test --run
 
# build Storybook to ensure it compiles
pnpm storybook:build

For visual checks, teams typically use Chromatic, Loki, or Playwright screenshot tests. The specifics depend on your stack, but the principle is the same: stories should represent the same MSW scenarios your tests use.

Regression Prevention: Tie Visual Stories to Scenarios#

Create a small “contract-critical” story set, such as:

  • Default success state
  • Empty state
  • Error state
  • Permission denied
  • Slow network loading state

Those five states typically catch a large percentage of UI regressions, especially for data-heavy apps.

A practical naming convention:

Story nameScenarioWhy it matters
DefaultsuccessMost-used path
EmptyemptyCommon edge case in new accounts
ServerError500Messaging and retry behavior
Unauthorized401Login redirect, permissions
Loadinglong delaySkeletons, disabled actions

Add a “Slow” Scenario for UI Timing Bugs#

Timing bugs are common in React: double spinners, flicker, disabled buttons re-enabled too early. Add a slow scenario and review it visually.

TypeScript
// src/mocks/scenarios/orders.scenarios.ts
import { http, HttpResponse, delay } from 'msw';
 
export const slowOrders = () => [
  http.get('/api/orders', async () => {
    await delay(2500);
    return HttpResponse.json({ items: [], total: 0 });
  }),
];

Use it in a story named Loading, and assert in tests that skeletons render and actions are disabled while pending.

PR Review Checklist: Contract and UI Together#

A practical review process is to require:

  • A handler or scenario change when the API behavior changes.
  • A story update for any new UI state.
  • A contract test for any bug fix that involved data-fetching or error handling.

This aligns well with a broader design and quality checklist: React design review checklist for performance, accessibility, maintainability.

💡 Tip: Put src/mocks/** under CODEOWNERS review, similar to backend API definitions. It forces mock changes to be intentional and discourages “quick fixes” that break contract coverage.

# Practical Patterns That Scale Beyond One Component#

Pattern 1: “Feature Contract” Scenario Bundles#

For a feature page, bundle scenarios across multiple endpoints. Example: OrdersPage might call orders, user profile, and billing status.

Instead of scattering handlers across stories, create a feature-level scenario file that composes domain handlers.

ApproachProsCons
Per-component scenariosEasy to startCan duplicate cross-cutting requests
Per-feature scenariosMatches real screensRequires coordination across components
HybridBest of bothNeeds conventions and code review discipline

Pattern 2: Data Factories for Stability#

Hardcoded fixtures get stale. Factories keep fixtures consistent and allow randomization when needed.

A pragmatic rule is: stable by default, randomized only in dedicated fuzz tests.

Pattern 3: Contract Coverage for Pagination, Sorting, and Filtering#

These are frequent regression sources because they rely on query params.

A simple approach:

  • Create a handler that asserts query parameters and returns different payloads.
  • Add one Storybook story per query variant you care about.

Even a small set of stories and tests here catches issues like wrong parameter names and broken sorting labels.

# Common Pitfalls and How to Avoid Them#

  1. 1

    Duplicating handlers in stories and tests
    Fix: shared handler modules plus scenario composition.

  2. 2

    Not failing on unhandled requests in tests
    Fix: enforce onUnhandledRequest: 'error' so missing contracts are caught.

  3. 3

    Mocking response shapes that don’t match production
    Fix: base handlers on real API responses, and keep them updated when backend changes.

  4. 4

    Too many stories without a review strategy
    Fix: define a “contract-critical” subset for visual regression and keep the rest informational.

  5. 5

    Using instant responses everywhere
    Fix: add default delay, and include at least one slow scenario per feature.

# Key Takeaways#

  • Define MSW handlers once and share them between Storybook and tests to eliminate mock drift.
  • Build named scenarios for success, empty, error, unauthorized, and slow states, then reuse them across stories and contract tests.
  • Make tests strict with onUnhandledRequest: 'error' to enforce UI-to-API contract coverage.
  • Treat Storybook stories as living contract documentation and run visual checks on a small, high-value subset in CI.
  • Review changes to src/mocks/** like production code, because handler changes effectively change the UI contract.

# Conclusion#

React component contract testing works best when your mocks are a shared, versioned asset. MSW plus Storybook gives you living API mocks that improve day-to-day development and significantly reduce flaky UI and late-stage regressions.

If you want help standardizing this across a React codebase, including Storybook governance, CI visual regression, and a stable MSW contract layer, reach out to Samioda and we’ll help you implement a maintainable testing workflow.

FAQ

Share
A
Adrijan OmićevićFounder & Senior Developer

Founder & Senior Developer at Samioda. 8+ years building React, Next.js, Flutter and n8n automation solutions for clients across Europe.

Need help with your project?

We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.