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.
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.
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()inbeforeEachre-registers on every test. It is idempotent but slow, and it resets handler overrides at an awkward point. Start once inbefore, reset inbeforeEach, stop inafter. -
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.interceptregistered 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 usecy.interceptfor an endpoint, remove it from the MSW handler set for that spec so the choice is explicit.
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.
Related
- Mocking APIs in Playwright Tests — the same integration in the other runner
- Browser Test Runner Integration — the layer decision this page applies
- Resetting Scenario State Between Tests — what
resetMockStatehas to cover
← Back to Browser Test Runner Integration