Mock the wire, not the SDK
Why an integration test for a third-party SDK like Stripe should mock the network request, not the SDK call above it.
The last lesson left you a habit: stub the boundary your code actually calls, auth.api.getSession, not the library internals underneath it. This lesson pushes that boundary one step further out, to where your code stops talking to a library and starts talking to someone else’s server.
That boundary hides the most expensive bug in this chapter: not a test that fails, but one that passes while production is broken. The fix is to move the mock to the one line where the test can’t lie.
The test that passes while production breaks
Section titled “The test that passes while production breaks”Here is a Server Action that puts a customer on a paid plan. It’s the subscription sibling of the createInvoice you’ve carried through this chapter: same wrapper and shape, except it reaches out to Stripe to start a checkout instead of writing a row.
export const createSubscription = authedAction( 'member', createSubscriptionSchema, async (input, { user, orgId }) => { const session = await stripeClient.checkout.sessions.create({ mode: 'subscription', line_items: [{ price: input.priceId, quantity: 1 }], metadata: { userId: user.id, orgId }, }); return ok({ checkoutUrl: session.url }); },);To test it, you reach for the tool you know, SDK mocking from the unit-testing lessons, and write the obvious thing:
vi.mock('stripe');
it('starts a subscription checkout', async () => { await signedInAs({ plan: 'free' }, tx);
await createSubscription({ priceId: 'price_pro_monthly' });
expect(stripeClient.checkout.sessions.create).toHaveBeenCalledWith({ mode: 'subscription', line_items: [{ price: 'price_pro_monthly', quantity: 1 }], metadata: expect.any(Object), });});It passes, it’s fast, you ship it. Three weeks later production starts throwing 400s on checkout. Maybe Stripe bumped a required header on your pinned API version, maybe the SDK renamed a field, maybe that metadata shape was one Stripe rejects. Real Stripe said no, and your test stayed green through every commit.
Your assertion checked that a function was called with certain arguments; it never checked the bytes that reached Stripe. Those bytes, the method, URL, headers, and body that go on the wire , are the only contract Stripe enforces. Stripe never saw your create call; it saw a request, and you shipped that request untested.
A mock contains your guess about the third party, not the third party, so it can only confirm your own assumptions back to you. If the guess is wrong, the test is wrong the same way and stays green.
So if mocking the SDK can’t catch the bug, where does the mock go instead? First you have to see what the SDK actually is.
An SDK is a tower on top of one network call
Section titled “An SDK is a tower on top of one network call”It’s tempting to picture stripeClient as a magic object whose methods “talk to Stripe.” Drop that picture. An SDK is a stack of plain code, and every method you call runs down that stack to a single rung at the bottom: one outbound network call. Everything above that rung is ordinary JavaScript running in your process.
Here’s the tower for one checkout.sessions.create call.
Each rung does work a function-mock silently throws away:
- Serialize. Stripe’s v1 API doesn’t take JSON; it takes
application/x-www-form-urlencoded. Soline_items: [{ price: 'price_x', quantity: 1 }]goes out asline_items[0][price]=price_x&line_items[0][quantity]=1. - Sign and set headers. The SDK attaches
Authorization: Bearer sk_..., aStripe-Version, and anIdempotency-Keythat stops a retry from double-charging a customer. - Retry. On a 429 or 5xx, the SDK backs off and tries again.
- The network call. The one rung that leaves your process, and the only thing Stripe observes.
- Parse. The SDK decodes the response bytes and maps an error into a typed
StripeErroryou cancatch.
So “the wire” isn’t an abstraction. It’s a specific string of bytes the SDK builds for you, the request you’d read in a network tab.
POST /v1/checkout/sessions HTTP/1.1Host: api.stripe.comAuthorization: Bearer sk_live_***Stripe-Version: 2025-04-30Idempotency-Key: 6f1c... (auto-generated)Content-Type: application/x-www-form-urlencoded
mode=subscription&line_items[0][price]=price_pro_monthly&line_items[0][quantity]=1&metadata[userId]=usr_123&metadata[orgId]=org_42Method, path, and host. Your call site named none of these; sessions.create resolved to POST /v1/checkout/sessions on api.stripe.com. A function-mock checks your arguments, never that the request reached the right place.
POST /v1/checkout/sessions HTTP/1.1Host: api.stripe.comAuthorization: Bearer sk_live_***Stripe-Version: 2025-04-30Idempotency-Key: 6f1c... (auto-generated)Content-Type: application/x-www-form-urlencoded
mode=subscription&line_items[0][price]=price_pro_monthly&line_items[0][quantity]=1&metadata[userId]=usr_123&metadata[orgId]=org_42Three headers the SDK added, not you. The Idempotency-Key matters most: it stops a retried checkout from charging twice. No mock of your code can verify a header your code never wrote.
POST /v1/checkout/sessions HTTP/1.1Host: api.stripe.comAuthorization: Bearer sk_live_***Stripe-Version: 2025-04-30Idempotency-Key: 6f1c... (auto-generated)Content-Type: application/x-www-form-urlencoded
mode=subscription&line_items[0][price]=price_pro_monthly&line_items[0][quantity]=1&metadata[userId]=usr_123&metadata[orgId]=org_42The body, form-encoded rather than JSON. Your { price, quantity } object became line_items[0][price]=.... Real Stripe parses this exact string; a mock of create() receives your object and never runs the serializer that produces it.
The payoff is bigger than Stripe. Every outbound HTTP client is this same tower over one network call: the Resend SDK, an AI provider’s SDK, an internal RPC client. They differ in what they serialize and which host they hit, but they all bottom out at the same rung, because the network is the one exit they all share. That shared exit is why a single tool can mock all of them: MSW (Mock Service Worker) intercepts at the network rung, so it doesn’t care which SDK built the request. The mechanics are the next lesson.
Two ways to mock too high
Section titled “Two ways to mock too high”The naive test mocked too high up the tower. “Too high” comes in two flavors, one rung apart. Here are all three options, the two mistakes and the fix, with what each actually tests.
const createSession = vi.spyOn( stripeClient.checkout.sessions, 'create',);
await createSubscription({ priceId: 'price_pro_monthly' });
expect(createSession).toHaveBeenCalledWith( expect.objectContaining({ mode: 'subscription' }),);Asserts your inputs, nothing else. The serializer, signer, retry, and parser never run, so you’ve tested only the object you handed create. When Stripe bumps the Stripe-Version header your client sends and the new version rejects a field you pass, real requests start 400ing. Your spy saw your arguments, never the header.
vi.mock('stripe', () => ({ default: class FakeStripe { checkout = { sessions: { create: vi.fn().mockResolvedValue({ url: 'https://x' }) }, }; },}));The same trap, one rung deeper. Request building, idempotency-key generation, and error mapping are all hand-written stand-ins, so your test runs against a Stripe that doesn’t exist. Break the SDK’s key generation in a refactor and retried checkouts ship without a key; a customer who double-clicks “Subscribe” is charged twice. No test built on FakeStripe catches it, because the fake never generated a key.
server.use( http.post('https://api.stripe.com/v1/checkout/sessions', () => HttpResponse.json({ id: 'cs_test_123', url: 'https://checkout.stripe.com/c/x' }), ),);
await createSubscription({ priceId: 'price_pro_monthly' });// the real SDK ran: it serialized, signed, and sent a real request.// we assert on that request — the bytes Stripe would have seen.The real SDK runs; only the network is frozen. The serializer, signer, and retry build an actual request, and MSW catches it at the boundary and returns a canned response. Everything above the network line is production code; only the bytes below it are fake. Real tower, frozen ground.
You’ve seen this shape before. Last lesson, mocking the JWT verifier instead of auth.api.getSession was too deep: you reached past the boundary your code calls, into the library’s internals. Mocking create here is too shallow: you stopped short of the boundary that matters, the network. Too deep and too shallow are the same error, the wrong boundary, the same skill pointed at a different seam.
So one diagnostic, for any test that touches a third party:
Mock what you don’t own, roll back what you do
Section titled “Mock what you don’t own, roll back what you do”This lesson and the chapter’s first are two halves of one idea. There you tested against a real Postgres and contained it with transaction rollback; here you mock Stripe and never let a real request leave the machine. Run the real thing in one place, fake it in the other: the contradiction dissolves once you ask the only question that matters, do you own this boundary?
You don’t own Stripe, so freeze the wire and assert on the request your code produced, the half of the contract you’re responsible for, rather than risk flaky networks, rate limits, and charges to real cards in CI.
You own your Postgres, so run it for real and roll it back: a stub only encodes your assumptions about the schema, and the schema is the thing most likely to have drifted.
Now check that the rule transferred. The trap is sliding “your Postgres” into the mock column, so watch for it as you sort.
Each of these is something an integration test has to deal with. Decide whether to mock it at the network boundary, or run it for real and roll it back. Drag each item into the bucket it belongs to, then press Check.
stripeClient.checkout.sessions.createinvoices tableresend.emails.send callsession rowIf every database item landed in “run it, roll back,” the rule transferred: one question, not two techniques.
Assert on the request, not the call
Section titled “Assert on the request, not the call”You know where to mock. What’s left is what to assert: the request your code sent. State the contract plainly: “when a member subscribes, the action must POST to Stripe with metadata.userId, metadata.orgId, and an Idempotency-Key header.” The arrange-act-assert shape is the one you know; only the assert line changes, from the SDK call to the intercepted request MSW caught.
// ❌ asserts the call you made — blind to everything the SDK did with itexpect(stripeClient.checkout.sessions.create).toHaveBeenCalledWith(/* ... */);
// ✓ asserts the request Stripe would have receivedexpect(capturedRequest.headers.get('Idempotency-Key')).toBeTruthy();expect(decodedBody).toMatchObject({ metadata: { userId: 'usr_123' } });How you capture capturedRequest and decodedBody is the next lesson. Two policies make this assertion trustworthy. First, an unhandled request must fail loud: set onUnhandledRequest: 'error' so a call you forgot to handle throws instead of returning a silent 200 that fakes success. Second, no hand-rolled fetch outside your client layer: route third-party HTTP through a typed client like stripeClient, so every call has one seam to intercept. A raw fetch in a handler is a refactor target first, a test target second.
Boundary mocks are assumptions; contract tests catch drift
Section titled “Boundary mocks are assumptions; contract tests catch drift”A boundary mock encodes what you assume the third party does, and that assumption can drift: Stripe ships changes on its own schedule, and your frozen handler won’t hear about them. The catch is a different test, named here only so you know it exists: a contract test runs against Stripe’s live sandbox on a schedule, nightly rather than per commit, to notice when reality has drifted from your assumption. Too slow and flaky for the inner loop, it lives outside it.
One habit follows: hand-write your fixtures, don’t record them. A recorded Stripe response arrives bloated with dozens of fields you never assert on, and when one of those drifts, nothing tells you. Five explicit lines naming only the fields your test reads, HttpResponse.json({ id: 'cs_test_123', url: '...' }), keep your assumption about Stripe’s response visible in the test.
A test does vi.mock('stripe') and asserts checkout.sessions.create ran with the expected line_items. It’s green. Which production failures is this test structurally unable to catch? Select all that apply.
priceId into line_items from the action.Idempotency-Key, so a double-clicked subscribe charges twice.Stripe-Version the account no longer accepts.create even though the member is on a plan that shouldn’t reach checkout.create, so a wrong priceId or an out-of-place call (your own control flow) stays visible to it. Anything the SDK does with that object on its way to the wire does not. That’s the whole argument for moving the mock down to the network.Your action writes an audit-log row to your own Postgres and fires an HTTP call to your internal notifications microservice — a separate service your team also runs. In one integration test, how should each collaborator be handled?
tx and roll it back; intercept the notifications request at the network and assert on it.notifications call go through for real, since it’s the side effect that matters most here.notifications happens to be your team’s service elsewhere doesn’t change the seam: from this test, it’s a third party reached over the network, handled exactly like Stripe.External resources
Section titled “External resources”The tool author's case for the exact thesis of this lesson: mock at the network level, not the call site.
Why mocking the client or fetch gives false confidence, and what to assert at the network instead.
A deep dive on the Idempotency-Key header this lesson highlights, and the safe-retry behavior the SDK gives you.