Skip to content
Chapter 88Lesson 4

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:

src/server/actions/createSubscription.int.test.ts
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.

Every checkout.sessions.create call is a trip down this tower. The only line you wrote is the top one; the SDK generates everything below it.

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 . So line_items: [{ price: 'price_x', quantity: 1 }] goes out as line_items[0][price]=price_x&line_items[0][quantity]=1.
  • Sign and set headers. The SDK attaches Authorization: Bearer sk_..., a Stripe-Version, and an Idempotency-Key that 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 StripeError you can catch.

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.1
Host: api.stripe.com
Authorization: Bearer sk_live_***
Stripe-Version: 2025-04-30
Idempotency-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_42

Method, 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.1
Host: api.stripe.com
Authorization: Bearer sk_live_***
Stripe-Version: 2025-04-30
Idempotency-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_42

Three 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.1
Host: api.stripe.com
Authorization: Bearer sk_live_***
Stripe-Version: 2025-04-30
Idempotency-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_42

The 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.

1 / 1

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.

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.

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.

One action, two boundaries, opposite rules — keyed on which one you own.

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.

Mock the wire (MSW) You don't own it — freeze the request
Run it, roll back (tx) You own it — run the real thing
stripeClient.checkout.sessions.create
An insert into your own invoices table
A resend.emails.send call
A Drizzle query against your own schema
An OpenAI completion call
Reading your own session row

If every database item landed in “run it, roll back,” the rule transferred: one question, not two techniques.

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 it
expect(stripeClient.checkout.sessions.create).toHaveBeenCalledWith(/* ... */);
// ✓ asserts the request Stripe would have received
expect(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.

You passed the wrong priceId into line_items from the action.
A regression drops the auto-generated Idempotency-Key, so a double-clicked subscribe charges twice.
Stripe starts 400ing because the SDK negotiates a Stripe-Version the account no longer accepts.
The action calls create even though the member is on a plan that shouldn’t reach checkout.

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?

Mock both: each is a collaborator the action depends on, and mocking keeps the test fast and isolated.
Let the audit-log insert hit the database inside tx and roll it back; intercept the notifications request at the network and assert on it.
Run both against the real services — only an end-to-end path proves the action actually works.
Stub the audit-log write, but let the notifications call go through for real, since it’s the side effect that matters most here.