Using MSW with Cypress Component Tests

Cypress component tests mount a component in a real browser, so the component fetches for real. Reaching for cy.intercept in every spec means maintaining a second definition of your API alongside the handlers your unit tests already use. This page runs the existing MSW handler set inside Cypress instead.

Context: the same handlers, one more consumer

A component test is the third consumer of the handler module described in sharing MSW handlers between browser and node. Because Cypress component testing runs in a real browser, the browser entry point — setupWorker — is the right one, exactly as it is for the dev server.

There are two mechanical differences from a unit test. The worker registration is asynchronous and has to be awaited before anything mounts, and Cypress’s command queue means “awaiting” looks like returning a promise from a hook rather than using await in the test body.

cy.intercept still has a place. Its aliasing and cy.wait('@alias') are genuinely good when the request itself is the subject of the assertion. The rule is one layer per endpoint — never both.

MSW or cy.intercept, per kind of assertion Three rows. Rendering behaviour driven by API data belongs to MSW because it reuses the shared handler set. An assertion about request timing or ordering belongs to cy.intercept because of its aliasing and wait commands. An assertion about the request payload can use either, but must not use both for the same endpoint, since whichever intercepts first wins silently. The assertion is about Use Because what the component renders MSW reuses the handlers unit tests already trust when a request fires, and in what order cy.intercept aliasing and cy.wait are built for exactly this the payload that was sent either — never both two interceptors on one endpoint is a coin flip Whichever layer intercepts first serves the request, and which one that is depends on load order rather than on anything visible in the spec. Pick per endpoint and write the choice down, or the next person will add the second one.

Solution

1. Start the worker before the first mount

// cypress/support/component.ts
import { mount } from 'cypress/react';
import { worker } from '../../src/mocks/browser';
import { resetMockState } from '../../src/mocks/reset';
import './commands';

Cypress.Commands.add('mount', mount);

before(() => {
  // Returning the promise makes Cypress wait for activation before any test runs.
  return worker.start({
    onUnhandledRequest: 'error',
    quiet: true,
    serviceWorker: { url: '/mockServiceWorker.js' },
  });
});

beforeEach(() => {
  resetMockState();
  cy.window().then((win) => {
    win.localStorage.clear();
    win.sessionStorage.clear();
  });
});

after(() => worker.stop());

Returning the promise rather than calling await is the Cypress idiom: the command queue treats a returned promise as something to resolve before continuing. Calling worker.start() without returning it produces a component that mounts and fetches before the worker exists, which shows up as one flaky spec rather than a consistent failure.

onUnhandledRequest: 'error' is as important here as anywhere else. Component tests are exactly where a forgotten endpoint quietly reaches a real API.

2. Serve the worker file to the component runner

Cypress’s component dev server needs to serve mockServiceWorker.js at the root:

// cypress.config.ts
import { defineConfig } from 'cypress';

export default defineConfig({
  component: {
    devServer: { framework: 'react', bundler: 'vite' },
    supportFile: 'cypress/support/component.ts',
    // The worker file lives here and is served at the root of the runner.
    indexHtmlFile: 'cypress/support/component-index.html',
  },
});

Because the component runner uses your Vite config, a worker already in public/ is served automatically. If registration fails with a 404, the causes are the same four as in fixing an MSW Service Worker 404 in Vite.

3. Override per test, not per file

// cypress/support/commands.ts
import { http, HttpResponse } from 'msw';
import { worker } from '../../src/mocks/browser';

declare global {
  namespace Cypress {
    interface Chainable {
      mockOnce(method: 'get' | 'post', url: string, status: number, body: unknown): Chainable<void>;
    }
  }
}

Cypress.Commands.add('mockOnce', (method, url, status, body) => {
  worker.use(
    http[method](url, () => HttpResponse.json(body, { status }), { once: true })
  );
});
// src/features/orders/OrderList.cy.tsx
import { OrderList } from './OrderList';

describe('<OrderList />', () => {
  it('renders rows from the default handlers', () => {
    cy.mount(<OrderList />);
    cy.findAllByRole('listitem').should('have.length', 20);
  });

  it('shows the error surface when the API fails', () => {
    cy.mockOnce('get', 'https://api.example.com/orders', 503, {
      error: 'service_unavailable',
      retryable: true,
    });

    cy.mount(<OrderList />);

    cy.findByRole('alert').should('contain.text', 'temporarily unavailable');
    cy.findByRole('button', { name: /try again/i }).should('be.enabled');
  });
});

{ once: true } means the retry inside the component succeeds against the default handler, so one spec can assert both the failure and the recovery. The resetMockState() in beforeEach guarantees the override cannot survive into the next test even if it is never consumed.

4. Where cy.intercept still wins

it('debounces the search request', () => {
  cy.intercept('GET', '**/api/orders?q=*').as('search');

  cy.mount(<OrderSearch />);
  cy.findByRole('searchbox').type('widget');   // six keystrokes

  cy.wait('@search');
  cy.get('@search.all').should('have.length', 1);   // debounced to one
});

Nothing in MSW gives you cy.get('@alias.all') as ergonomically. When the assertion is about how many requests happened and when, use the runner’s own tooling — and make sure MSW is not also handling that endpoint.

Hook ordering in a component spec A left-to-right sequence: a before hook starts the MSW worker once per spec file and Cypress waits for the returned promise; a beforeEach resets handlers, the store and browser storage; the test mounts the component and may register a one-shot override; the after hook stops the worker. An annotation marks the returned promise as the reason nothing mounts before the worker is active. before() return worker.start() once per spec file beforeEach() resetMockState() clear browser storage the test cy.mockOnce(...) then cy.mount override applies to one request after() worker.stop() returning the promise is what makes Cypress wait Without the return, the first spec mounts against an inactive worker and fails intermittently — usually only on the faster CI machine. Resetting in beforeEach rather than afterEach means a spec's guarantees do not depend on the previous spec finishing cleanly.

Verification

npx cypress run --component                       # all specs
npx cypress run --component --spec 'src/features/orders/*.cy.tsx'   # one folder in isolation

A folder that passes in isolation and fails in the full run has state leaking between spec files — usually browser storage, which the beforeEach above clears, or a module-level store that resetMockState does not yet know about.

In the Cypress runner, the console shows MSW’s own logging when quiet is false. Turning it on temporarily is the quickest way to confirm which handler served a given request.

Gotchas and edge cases

  • worker.start() in beforeEach re-registers on every test. It is idempotent but slow, and it resets handler overrides at an awkward point. Start once in before, reset in beforeEach, stop in after.

  • Cypress reloads the frame per spec file, so module state resets with it. That sounds convenient and hides leaks: a store that is never explicitly reset appears to work because the file boundary resets it, then breaks the day two specs in the same file both use it. Reset explicitly rather than relying on the frame reload.

  • cy.intercept registered while MSW is active is a silent race. Both are installed, and which one answers depends on whether the Service Worker or Cypress’s proxy sees the request first. If you must use cy.intercept for an endpoint, remove it from the MSW handler set for that spec so the choice is explicit.


Cypress timing rules that matter Three rules imposed by Cypress's command queue: return the worker start promise rather than awaiting it, register overrides before mount rather than after, and reset before each test rather than after. Each is paired with the failure produced by getting it wrong. Return, do not await the queue resolves returned promises an awaited start does not block the queue Override before mount the component fetches on mount an override after mount arrives too late Reset before, not after the guarantee a reader relies on a failing test would otherwise poison the next All three are about ordering rather than about MSW, which is why they are easy to get wrong once and then copy.

Component tests against end-to-end tests

Component testing in a real browser sits between unit tests and full end-to-end runs, and it is worth being deliberate about which specs belong there — otherwise it absorbs work from both sides and becomes slow without becoming more useful.

It is better than a unit test when the browser matters. Real layout, real focus management, real scroll behaviour, real CSS. A component whose bug is that a tooltip is positioned off-screen cannot be tested in jsdom at any price, and can be tested here cheaply.

It is better than an end-to-end test when routing does not matter. Mounting one component with seeded props and a mocked API is dramatically faster and more stable than navigating a whole application to reach the same screen. Any spec whose subject is a single component’s behaviour belongs here rather than in the end-to-end suite.

It is worse than both when the subject is neither. A spec that mounts a component in order to assert that a pure formatting function works belongs in a unit test. A spec that needs three screens of state to be meaningful belongs in the end-to-end suite, where navigation is the point.

The mocking implication is direct: component specs need the same handler set as everything else, and they need per-spec overrides more than any other layer, because a component test is usually about one specific response. That combination — a shared default set plus cheap one-shot overrides — is exactly what the setup on this page provides, and it is why reaching for a second interception mechanism inside component tests tends to create more problems than it solves.

A reasonable split, in practice: unit tests for logic, component tests for anything visual or interactive, and a small end-to-end suite for the two or three journeys that must never break.

← Back to Browser Test Runner Integration