Playwright CT with MSW for API Isolation in Component Tests
Component tests that hit real APIs are integration tests wearing a costume.
Component tests that hit real APIs are integration tests wearing a costume. They're slow, they fail when the API is down, and they can't deterministically reproduce error states. Mock Service Worker (MSW) intercepts requests at the network level inside the browser — the same browser that Playwright CT uses — which means you get realistic mocking without patching fetch or injecting fake HTTP clients into your component code.
This is the setup that makes Playwright CT genuinely useful for data-fetching components.
Why Network-Level Mocking Beats Module Mocking
The common alternative is mocking the module that makes the request:
// Don't do this in CT tests
jest.mock('../api/users', () => ({
fetchUser: jest.fn().mockResolvedValue({ name: 'Alice' }),
}));This works in Jest/Vitest unit tests, but it tests a different thing than what users experience. It bypasses the actual HTTP call, any request middleware, retry logic, and request headers. A component that accidentally double-fetches, sends wrong headers, or fails under 401 responses won't be caught.
MSW intercepts at the service worker level. The component makes a real fetch() call. The service worker catches it, applies your handler, and returns a mocked response. The component processes that response exactly as it would with a real server.
MSW 2.x Setup for Playwright CT
Install MSW:
npm install msw --save-devCreate your request handlers:
// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/users/:id', ({ params }) => {
return HttpResponse.json({
id: params.id,
name: 'Alice Johnson',
email: 'alice@example.com',
});
}),
http.get('/api/posts', () => {
return HttpResponse.json([
{ id: '1', title: 'First Post', published: true },
{ id: '2', title: 'Draft Post', published: false },
]);
}),
];Initialize the worker in the CT setup file:
// playwright/index.tsx
import { beforeMount, afterMount } from '@playwright/experimental-ct-react/hooks';
import { setupWorker } from 'msw/browser';
import { handlers } from '../src/mocks/handlers';
const worker = setupWorker(...handlers);
beforeMount(async () => {
await worker.start({
onUnhandledRequest: 'warn',
serviceWorker: {
url: '/mockServiceWorker.js',
},
});
});Generate the service worker file that MSW needs:
npx msw init public/ --saveThis creates public/mockServiceWorker.js. Playwright CT's Vite server needs to serve it, so add public/ as a static directory in your CT Vite config:
// playwright-ct.config.ts
import { defineConfig } from '@playwright/experimental-ct-react';
export default defineConfig({
use: {
ctPort: 3100,
ctViteConfig: {
publicDir: 'public',
},
},
});Testing Loading States
The most common gap in component tests is loading states. Unit tests usually mock synchronous returns and never see the spinner. With MSW, you can delay the response:
// UserCard.ct.spec.tsx
import { test, expect } from '@playwright/experimental-ct-react';
import { http, HttpResponse } from 'msw';
import { UserCard } from './UserCard';
test('shows loading skeleton while fetching user', async ({ mount, page }) => {
// Delay the response by 500ms
await page.route('**/api/users/123', async (route) => {
await page.waitForTimeout(100); // let the component render first
// This approach uses Playwright routing instead of MSW for per-test overrides
});
// Better: use MSW's delayed response in the test handler
const component = await mount(<UserCard userId="123" />, {
hooksConfig: {
mswHandlers: [
http.get('/api/users/123', async () => {
await new Promise((r) => setTimeout(r, 500));
return HttpResponse.json({ id: '123', name: 'Alice' });
}),
],
},
});
await expect(component.getByTestId('skeleton-loader')).toBeVisible();
await expect(component.getByText('Alice Johnson')).toBeVisible();
});The hooksConfig pattern requires wiring the handler injection in your setup file. Here's the full pattern:
// playwright/index.tsx
import { beforeMount } from '@playwright/experimental-ct-react/hooks';
import { setupWorker } from 'msw/browser';
import { handlers as defaultHandlers } from '../src/mocks/handlers';
const worker = setupWorker(...defaultHandlers);
beforeMount(async ({ hooksConfig }) => {
// Reset to defaults, then apply per-test overrides
worker.resetHandlers();
if (hooksConfig?.mswHandlers) {
worker.use(...hooksConfig.mswHandlers);
}
if (!worker.started) {
await worker.start({ onUnhandledRequest: 'warn' });
}
});Now tests can pass custom handlers:
test('shows loading skeleton while fetching', async ({ mount }) => {
const component = await mount<{ mswHandlers: RequestHandler[] }>(
<UserCard userId="123" />,
{
hooksConfig: {
mswHandlers: [
http.get('/api/users/123', async () => {
await new Promise((r) => setTimeout(r, 300));
return HttpResponse.json({ id: '123', name: 'Alice' });
}),
],
},
}
);
await expect(component.getByRole('status')).toHaveAttribute('aria-label', 'Loading');
await expect(component.getByText('Alice')).toBeVisible({ timeout: 2000 });
});Testing Error States
Error state testing with real APIs means intentionally breaking things. With MSW you return a 500 cleanly:
test('shows error message on server error', async ({ mount }) => {
const component = await mount<HooksConfig>(
<UserCard userId="999" />,
{
hooksConfig: {
mswHandlers: [
http.get('/api/users/999', () => {
return HttpResponse.json(
{ message: 'Internal server error' },
{ status: 500 }
);
}),
],
},
}
);
await expect(component.getByRole('alert')).toContainText('Failed to load user');
await expect(component.getByRole('button', { name: 'Retry' })).toBeVisible();
});
test('shows not found state on 404', async ({ mount }) => {
const component = await mount<HooksConfig>(
<UserCard userId="deleted-user" />,
{
hooksConfig: {
mswHandlers: [
http.get('/api/users/deleted-user', () => {
return HttpResponse.json(
{ message: 'Not found' },
{ status: 404 }
);
}),
],
},
}
);
await expect(component.getByText('User not found')).toBeVisible();
});Testing Empty States
test('shows empty state when user has no posts', async ({ mount }) => {
const component = await mount<HooksConfig>(
<UserPostList userId="123" />,
{
hooksConfig: {
mswHandlers: [
http.get('/api/users/123/posts', () => {
return HttpResponse.json([]);
}),
],
},
}
);
await expect(component.getByText('No posts yet')).toBeVisible();
await expect(component.getByRole('link', { name: 'Write your first post' })).toBeVisible();
});Resetting Handlers Between Tests
Handler state leaking between tests causes the most confusing failures in MSW setups. The beforeMount hook fires before each mount() call, so calling worker.resetHandlers() there ensures each test starts clean:
// playwright/index.tsx
beforeMount(async ({ hooksConfig }) => {
worker.resetHandlers(); // Always reset first
if (hooksConfig?.mswHandlers?.length) {
worker.use(...hooksConfig.mswHandlers);
}
});If a test needs to assert that a request was made (not just that the UI updated), MSW 2.x doesn't have built-in request capture. Use Playwright's page.waitForRequest() or page.waitForResponse() alongside MSW:
test('sends correct user ID in request', async ({ mount, page }) => {
const requestPromise = page.waitForRequest('**/api/users/abc-123');
await mount<HooksConfig>(
<UserCard userId="abc-123" />,
{
hooksConfig: {
mswHandlers: [
http.get('/api/users/abc-123', () =>
HttpResponse.json({ id: 'abc-123', name: 'Bob' })
),
],
},
}
);
const request = await requestPromise;
expect(request.url()).toContain('abc-123');
});TypeScript for hooksConfig
Define a type for your hooks config to get proper type checking:
// src/test-types.ts
import type { RequestHandler } from 'msw';
export interface HooksConfig {
mswHandlers?: RequestHandler[];
}Use it in tests:
import type { HooksConfig } from '../src/test-types';
const component = await mount<HooksConfig>(
<MyComponent />,
{ hooksConfig: { mswHandlers: [...] } }
);The Result
The component under test calls fetch('/api/users/123') exactly as it would in production. MSW intercepts it at the service worker layer. Your test controls the response. The component renders, you assert on DOM state. No jest.mock(), no injected fake clients, no test-specific code paths in your production component.
This combination — Playwright CT for browser-accurate rendering, MSW for network isolation — gives you test confidence that sits between unit tests and full E2E tests. Loading states, error boundaries, retry logic, and empty states that are tedious to trigger against real APIs become trivial to reproduce on every test run.
If you need to verify these states across full user flows rather than individual components, HelpMeTest covers that layer with plain-English test scenarios that don't require any additional tooling setup.
Deterministic tests are fast tests, and fast tests are the ones engineers actually run.