Featured image of post Shared HTTP Mocks Across a Monorepo: When Fakes Aren't the Right Tool

Shared HTTP Mocks Across a Monorepo: When Fakes Aren't the Right Tool

The previous two posts covered Fakes for internal services β€” but external HTTP APIs don't fit that pattern. This post shows how to share HTTP mocks across frontend and backend in a monorepo using MSW / msw-fetch-mock, complementing the Fake approach.

The previous posts covered DI + Fake + in-memory and sharing Fakes across a monorepo. That approach has one scope limit: services you own.

When the dependency is an external HTTP API (Stripe, GitHub, third-party SaaS), Fake isn’t the right tool. This post covers how to apply the same “share across layers” philosophy to HTTP mocks instead.

Why External HTTP Doesn’t Fit the Fake Pattern

The FakeUserService from the last post works because:

  • You own UserService’s interface and implementation
  • You know its behavioral invariants
  • The Fake shares the same interface as production

External HTTP APIs fail all three conditions:

  • The “interface” is someone else’s REST/GraphQL endpoint β€” out of your control
  • Behavior changes without warning
  • Production uses fetch + network; you can’t swap in a Fake class

Forcing a Fake becomes a maintenance nightmare: Stripe changes its response shape, FakeStripeService doesn’t follow, tests stay green while production breaks.

The Right Substitution Point: The Network Layer

For external HTTP, substitution happens at the HTTP call itself, not at some service interface. Intercept fetch / http.request and return fake responses.

That’s what MSW / msw-fetch-mock do:

  • Production code is unchanged (still calls fetch(...))
  • Tests intercept network requests and return the fake response you define
  • After tests, things return to normal

Compared to Fakes:

Fake (internal)HTTP mock (external)
Substitution pointinterfacenetwork layer
Needs DIyesno
Test setupinject the Fakestart a mock server
Production changesinterface can changefetch calls unchanged

The Sharing Challenge

Frontend and backend both call external APIs. Both need HTTP mocking.

Common wrong approach:

Frontend uses vi.spyOn(global, 'fetch')

1
2
3
vi.spyOn(global, 'fetch').mockResolvedValue(
  new Response(JSON.stringify({ data: 'fake' }))
);

Backend uses nock

1
nock('https://api.stripe.com').get('/v1/customers').reply(200, { data: 'fake' });

Two different APIs, two different behaviors β€” same external API, two mock implementations. As much of a mess as mock proliferation.

The Fix: Share the Same Handler Set

Same philosophy as Fakes β€” put HTTP mock handlers in a shared package, and every layer’s tests import the same set.

MSW supports this natively:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// shared/testing/stripe-handlers.ts
import { http, HttpResponse } from 'msw';

export const stripeHandlers = [
  http.get('https://api.stripe.com/v1/customers/:id', ({ params }) => {
    return HttpResponse.json({
      id: params.id,
      name: 'Test Customer',
      email: 'test@example.com',
    });
  }),

  http.post('https://api.stripe.com/v1/customers', async ({ request }) => {
    const body = await request.json();
    return HttpResponse.json({ id: 'cus_123', ...body }, { status: 201 });
  }),
];

This handler set feeds both frontend and backend tests.

Backend Usage

Node.js uses setupServer:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// server/src/__tests__/billing.test.ts
import { setupServer } from 'msw/node';
import { stripeHandlers } from '@app/shared/testing';

const server = setupServer(...stripeHandlers);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

test('creates customer via Stripe API', async () => {
  const billing = new BillingService();
  const customer = await billing.createCustomer({ name: 'Alice' });

  expect(customer.id).toBe('cus_123');
});

The fetch('https://api.stripe.com/...') inside BillingService is transparently intercepted by MSW. Production code needs zero test-aware changes.

Frontend Usage

Browser environments use Service Worker β€” but Vitest also works fine with the Node version:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// client/src/__tests__/checkout.test.tsx
import { setupServer } from 'msw/node';
import { stripeHandlers } from '@app/shared/testing';

const server = setupServer(...stripeHandlers);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

test('shows customer name after creation', async () => {
  render(<CheckoutForm />);
  fireEvent.click(screen.getByText('Create'));

  expect(await screen.findByText('Test Customer')).toBeInTheDocument();
});

Same stripeHandlers, works on both sides.

Customizing Responses: Testing Edge Cases

The shared handlers cover the happy path. Special cases get layered on with server.use():

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import { http, HttpResponse } from 'msw';

test('handles Stripe API error', async () => {
  server.use(
    http.post('https://api.stripe.com/v1/customers', () => {
      return HttpResponse.json(
        { error: { message: 'Card declined' } },
        { status: 402 }
      );
    })
  );

  const billing = new BillingService();
  await expect(billing.createCustomer({ name: 'Bob' })).rejects.toThrow('Card declined');
});

server.use() is temporary β€” resetHandlers() returns to the shared handlers.

MSW’s API Too Verbose? Try msw-fetch-mock

MSW’s native API writes one handler per endpoint, which can be verbose in tests. If you want features like times(n), persist(), assertNoPendingInterceptors(), use msw-fetch-mock β€” it sits on top of MSW with an API aligned to undici MockAgent and Cloudflare Workers fetchMock:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import { fetchMock } from 'msw-fetch-mock';

beforeAll(() => fetchMock.activate({ onUnhandledRequest: 'error' }));
afterAll(() => fetchMock.deactivate());
afterEach(() => {
  fetchMock.assertNoPendingInterceptors();
  fetchMock.reset();
});

test('creates customer', async () => {
  fetchMock.get('https://api.stripe.com')
    .intercept({ path: '/v1/customers', method: 'POST' })
    .reply(201, { id: 'cus_123' });

  const billing = new BillingService();
  const customer = await billing.createCustomer({ name: 'Alice' });

  expect(customer.id).toBe('cus_123');
});

Full API reference and comparison with other mocking libraries in the earlier msw-fetch-mock overview.

Why the Sharing Pattern Differs

Fakes and HTTP mocks solve two sides of the same problem:

Fakes share: interface + in-memory implementation Frontend and backend both import the FakeUserService class, plug it into a constructor.

HTTP mocks share: request handler lists Frontend and backend both import the stripeHandlers array, feed it to an MSW server.

One can’t replace the other:

  • Forcing HTTP mocks on internal services adds unnecessary network β€” production is a direct function call, why wrap it in HTTP just to mock
  • Forcing Fakes on external APIs reimplements someone else’s service by hand β€” behavior can never stay in sync

You need both.

The Complete Monorepo Testing Strategy

Combining all three posts, a mature monorepo testing architecture looks like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
packages/
  └── shared/
      └── testing/
          β”œβ”€β”€ fakes/              # Fakes for internal services
          β”‚   β”œβ”€β”€ fake-user-service.ts
          β”‚   └── fake-storage-service.ts
          β”œβ”€β”€ http-handlers/      # MSW handlers for external APIs
          β”‚   β”œβ”€β”€ stripe-handlers.ts
          β”‚   └── github-handlers.ts
          └── index.ts
  • Internal services β†’ Fakes (previous two posts)
  • External HTTP APIs β†’ MSW handlers (this post)
  • Every layer imports what it needs

Tests look consistent across layers:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import { FakeUserService } from '@app/shared/testing';
import { stripeHandlers } from '@app/shared/testing';
import { setupServer } from 'msw/node';

const server = setupServer(...stripeHandlers);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

test('creates user and customer', async () => {
  const userService = new FakeUserService();
  const service = new SignupService(userService);

  await service.signup({ name: 'Alice' });

  expect(await userService.get(1)).toBeDefined();   // internal: Fake state
  // Stripe call is intercepted by MSW and returns fake data (external HTTP)
});

When Neither Applies

A few rare cases neither fits:

  • Real API integration tests (contract testing, verifying Stripe’s actual behavior) β€” don’t mock, use the sandbox environment
  • Pure function testing (no external dependencies) β€” just pass arguments

The rule: mocks/fakes isolate “things not under test.” They’re not an excuse to hide dependencies.

Summary

The complete test-double strategy for a monorepo:

Dependency typeToolShared location
Internal service (stateful)Fakeshared/testing/fakes/
Internal service (pure functions)Stubinside the package
External HTTP APIMSW handlersshared/testing/http-handlers/
Real contract verificationDon’t mock β€” use sandboxN/A

The central claim of this three-post series: test doubles should be shared infrastructure across layers, not boilerplate re-written in every file. Fake and MSW answer at different layers, but the thinking is the same β€” write once, share, test behavior not implementation.

References