Logo

MSW v1 to v2: The Jest Setup Nobody Warns You About

7 min read
MSWJestNext.js

Table of Contents

It Started Simple

The MSW v2 migration guide is well-written. It walks you through the API changes: rest becomes http, res(ctx.json(data)) becomes HttpResponse.json(data), types get renamed. Straightforward stuff. I read through it, thought "this won't take long," and started updating handler files.

The handler changes were indeed mechanical. But when I ran npm test, everything fell apart. Not because of the API changes — because of what MSW v2 now expects from the environment it runs in.

The Handler Changes (The Easy Part)

Before diving into the hard stuff, here's what the handler migration looks like. This part is genuinely simple:

1// v1
2import { rest } from 'msw'
3import type { ResponseResolver, RestRequest, RestContext } from 'msw'
4
5export const handler = rest.get('/api/items', resolve)
6
7export const resolve: ResponseResolver<RestRequest, RestContext> = (
8 req,
9 res,
10 ctx,
11) => {
12 const limit = Number(req.url.searchParams.get('limit'))
13 return res(ctx.json({ items: [], limit }))
14}
1// v2
2import { http, HttpResponse } from 'msw'
3import type { HttpResponseResolver } from 'msw'
4
5export const handler = http.get('/api/items', resolve)
6
7export const resolve: HttpResponseResolver = ({ request }) => {
8 const url = new URL(request.url)
9 const limit = Number(url.searchParams.get('limit'))
10 return HttpResponse.json({ items: [], limit })
11}

The pattern is consistent across every handler: resthttp, the three-argument callback (req, res, ctx) becomes a single destructured object ({ request }), and res(ctx.json()) becomes HttpResponse.json(). For network errors, res.networkError('Failed') becomes HttpResponse.error(). For passthrough requests, req.passthrough() becomes the passthrough() function imported from msw.

Other small changes:

If this was all there was to it, the migration would take an hour. But it wasn't.

The Real Problem: jsdom Doesn't Have Web APIs

MSW v2 is built on web standards. It uses TextEncoder, TextDecoder, ReadableStream, WritableStream, TransformStream, and BroadcastChannel internally. These are all available in modern browsers and Node.js — but not in jsdom.

Jest with jsdom is the standard setup for testing React components. jsdom simulates a browser environment, but it doesn't implement every Web API. MSW v1 worked around this by using Node.js-specific polyfills internally. MSW v2 dropped that approach and expects these APIs to exist globally.

The first error I hit was something like:

1ReferenceError: TextEncoder is not defined

The fix is to polyfill everything MSW v2 needs in jest/setup.ts, before any other imports:

1import { TransformStream, ReadableStream, WritableStream } from 'stream/web'
2import { TextEncoder, TextDecoder } from 'util'
3import { BroadcastChannel } from 'worker_threads'
4
5if (typeof globalThis.TextEncoder === 'undefined') {
6 globalThis.TextEncoder = TextEncoder
7}
8if (typeof globalThis.TextDecoder === 'undefined') {
9 globalThis.TextDecoder = TextDecoder as typeof globalThis.TextDecoder
10}
11if (typeof globalThis.TransformStream === 'undefined') {
12 globalThis.TransformStream =
13 TransformStream as typeof globalThis.TransformStream
14}
15if (typeof globalThis.ReadableStream === 'undefined') {
16 globalThis.ReadableStream = ReadableStream as typeof globalThis.ReadableStream
17}
18if (typeof globalThis.WritableStream === 'undefined') {
19 globalThis.WritableStream = WritableStream as typeof globalThis.WritableStream
20}
21if (typeof globalThis.BroadcastChannel === 'undefined') {
22 globalThis.BroadcastChannel =
23 BroadcastChannel as typeof globalThis.BroadcastChannel
24}

The typeof checks aren't strictly necessary in jsdom (they'll always be undefined), but they make the setup defensive — if a future jsdom version adds support, you won't get conflicts.

The order matters here. These polyfills must come before @testing-library/jest-dom or any other import that might trigger MSW initialization.

Replacing whatwg-fetch with undici

After fixing the Web API polyfills, I hit the next wall. Our setup was using whatwg-fetch to polyfill the Fetch API in jsdom:

1// Before
2if (typeof window !== 'undefined') {
3 require('whatwg-fetch')
4}

This worked fine with MSW v1, but MSW v2's HttpResponse.error() creates a Response with status 0 — a network error response. whatwg-fetch's Response implementation doesn't handle this correctly and throws.

The fix is to use undici, which is the HTTP client that Node.js uses internally for its native fetch. Since it's the same implementation, it handles all edge cases correctly:

1if (typeof window !== 'undefined') {
2 const undici = require('undici')
3 globalThis.fetch = undici.fetch
4 globalThis.Response = undici.Response
5 globalThis.Request = undici.Request
6 globalThis.Headers = undici.Headers
7}

This swap means replacing whatwg-fetch with undici in devDependencies too:

1- "whatwg-fetch": "^3.6.2"
2+ "undici": "^5.28.4"

The Jest Config Changes

Even after the polyfills, tests were failing with import errors. MSW v2 depends on packages like rettime and until-async that ship as ESM-only. Jest's default transformIgnorePatterns skips everything in node_modules/, so these ESM packages don't get transformed and fail to import.

I was already handling a few ESM packages, but the approach needed restructuring:

1// Before
2const esm = ['@example/icons', '@example/components', 'swiper']
3
4// ...
5const getConfig = async (): Promise<Config> => {
6 const jestConfig = await createJestConfig(config)()
7 return {
8 ...jestConfig,
9 transformIgnorePatterns:
10 jestConfig.transformIgnorePatterns?.filter(
11 (pattern: string | RegExp) => pattern !== '/node_modules/',
12 ) ?? [],
13 }
14}

The old approach just removed the /node_modules/ pattern entirely, which meant Jest would try to transform everything in node_modules/. This worked but was slow. The new approach explicitly lists which packages to transform:

1const esm = [
2 '@example/icons',
3 '@example/components',
4 'swiper',
5 'rettime',
6 'until-async',
7]
8
9const getConfig = async (): Promise<Config> => {
10 const jestConfig = await createJestConfig(config)()
11 return {
12 ...jestConfig,
13 transformIgnorePatterns: [
14 `/node_modules/(?!(${esm.join('|')})/)`,
15 ...(jestConfig.transformIgnorePatterns?.filter(
16 (pattern: string | RegExp) =>
17 typeof pattern === 'string' && !pattern.includes('/node_modules/'),
18 ) ?? []),
19 ],
20 testEnvironmentOptions: {
21 ...jestConfig.testEnvironmentOptions,
22 customExportConditions: ['node', 'node-addons'],
23 },
24 }
25}

Two changes here:

  1. transformIgnorePatterns now uses a negative lookahead regex to only transform the listed ESM packages, instead of removing the node_modules exclusion entirely.

  2. customExportConditions is new and critical. MSW v2 uses the exports field in package.json with conditional exports. Without ['node', 'node-addons'], Jest resolves the wrong entry point and you get cryptic import errors.

The Storybook Side

If you use msw-storybook-addon, it needs to be upgraded too:

1- "msw-storybook-addon": "^1.8.0"
2+ "msw-storybook-addon": "^2.0.7"

And the handler syntax in stories follows the same v1 → v2 pattern:

1// v1
2import { rest } from 'msw'
3
4parameters: {
5 msw: {
6 handlers: [
7 rest.get('/api/stock', (_, res, ctx) => {
8 return res(ctx.json({ stock: 10 }))
9 }),
10 ],
11 },
12}
1// v2
2import { http, HttpResponse } from 'msw'
3
4parameters: {
5 msw: {
6 handlers: [
7 http.get('/api/stock', () => {
8 return HttpResponse.json({ stock: 10 })
9 }),
10 ],
11 },
12}

Don't forget to regenerate mockServiceWorker.js with npx msw init public/ — the service worker file changed significantly between v1 and v2.

The Full Checklist

If you're doing this migration in a Next.js + Jest project, here's everything in order:

  1. Update msw to v2 and msw-storybook-addon to v2 (if applicable)
  2. Replace whatwg-fetch with undici in devDependencies
  3. Add Web API polyfills to your Jest setup file (before other imports)
  4. Replace whatwg-fetch with undici's fetch/Response/Request/Headers in the setup
  5. Add rettime and until-async to your ESM transform list
  6. Add customExportConditions: ['node', 'node-addons'] to testEnvironmentOptions
  7. Update all handlers: resthttp, res(ctx.*)HttpResponse.*
  8. Update types: ResponseResolver<RestRequest, RestContext>HttpResponseResolver, RequestHandlerHttpHandler
  9. Update setupWorker import from msw to msw/browser
  10. Regenerate mockServiceWorker.js
  11. Run tests, fix any remaining issues

What I'd Do Differently

If I could redo this, I'd start with the Jest setup changes (steps 2-6) before touching any handler code. The handler changes are mechanical and easy to verify — but if your test environment isn't set up correctly, every test fails and you can't tell if your handler changes are correct.

I spent time debugging handler code that was actually fine. The tests were failing because of missing polyfills, not because of incorrect handler syntax.

The MSW migration guide focuses on the API changes because that's what MSW controls. The Jest environment setup is technically Jest's problem, not MSW's. But in practice, it's the part that takes the most time and causes the most confusion.

Related Articles