How to Set Up Vitest and Playwright Test Environments with a Single MSW Handler
3-minute summary: Create a single
src/mocks/handlers.tsfile, connect it to Vitest usingsetupServerfrommsw/node, then connect it to Playwright usingdefineNetworkFixturefrom@msw/playwright. From that point on, both environments share the same mock contract, and handler mismatch problems disappear by design.
When writing frontend tests, you inevitably run into this question: "The backend isn't ready yet — how do I test?" Early on, I used to patch fetch directly with jest.mock(), or spin up a separate mock server by switching the API base URL via environment variables. Honestly, that approach scatters config files everywhere, and Vitest mocks and Playwright mocks end up living separate lives until one day you experience that moment: "Wait, why does this handler behave differently here?"
MSW (Mock Service Worker) solves that problem at the network layer itself. Without touching a single line of application code, a browser Service Worker or Node.js interceptor intercepts actual HTTP requests and returns the responses defined by your handlers. The ability to share a single handler file across both Vitest unit tests and Playwright E2E tests is the core reason MSW has been adopted by so many frontend teams.
This article walks through setting up both environments together with MSW 2.x, aimed at frontend developers already using Vitest or Playwright. We'll cover the key concepts, look at real code, and address the pitfalls you'll encounter in practice.
Core Concepts
How MSW Behaves Differently per Environment
MSW's design philosophy is simple: "One set of handlers, one entry point per environment." Handler definitions are written identically everywhere using the http, graphql, and ws APIs — only how you turn them on differs by environment.
| Environment | API Used | How It Works |
|---|---|---|
| Node.js (Vitest default) | setupServer() from msw/node |
Intercepts requests via Node.js HTTP interceptor |
| Browser (Vitest Browser Mode) | setupWorker() from msw/browser |
Registers an actual Service Worker |
| Playwright | defineNetworkFixture() from @msw/playwright |
Uses the page.route() API — no SW needed |
What is a Service Worker (SW)? A script the browser runs in a separate thread, positioned between the page and the network, capable of intercepting requests or caching responses. MSW uses it as an interceptor in browser environments.
Thanks to this structure, handlers.ts can remain a single, environment-agnostic file. Because the mock contract validated in Vitest applies identically in Playwright, situations like "it worked in unit tests, why is E2E different?" become far less common.
MSW 2.x — What Changed from the Old Version
MSW 2.x launched in late 2023 with significant API changes. Here are the key differences, since older examples can be confusing.
| Item | 1.x (old) | 2.x (current) |
|---|---|---|
| HTTP handler | rest.get(...) |
http.get(...) |
| Response creation | res(ctx.json(...)) |
HttpResponse.json(...) |
| Status code | res(ctx.status(404), ctx.json(...)) |
HttpResponse.json(..., { status: 404 }) |
You'll still find many 1.x examples in search results. If you see rest or res(ctx.json(...)), it's a legacy example. For new projects, write with the 2.x API from the start.
Practical Setup
With the core concepts in place, let's build the actual configuration. We'll go Vitest → Playwright and see how a single handler file connects both sides.
Example 1: Defining Shared Handlers — A Single Contract for All Environments
The first thing to do is create one handler file. This file becomes the "mock contract" for both Vitest and Playwright. Start with installation.
pnpm add -D mswmsw is only used in tests and the dev server, so install it as a -D (devDependency). There's no reason to include it in the production bundle.
// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw'
export const handlers = [
http.get('/api/user', () =>
HttpResponse.json({ id: 1, name: 'Alice' })
),
http.post('/api/login', () =>
HttpResponse.json({ token: 'mock-token' }, { status: 200 })
),
]Write handler paths as relative paths (/api/user). In Vitest Node mode, they match the request URL directly. In Playwright, they are joined with the baseURL set in playwright.config.ts before matching — so if baseURL is http://localhost:3000, this handler will respond to http://localhost:3000/api/user.
Example 2: Vitest Node Mode Setup
Because Vitest runs in a Node.js environment by default, use setupServer from msw/node. Even without an actual Service Worker, the Node HTTP interceptor fills that role.
// src/mocks/server.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
export const server = setupServer(...handlers)// vitest.setup.ts (located at project root)
import { server } from './src/mocks/server'
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
setupFiles: ['./vitest.setup.ts'],
},
})It's worth verifying that the path in setupFiles ('./vitest.setup.ts') matches the actual file location (project root). If you placed it inside src/, update the path accordingly.
onUnhandledRequest: 'error' causes tests to fail when a request goes out that has no matching handler. You can start with 'warn' to ease into it, but in team projects, enforcing 'error' is better for catching mock drift early.
To override a response for a specific test, use server.use(). The resetHandlers() in afterEach restores the original handlers after every test.
// Reproducing an error case in an individual test
it('shows an error message when login fails', async () => {
server.use(
http.post('/api/login', () =>
HttpResponse.json({ message: 'Unauthorized' }, { status: 401 })
)
)
// ... test body
})Example 3: Playwright + @msw/playwright Setup
The way to use MSW with Playwright used to rely on a community package (playwright-msw), but the MSW team now maintains an official package: @msw/playwright. I used playwright-msw at first, but ran into compatibility issues with the http and HttpResponse APIs during the MSW 2.x migration. Switching to @msw/playwright resolved everything cleanly. Since it's made by the MSW team, it's the right choice.
pnpm add -D @msw/playwrightNote:
@msw/playwrightis in early release (v0.6.x) and the API may change. It is recommended to verify thedefineNetworkFixturefunction name against the current version in the official GitHub repository.
// playwright/fixtures.ts
import { test as base } from '@playwright/test'
import { defineNetworkFixture } from '@msw/playwright'
import { handlers } from '../src/mocks/handlers'
export const test = base.extend(
defineNetworkFixture({ handlers })
)
export { expect } from '@playwright/test'In actual tests, import from this fixture instead of @playwright/test. The network fixture is automatically injected, so when you want to override handlers for a specific test, you can use network.use(). It's the exact same pattern as server.use() in Vitest, so it feels familiar.
// tests/user.spec.ts
import { test, expect } from '../playwright/fixtures'
import { http, HttpResponse } from 'msw'
test('user information renders on screen', async ({ page }) => {
await page.goto('/')
await expect(page.getByText('Alice')).toBeVisible()
})
test('displays an error message when the API fails', async ({ page, network }) => {
await network.use(
http.get('/api/user', () =>
HttpResponse.json({ message: 'Not Found' }, { status: 404 })
)
)
await page.goto('/')
await expect(page.getByText('An error occurred')).toBeVisible()
})Because @msw/playwright uses page.route() internally, there is no need to register an actual Service Worker. You don't need to generate a SW file with npx msw init public/, and if you're only using the Playwright environment, you can skip that command entirely.
The Most Common Mistakes in Practice
-
Omitting the
onUnhandledRequestsetting — Without it, requests with no matching handler will silently fail or leak out to the real network, leading to the baffling experience of "why is this test mysteriously passing?" Starting with'error'is recommended. -
Forgetting
server.resetHandlers()— If you leave out the reset inafterEach, a handler overridden in one test bleeds into the next. You end up with an unstable suite whose results vary depending on test execution order. -
Using the community package
playwright-mswwith MSW 2.x —playwright-mswis a community package written for MSW 1.x and can have compatibility problems with MSW 2.x'shttpandHttpResponseAPIs. Choose the official@msw/playwrightpackage instead.
Pros and Cons
Advantages
| Item | Details |
|---|---|
| Single handler reuse | handlers.ts is shared by both Vitest and Playwright — mock contract mismatches are structurally prevented |
| No backend needed in CI | Frontend tests run to completion without a backend server. Eliminates infrastructure dependencies and wait times |
| Deterministic responses | Removes flaky test causes due to network instability |
| Instant error scenario reproduction | Cases hard to reproduce in real environments — 500s, 401s, timeouts — can be set up via handler overrides |
| Zero production code interference | Operates only at the network layer without modifying application code |
What is a Flaky Test? A test that passes in some runs and fails in others without any code changes. Network response delays and timeouts are among the common causes.
Drawbacks and Caveats
| Item | Details | Mitigation |
|---|---|---|
| Cannot validate the real API | Backend bugs such as DB queries and auth logic cannot be caught | Supplement with separate API integration tests or contract tests (e.g., Pact) |
| Handler maintenance cost | Handlers must be updated whenever the API spec changes | Consider tools that auto-generate handlers from an OpenAPI spec |
| Cannot test SW scenarios | @msw/playwright is page.route()-based and not suited for testing SW behavior itself |
Run SW-related tests in a separate environment |
| Browser Mode setup complexity | Node mode (setupServer) and Browser mode (setupWorker) differ, adding configuration overhead when supporting both |
Node mode alone is sufficient for most projects |
Closing Thoughts
A single handlers.ts file works as an identical mock contract for both Vitest and Playwright. After actually adopting this structure, the debugging time I used to spend on "unit tests passed, why is E2E failing?" dropped noticeably. Handler drift becomes structurally difficult to introduce in the first place.
Three steps you can take right now:
- Install with
pnpm add -D msw, then migrate just one or two of the most-used API endpoints in your current project intosrc/mocks/handlers.ts. - Create
src/mocks/server.tsandvitest.setup.ts, addsetupFilestovitest.config.ts, and confirm that your existing tests run through MSW. At this stage, starting withonUnhandledRequest: 'warn'to first identify which requests are going out without a handler is a good approach. - If you're using Playwright, run
pnpm add -D @msw/playwrightand wire updefineNetworkFixtureinplaywright/fixtures.ts. You'll immediately feel the difference of your existing Playwright tests running against the same handlers.
References
- MSW Official Docs — Introduction
- MSW Official Recipes — Vitest Browser Mode
- MSW Blog — Why Use Mock Service Worker?
- GitHub — mswjs/playwright (official @msw/playwright package)
- npm — @msw/playwright
- DEV Community — Configure Vitest, MSW and Playwright in a React project (Part 1)
- DEV Community — Configure Vitest, MSW and Playwright in a React project (Part 2)
- Makepath Blog — Unit Testing a React Application with Vitest, MSW, and Playwright
- Epic Web Dev — Vitest Browser Mode vs Playwright