MSW mechanics in practice
Mock outbound HTTP in your integration tests with Mock Service Worker v2, intercepting the wire so the real SDK answers to handlers you write per scenario.
The last lesson settled an argument: when your code reaches out to Stripe, you mock the wire, the actual HTTP request leaving your process, and you leave the SDK’s serializing, signing, retrying, and parsing under test. You came away with a rule and a mental model, but not with a single line you can type. This lesson supplies the code.
Picture three tests against one endpoint, POST /v1/checkout/sessions, the call Stripe’s SDK makes when you open a checkout session:
- A happy-path test needs that endpoint to return a canned checkout URL so
createSubscriptionresolves and you can assert the URL came back. - A duplicate-customer test needs the same endpoint to return
400witherror.code: 'resource_already_exists', so you can assert the SDK surfaced it as the mapped error your code handles. - A retry test needs that endpoint to fail three times with
503and then succeed with200, so you can prove the SDK’s retry logic actually climbs back to success.
Same URL, three different behaviors, chosen per test. That is the whole job, and the tool for it is MSW . By the end of this lesson, those three scenarios are three small, named blocks you can write from memory.
The work has three moving parts: a handler per scenario, a matcher per request shape, and per-test overrides that don’t leak. All of it runs under a discipline you already half-own. The server that does the intercepting lives in exactly one place, handlers reset between every test, and, following the policy you locked in last lesson, a request with no handler doesn’t quietly return 200. It fails loud, because an unhandled request to a third party is a bug, not a fallthrough.
We’ll build it in layers. First, the plumbing that every later section assumes: the server and its lifecycle. Then the three scenarios in order: the happy handler, the per-test override, and the retry sequence. The capstone is the other direction, recovering the request the SDK actually sent so you can assert on the bytes on the wire. The whole thing lives in a new src/test/msw/ directory.
Standing up the server: install and lifecycle
Section titled “Standing up the server: install and lifecycle”Before any handler can intercept anything, MSW needs a server instance and a place in the test lifecycle to switch it on and off. You wire this up once, and every section after this one assumes it’s there.
Install it as a dev dependency, since MSW is a test tool and never ships to production:
pnpm add -D mswNext, the server itself. This is the one module where the instance is created, and it’s deliberately tiny:
import { setupServer } from 'msw/node';import { stripeHandlers } from './handlers/stripe';import { resendHandlers } from './handlers/resend';import { posthogHandlers } from './handlers/posthog';
export const server = setupServer( ...stripeHandlers, ...resendHandlers, ...posthogHandlers,);setupServer takes a list of handlers (the next section builds those handlers/ files) and hands back a server you’ll drive from the lifecycle hooks. The key point is that this is a module singleton. Anywhere in your test suite that imports server from this file gets the same instance, the same switchboard. There is one server, and these handler files describe what it knows how to answer.
A switchboard does nothing until you plug it in, and you plug it in from the integration setup file, the one wired up earlier in this chapter with the migration runner and the auth mock. You’re adding three lines to it, one per phase of a test run:
import { server } from './msw/server';
// alongside this chapter's earlier migration setup and the auth mock
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));afterEach(() => server.resetHandlers());afterAll(() => server.close());The singleton from the module you just wrote. You import it and never call setupServer again, since that already happened once.
import { server } from './msw/server';
// alongside this chapter's earlier migration setup and the auth mock
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));afterEach(() => server.resetHandlers());afterAll(() => server.close());listen boots interception for the whole worker. The onUnhandledRequest: 'error' option is the policy from last lesson, now live: any request without a matching handler throws instead of silently passing through. A URL typo or a missing handler becomes a loud, immediate failure, which is exactly what you want.
import { server } from './msw/server';
// alongside this chapter's earlier migration setup and the auth mock
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));afterEach(() => server.resetHandlers());afterAll(() => server.close());resetHandlers strips every per-test override (the next sections add those) and snaps the server back to its default handlers. This is the single most important line for keeping tests independent: one test’s override must never bleed into the next. You’ll meet this line again, by name, when this chapter catalogues the ways tests leak into each other.
import { server } from './msw/server';
// alongside this chapter's earlier migration setup and the auth mock
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));afterEach(() => server.resetHandlers());afterAll(() => server.close());close tears interception down when the worker is done. It’s symmetric with listen, and nothing more.
Three hooks, three jobs: listen turns interception on, resetHandlers clears per-test state between tests, and close turns it off. You set onUnhandledRequest once here and never think about it again, since that one line governs the whole suite.
Default handlers: the happy-path contract
Section titled “Default handlers: the happy-path contract”Now for the first scenario, the canned checkout URL, and, more importantly, the home for every happy path in your suite.
Handlers live in src/test/msw/handlers/, one file per third party: stripe.ts, resend.ts, posthog.ts. Each file exports an array of handlers covering only the happy paths your suite actually exercises, and server.ts spreads those arrays into setupServer (you saw that spread a moment ago). Splitting by provider keeps each file readable and keeps “what we assume Stripe does on success” in one obvious place.
That framing is the whole point of the default array: it is the contract. It says, in code, “here is what we assume these services do when everything goes right.” A test that needs an unhappy path, the 400 or the 503, doesn’t edit this file. It stacks a one-test override on top, which the next section covers. Defaults are the rule; overrides are the exceptions.
Each handler matches a request by method and URL. The method comes from the http object (http.get, http.post, http.put, http.patch, http.delete, and http.all for any method) and the URL is a string you match against:
http.post('https://api.stripe.com/v1/checkout/sessions', resolver);The URL can carry path parameters with a :name segment, which you’ll read back later when capturing requests:
http.post('https://api.stripe.com/v1/customers/:customerId', resolver);Pin the exact base URL and full path. A pathname that doesn’t match falls through to onUnhandledRequest: 'error' and the test fails loud, which is good: you’ll see it immediately. The subtler trap is a wrong host. If your code somehow hits a regional or sandbox endpoint your handler doesn’t cover, that request also just misses your handler. Pin the host you actually call.
The second argument is the resolver : a function that receives the matched request and returns the response. In MSW v2 it returns an HttpResponse directly:
async ({ request, params }) => HttpResponse.json(body, { status });HttpResponse is MSW’s response builder, and it has a shape for every kind of reply your suite needs:
HttpResponse.json(body, { status })is a JSON body, status200by default. This is the workhorse.HttpResponse.text(body, { status })is a plain-text body.HttpResponse.error()is not an HTTP error but a network-level failure, like a refused connection. You’ll use this in the retry section to engage the SDK’s retry path.new HttpResponse(body, { headers })is the raw escape hatch when you need full control over headers or a binary body. Reach for it rarely.
Before you write your first handler, one detour will save you an afternoon. If you search the web for MSW examples, most of what you find predates a rewrite the library shipped in late 2023, and the old API looks just different enough to break when you paste it. Here is the same handler in both dialects so you can recognize and translate on sight:
import { rest } from 'msw';
rest.post( 'https://api.stripe.com/v1/checkout/sessions', (req, res, ctx) => res(ctx.json({ id: 'cs_test_123', url: 'https://checkout.stripe.com/c/cs_test_123' })),);Most pre-2024 tutorials look like this. The import is rest, the resolver takes a (req, res, ctx) triple, and you build the response by calling res(ctx.json(...)). If a snippet you copied has any of those three tells, it’s using the old API.
import { http, HttpResponse } from 'msw';
http.post( 'https://api.stripe.com/v1/checkout/sessions', () => HttpResponse.json({ id: 'cs_test_123', url: 'https://checkout.stripe.com/c/cs_test_123' }),);http replaced rest. The resolver returns an HttpResponse directly instead of being handed a res/ctx toolkit. Translating an old snippet is mechanical: change rest to http, drop req, res, ctx, and write return HttpResponse.json(x) where the old one wrote res(ctx.json(x)).
MSW v2 is what this course writes, and the cutoff is October 2023, so you can date any example you find. Now the first scenario, resolved. Here is the Stripe handler file with its happy checkout path, the array that server.ts already spreads in:
import { http, HttpResponse } from 'msw';
export const stripeHandlers = [ http.post('https://api.stripe.com/v1/checkout/sessions', () => HttpResponse.json({ id: 'cs_test_123', url: 'https://checkout.stripe.com/c/cs_test_123', }), ),];That’s the canned-URL scenario done. The default array carries the happy checkout session, server.ts spreads stripeHandlers into setupServer, and every test in the suite now gets a successful checkout response for free, so createSubscription resolves with a real-looking URL. Scenario one, complete.
Overriding for one test: server.use and unhappy paths
Section titled “Overriding for one test: server.use and unhappy paths”The second scenario is the duplicate-customer 400. The happy path is already in the defaults, so this isn’t something you add there. It’s an exception you stack for exactly one test.
The tool is server.use(...). It pushes one or more handlers on top of the defaults for the current test, and the resetHandlers() you already wired into afterEach strips them before the next test runs. The rule to keep in mind: happy paths live in the default handlers, and you reach for server.use only for the one unhappy path this single test needs.
Here is the duplicate-customer override and the test that uses it:
it('surfaces a duplicate customer as a mapped error', () => withRollback(async ({ tx }) => { await signedInAs({ plan: 'free' }, tx);
server.use( http.post('https://api.stripe.com/v1/checkout/sessions', () => HttpResponse.json( { error: { code: 'resource_already_exists' } }, { status: 400 }, ), ), );
const result = await createSubscription({ priceId: 'price_pro_monthly' });
expect(result).toBeErrResult('conflict'); }));For this one test, the checkout endpoint stops returning the happy URL and returns a 400 with the error.code Stripe uses for a duplicate. The SDK receives that response, maps it to the error your code handles, and your action returns the 'conflict' code. The assertion is toBeErrResult('conflict'): you check the Result code, the same discipline from the previous chapter, never the user-facing message, which is UI copy that changes. Thanks to afterEach, the next test sees the original happy handler again, because the override is gone. Scenario two, complete.
One reviewer habit to internalize while it’s fresh: an override is only ever a deviation from a contract that already exists. If you find yourself writing a server.use for an endpoint that has no default handler underneath it, that’s a smell. It means your default array is missing a contract entry, not that you’ve found a clever exception. Every endpoint your suite touches should have a happy-path default; server.use shadows that default for a test, it doesn’t stand in for a missing one.
That 400 is one of three failure shapes you’ll inject, and they all live right here in the override, each one line:
- A status code, such as
{ status: 503 }, gives the SDK a server error to chew on, which exercises its real retry path. This is the entire reason you mock the wire: a function-level mock would never trigger that retry. HttpResponse.error()is a network-level failure, like a refused connection. It also engages the SDK’s retry logic, but from the transport layer rather than an HTTP status.- A slow upstream,
await new Promise((r) => setTimeout(r, 5000))inside the resolver before you return, simulates a hanging third party. This one has a catch: under fake timers it needsvi.useFakeTimers()plusadvanceTimersByTimeAsync(the clock seam you met in the previous chapter’s lesson on time in tests) to push past the delay. MSW and fake timers have a sharp interaction we’ll get to at the end of this lesson, so keep this one in your back pocket for now.
Before the next scenario, assemble an override yourself from the surface you just learned. The skeleton below is a server.use for the duplicate-customer test with three blanks. Pick the method, the response helper, and the status that make it a correct 400-with-JSON override:
Complete the duplicate-customer override so it returns a 400 with a JSON error body. Pick the right option from each dropdown, then press Check.
server.use( ___('https://api.stripe.com/v1/checkout/sessions', () => ___( { error: { code: 'resource_already_exists' } }, { status: ___ }, ), ),);Sequenced responses: { once: true } for retries
Section titled “Sequenced responses: { once: true } for retries”The third scenario is the one students reliably get wrong, which is why it gets its own section: three 503s, then a 200. The mechanism is a handler option called once, and the most common mistake is reaching for a method that doesn’t exist. There is no .once(). Instead, once is the third argument to http.*, an option object that changes how long the handler lives.
A handler marked { once: true } answers exactly one matching request and then retires. It’s spent, and the next matching request looks past it to whatever is underneath. Stack a few of them and you’ve described a sequence:
server.use( http.post(url, () => new HttpResponse(null, { status: 503 }), { once: true }), http.post(url, () => new HttpResponse(null, { status: 503 }), { once: true }), http.post(url, () => new HttpResponse(null, { status: 503 }), { once: true }), http.post(url, () => HttpResponse.json({ id: 'sub_123' })),);Three once handlers for the same endpoint. Order is response order: the first one answers the first request, the second answers the second, the third answers the third. Each drains as it’s used.
server.use( http.post(url, () => new HttpResponse(null, { status: 503 }), { once: true }), http.post(url, () => new HttpResponse(null, { status: 503 }), { once: true }), http.post(url, () => new HttpResponse(null, { status: 503 }), { once: true }), http.post(url, () => HttpResponse.json({ id: 'sub_123' })),);This is the third argument to http.post, the handler option that makes it single-use. It is not a .once() method, since no such method exists. Getting this wrong is the most common MSW mistake: it’s an option, not a call.
server.use( http.post(url, () => new HttpResponse(null, { status: 503 }), { once: true }), http.post(url, () => new HttpResponse(null, { status: 503 }), { once: true }), http.post(url, () => new HttpResponse(null, { status: 503 }), { once: true }), http.post(url, () => HttpResponse.json({ id: 'sub_123' })),);The sticky tail. After the three once handlers are spent, every further request hits this one and gets the success. It has no once, so it answers as many times as needed, which is what lets the fourth request, and any after it, come back 200.
server.use( http.post(url, () => new HttpResponse(null, { status: 503 }), { once: true }), http.post(url, () => new HttpResponse(null, { status: 503 }), { once: true }), http.post(url, () => new HttpResponse(null, { status: 503 }), { once: true }), http.post(url, () => HttpResponse.json({ id: 'sub_123' })),);What’s actually under test here is the SDK’s real retry logic climbing this staircase: three failures, then success. A function-level mock could never exercise that. This is the payoff of mocking the wire from last lesson.
Read the stack top to bottom and it’s a script: request one drains the first once handler and gets 503, request two drains the second and gets 503, request three drains the third and gets 503, and request four, finding no once handlers left, hits the sticky success and gets 200 { id: 'sub_123' }. The SDK, retrying on 503, walks exactly that path and arrives at success, which is the behavior your test asserts.
That final non-once handler is not optional. Without it, the fourth request finds nothing, neither a once handler nor a sticky fallback, and falls straight through to onUnhandledRequest: 'error', failing the test with a confusing “no handler” error right when you expected success. The sticky tail is what catches the retry. There’s also an inverse, restoreHandlers(), that re-arms spent once handlers; it’s worth knowing the name exists, but you won’t need it here.
To lock in the drain-in-order model, put the four-handler stack in the right sequence of responses. Each retry attempt below receives one response, so order them as the stack hands them out:
The stack below is wired for a retry test. Drag the responses into the order the four requests receive them. Drag the items into the correct order, then press Check.
server.use( http.post(url, () => new HttpResponse(null, { status: 503 }), { once: true }), http.post(url, () => new HttpResponse(null, { status: 503 }), { once: true }), http.post(url, () => new HttpResponse(null, { status: 503 }), { once: true }), http.post(url, () => HttpResponse.json({ id: 'sub_123' })),);503 (drains the first once handler) 503 (drains the second once handler) 503 (drains the third once handler) 200 { id: 'sub_123' } (the sticky tail) Recovering the request: the clone-and-capture pattern
Section titled “Recovering the request: the clone-and-capture pattern”Everything so far has been about what your handler returns. The capstone is the other direction: what the SDK sent. This is the entire reason you mock the wire instead of the function, so you can assert on the actual outbound request, the bytes Stripe would receive, never on “the SDK method was called.” Last lesson promised this; here is the mechanic.
The pattern is a capture array you declare inside the test, an override that pushes the request into it, and assertions after the act:
it('sends userId and an idempotency key to Stripe', () => withRollback(async ({ tx }) => { const { user } = await signedInAs({ plan: 'free' }, tx); const seen: Request[] = [];
server.use( http.post('https://api.stripe.com/v1/checkout/sessions', ({ request }) => { seen.push(request.clone()); return HttpResponse.json({ id: 'cs_test_123', url: 'https://stripe.test/c' }); }), );
await createSubscription({ priceId: 'price_pro_monthly' });
const body = await seen[0].text(); expect(body).toContain(`metadata[userId]=${user.id}`); expect(seen[0].headers.get('Idempotency-Key')).toBeTruthy(); }));The capture array, declared inside the test, not at module scope. Module-level mutable state shared across tests is a classic flake source, since one test’s captured request can leak into another’s assertions. A fresh array per test, scoped to the test, can’t leak. This chapter’s flake lesson names this exact trap.
it('sends userId and an idempotency key to Stripe', () => withRollback(async ({ tx }) => { const { user } = await signedInAs({ plan: 'free' }, tx); const seen: Request[] = [];
server.use( http.post('https://api.stripe.com/v1/checkout/sessions', ({ request }) => { seen.push(request.clone()); return HttpResponse.json({ id: 'cs_test_123', url: 'https://stripe.test/c' }); }), );
await createSubscription({ priceId: 'price_pro_monthly' });
const body = await seen[0].text(); expect(body).toContain(`metadata[userId]=${user.id}`); expect(seen[0].headers.get('Idempotency-Key')).toBeTruthy(); }));clone() is load-bearing. A Request body is a one-shot stream: read it once and it’s consumed. The framework and the SDK round-trip will read this body, so if you don’t clone before anyone reads it, your later seen[0].text() throws “body already consumed.” Clone, then push the copy. This is the single highest-value gotcha in the lesson.
it('sends userId and an idempotency key to Stripe', () => withRollback(async ({ tx }) => { const { user } = await signedInAs({ plan: 'free' }, tx); const seen: Request[] = [];
server.use( http.post('https://api.stripe.com/v1/checkout/sessions', ({ request }) => { seen.push(request.clone()); return HttpResponse.json({ id: 'cs_test_123', url: 'https://stripe.test/c' }); }), );
await createSubscription({ priceId: 'price_pro_monthly' });
const body = await seen[0].text(); expect(body).toContain(`metadata[userId]=${user.id}`); expect(seen[0].headers.get('Idempotency-Key')).toBeTruthy(); }));Recover the body after the act, off the cloned request. Stripe’s v1 API sends application/x-www-form-urlencoded, so this is .text() (a form string), not .json(). The string looks like metadata[userId]=u_1&metadata[orgId]=o_1, and asserting metadata[userId] is in it proves the SDK serialized your metadata exactly the way Stripe expects.
it('sends userId and an idempotency key to Stripe', () => withRollback(async ({ tx }) => { const { user } = await signedInAs({ plan: 'free' }, tx); const seen: Request[] = [];
server.use( http.post('https://api.stripe.com/v1/checkout/sessions', ({ request }) => { seen.push(request.clone()); return HttpResponse.json({ id: 'cs_test_123', url: 'https://stripe.test/c' }); }), );
await createSubscription({ priceId: 'price_pro_monthly' });
const body = await seen[0].text(); expect(body).toContain(`metadata[userId]=${user.id}`); expect(seen[0].headers.get('Idempotency-Key')).toBeTruthy(); }));The payoff. This header is generated by the SDK, not your code, so it’s invisible to any function-level mock. Only by intercepting the real wire can you assert it’s present. This is the thing last lesson said only the wire could verify.
Walk the test once more, because its shape is the shape of nearly every integration test you’ll write after this chapter. The arrange step signs in a user with signedInAs (this chapter’s auth fixture) inside withRollback (its transaction wrapper) and declares the empty seen array. The override captures each matched request and returns a canned response. The act calls createSubscription exactly as production calls it. The assertions run after, on the captured request, never inside the resolver. Keep resolvers about responding and assertions about verifying, because an expect inside a resolver fails in a place that’s hard to trace.
Two parts of that test deserve a closer look, because they’re where first-timers stumble.
The clone. A Request body is a single-use stream, designed to be read straight through once, like water out of a tap, not a value you can re-read. Something in the pipeline, either the framework forwarding the request or the SDK serializing it, will read this body to do its job. If you then call request.text() on that same consumed request, you get a thrown error, not your data. request.clone() makes an independent copy with its own unread stream, so you push the clone and read it later in peace. Whenever you capture, clone first.
Reading by content type. What you call to read the body depends on what the SDK sent. Stripe’s v1 API encodes its bodies as application/x-www-form-urlencoded, a form string like metadata[userId]=u_1&metadata[orgId]=o_1, so you read it with await request.text(), not .json(). JSON APIs (most modern ones) use await request.json(). This form-encoding is the kind of detail a function-level mock can’t see and only a wire-level capture can check, which makes last lesson’s point concrete.
Beyond the body, the request carries three more things worth asserting, and each has its own accessor:
- URL parameters, those
:customerIdsegments, arrive on the resolver’sparams:({ params }) => params.customerId. - Query string: parse it off the URL with
new URL(request.url).searchParams.get('expand'). - Headers, SDK-generated ones especially:
request.headers.get('Idempotency-Key'),request.headers.get('Stripe-Version'). These are the headers last lesson promised only the wire could verify.
Now feel the clone trap before the rule fully sets. Predict what this tiny program prints: a resolver reads the request body, and then the test reads it again off the same, un-cloned reference:
The resolver reads the request body, stashes the same reference, and the test reads it again after the act. No clone() anywhere. Predict what this program prints, then press Check.
let captured: Request;
server.use( http.post('https://api.stripe.com/v1/checkout/sessions', async ({ request }) => { await request.text(); captured = request; return HttpResponse.json({ id: 'cs_test_123' }); }),);
await createSubscription({ priceId: 'price_pro_monthly' });
try { await captured.text(); console.log('read ok');} catch (error) { console.log(error instanceof Error ? error.message : 'unknown');}Request body is a single-use stream. The resolver’s await request.text() already drained it, so captured points at an emptied request; the second await captured.text() finds nothing left and throws, and the catch logs its message. The 'read ok' branch is never reached. The fix is request.clone() before the first read — push the clone, then read the copy later.That thrown error is the one you’ll hit on your first capture if you forget to clone. Seeing it once teaches the rule better than reading it ten times.
When MSW fights fake timers, and when it’s the wrong tool
Section titled “When MSW fights fake timers, and when it’s the wrong tool”Two watch-outs don’t belong to any single mechanic, since they qualify the whole MSW setup, so they get their own short section. The first will hang a test if you don’t know it; the second is a decision you make once.
MSW and fake timers don’t naturally coexist. MSW resolves requests through internal async work, the microtasks and timers it schedules itself. The moment you reach for vi.useFakeTimers() with its defaults, Vitest freezes the clock for everything, including the queueMicrotask MSW relies on, so the request never settles. Your resolver never runs, your await never resolves, and the test hangs until it times out. This is a common wall, not a footnote.
There are three ways through it, from lightest touch to most robust. Because this is the one part of the API that drifts across Vitest versions, confirm the exact option names against the project’s pinned Vitest before you lean on them:
it('retries on a slow upstream', () => withRollback(async ({ tx }) => { vi.useFakeTimers(); // act + advance the clock here vi.useRealTimers(); }));The lightest fix. Keep vi.useFakeTimers() inside the test body and call vi.useRealTimers() before it ends, so setup and teardown run on the real clock and only the controlled section is frozen. This is often enough on its own.
vi.useFakeTimers({ shouldAdvanceTime: true });Heavier than scoping, lighter than the next option. shouldAdvanceTime lets the fake clock keep ticking along with real time underneath, so MSW’s internal work still progresses while you control the foreground.
vi.useFakeTimers({ toFake: ['setTimeout', 'setInterval', 'Date'] });The durable fix for MSW v2. Name which timers to fake and leave queueMicrotask off the list, so MSW’s internals run on the real microtask queue while your code still sees a frozen Date and a controllable setTimeout. This is the one to reach for when the other two don’t hold.
The third tab is the durable fix, and it’s worth understanding rather than copying. MSW must be allowed to use the real queueMicrotask, so you fake everything else and leave that one alone. Pair it with advanceTimersByTimeAsync for the slow-upstream resolver from the override section, and the hanging test resolves on command. The clock seam itself is the previous chapter’s lesson on time in tests; this is only the part where it collides with MSW. Treat it as a known sharp edge: you will hit a hung test here at least once, and now you’ll know why.
MSW is the tool; don’t double-mock. This course pins MSW for outbound HTTP. You’ll see an alternative, nock, in older codebases; name it once and move on. MSW’s advantages are real: the same API across Node, jsdom, and the browser, declarative request matching, and onUnhandledRequest: 'error' built in. The hard rule that comes with picking it: never combine vi.spyOn(global, 'fetch') or vi.mock('node:https') with MSW. Two interception layers means two sources of truth, and the result is baffling double-mock behavior where neither layer fully controls the request. Pick one, and pick MSW. There’s a subtlety here too: Stripe’s SDK routes through node:https, not fetch, so a fetch spy wouldn’t even catch the request, which is another reason the wire-level interceptor is the right tool and the spy is the wrong one.
To close, consolidate the watch-outs scattered through this lesson into one decision. Of the choices below, which are actual bugs in an MSW integration setup?
A teammate’s MSW integration setup is below, one snippet per choice. Which of these are bugs? Select all that apply.
// the only lifecycle hooks in setupFilesbeforeAll(() => server.listen({ onUnhandledRequest: 'error' }));afterAll(() => server.close());beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }));vi.spyOn(global, 'fetch');server.use(http.post(url, () => HttpResponse.json({})));http.post(url, async ({ request }) => { seen.push(request); return HttpResponse.json({ id: 'cs_test_123' });});// later: await seen[0].text()export const stripeHandlers = [ http.post(url, () => HttpResponse.json({ id: 'cs_test_123' })),];Four of these are bugs, and one is the contract working as designed.
- No
afterEach(() => server.resetHandlers())is a bug: everyserver.useoverride survives into the rest of the file, the exact leakresetHandlers()exists to stop. onUnhandledRequest: 'warn'is a bug: an unhandled request (a URL typo, a missing handler) slips through as a silent pass. The policy is'error', so a miss fails loud.vi.spyOn(global, 'fetch')next to MSW is a bug: two interception layers means two sources of truth and baffling double-mock behavior. Pick MSW alone, and note that the Stripe SDK routes throughnode:https, notfetch, so the spy wouldn’t even catch it.- Pushing
requestwithoutrequest.clone()is a bug: the body is a one-shot stream, so the laterseen[0].text()throws “body already consumed.” Clone before you push. - The default
stripeHandlersarray is not a bug. It’s the happy-path contract, exactly where happy paths belong, and overrides are the exceptions stacked on top of it.
Wrap and external resources
Section titled “Wrap and external resources”MSW is now your default tool for every outbound HTTP call in the integration suite. The whole loop, in brief: a singleton server wired into the lifecycle once, a default-handler file per third party that is your happy-path contract, server.use to stack an unhappy override for a single test without leaking, { once: true } to sequence responses for a retry, and clone()-and-capture to assert on the exact bytes the SDK sent. Three scenarios against one endpoint, three small named blocks, exactly what was promised.
One boundary is worth marking before you move on: everything here was outbound, your code calling Stripe. The next lesson flips it to inbound, testing a webhook receiver that Stripe calls, which has a completely different shape (raw bodies, signature verification, deduplication). Don’t conflate the two; MSW is for the requests you send. After that, the chapter weaves this capture pattern into a complete Server Action test, and later names handler-leak as one of its catalogued causes of flake, promoting the resetHandlers() line to a first-class concept.
When you want the canonical reference, these are the pages to bookmark.