I spent months with flaky CI runs before I figured out the right way to mock third-party APIs in Jest — here’s the approach that finally made our test suite reliable.

Last updated: July 1, 2026

I used to dread Friday deploys because our test suite would randomly fail whenever Stripe’s sandbox environment had a slow response or a rate-limit hiccup. Our integration tests were making real network calls to third-party APIs, and every flaky failure cost us fifteen minutes of “is this actually broken or is it just the network again?” Learning to properly mock third-party APIs in Jest unit tests was what finally got our CI pipeline from “trust it eventually” to “trust it every time.” Here’s the exact setup I use now across every Node.js project I touch.

TL;DR

  • Use jest.mock() for module-level mocking when you control the client wrapper, and msw (Mock Service Worker) when you need to intercept raw HTTP calls without touching your code.
  • Never mock at the axios/fetch level directly if you can avoid it — mock at the boundary of your own API client wrapper instead, so your tests stay resilient to implementation changes.
  • Always test both the success path and at least two failure paths (timeout, 4xx/5xx) — most production incidents I’ve debugged came from unhandled third-party error responses, not the happy path.

Why Mocking Third-Party APIs in Jest Matters

Third-party API calls are the single biggest source of flakiness in JavaScript test suites, and it’s not close. Real network calls introduce latency, rate limits, and nondeterministic failures that have nothing to do with whether your code is correct. When you mock third-party APIs in Jest, you’re not just making tests faster — you’re removing an entire category of false negatives that erode trust in CI, which is exactly what happened to my team before we fixed this.

The second reason this matters is coverage. It’s genuinely hard to reliably trigger a 429 rate-limit response or a connection timeout from a real sandbox API on demand. Mocking lets you deterministically test those failure paths, which in my experience is where the actual production bugs live — nobody’s payment integration breaks because Stripe returned a normal 200, it breaks because Stripe returned a 503 during a deploy window and the retry logic had a bug.

Should I Mock Axios Directly or My Own API Client in Jest?

This comes up in almost every code review I’ve done. Mock your own client wrapper, not axios or fetch directly. Mocking the HTTP library couples your tests to a specific implementation detail, so switching from axios to fetch later breaks every single test even when your actual business logic hasn’t changed at all.

Prerequisites

  • Node.js 18+ and a project already using Jest (I’m on Jest 29.7 for this walkthrough).
  • npm install --save-dev msw@2 if you’re following the MSW section — version 2 changed the API significantly from version 1, so make sure your docs match your installed version.
  • A third-party API client wrapper in your own codebase. If you’re calling axios.get() directly scattered across your app, stop and build a thin wrapper first — it’ll make mocking dramatically simpler.

Security Note: Never let test mocks accidentally leak real API keys into your repo. I’ve seen .env.test files committed with real sandbox keys because someone copy-pasted from .env.local. Use obviously-fake values like sk_test_MOCK_KEY_DO_NOT_USE in test fixtures.

Step-by-Step Implementation

With the prerequisites out of the way, here’s the sequence I actually follow on a new project.

Step 1: Build a thin API client wrapper (if you don’t have one)

This is the foundation everything else depends on. Instead of calling axios directly in your business logic, wrap it:

// src/clients/stripeClient.js
const axios = require('axios');

async function createCharge(amount, currency, source) {
  const response = await axios.post('https://api.stripe.com/v1/charges', {
    amount, currency, source,
  }, {
    headers: { Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}` },
  });
  return response.data;
}

module.exports = { createCharge };

Step 2: Mock at the wrapper level with jest.mock()

This is my default approach for unit tests where I just need to verify my business logic handles the API response correctly — I don’t care about HTTP semantics, just the data shape.

// src/services/orderService.test.js
jest.mock('../clients/stripeClient');
const stripeClient = require('../clients/stripeClient');
const { processOrder } = require('./orderService');

describe('processOrder', () => {
  afterEach(() => jest.clearAllMocks());

  it('marks order as paid on successful charge', async () => {
    stripeClient.createCharge.mockResolvedValue({
      id: 'ch_mock123',
      status: 'succeeded',
    });

    const result = await processOrder({ amount: 5000, currency: 'usd' });

    expect(result.status).toBe('paid');
    expect(stripeClient.createCharge).toHaveBeenCalledWith(5000, 'usd', undefined);
  });

  it('marks order as failed when the charge is declined', async () => {
    stripeClient.createCharge.mockRejectedValue({
      response: { status: 402, data: { error: { code: 'card_declined' } } },
    });

    const result = await processOrder({ amount: 5000, currency: 'usd' });

    expect(result.status).toBe('failed');
    expect(result.reason).toBe('card_declined');
  });
});

Pro Tip: Always call jest.clearAllMocks() in afterEach, not just beforeEach. I got bitten once by a mock’s call history leaking into an unrelated test because I only cleared before, not after, and a later test in the same file ran with stale mock.calls data.

Step 3: Use MSW when you need to test the real HTTP layer

jest.mock() is great for unit tests, but it doesn’t exercise your actual axios/fetch configuration — headers, retries, timeout handling. For that, I use Mock Service Worker (MSW), which intercepts requests at the network level so your real HTTP client code actually runs.

// src/mocks/handlers.js
const { http, HttpResponse } = require('msw');

const handlers = [
  http.post('https://api.stripe.com/v1/charges', () => {
    return HttpResponse.json({ id: 'ch_mock123', status: 'succeeded' });
  }),
];

module.exports = { handlers };
// src/mocks/server.js
const { setupServer } = require('msw/node');
const { handlers } = require('./handlers');

const server = setupServer(...handlers);
module.exports = { server };
// jest.setup.js
const { server } = require('./src/mocks/server');

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

The onUnhandledRequest: 'error' option is the part I always recommend — it makes any unmocked request fail loudly instead of silently hitting the real network, which is exactly the flakiness source we’re trying to eliminate.

Step 4: Test timeout and rate-limit scenarios explicitly

This is the step most teams skip, and it’s the one that matters most in my experience.

it('retries once on a 429 rate-limit response', async () => {
  let callCount = 0;
  server.use(
    http.post('https://api.stripe.com/v1/charges', () => {
      callCount++;
      if (callCount === 1) {
        return new HttpResponse(null, { status: 429 });
      }
      return HttpResponse.json({ id: 'ch_retry_success', status: 'succeeded' });
    })
  );

  const result = await processOrder({ amount: 5000, currency: 'usd' });

  expect(callCount).toBe(2);
  expect(result.status).toBe('paid');
});

it('handles a connection timeout gracefully', async () => {
  server.use(
    http.post('https://api.stripe.com/v1/charges', () => {
      return HttpResponse.error();
    })
  );

  const result = await processOrder({ amount: 5000, currency: 'usd' });

  expect(result.status).toBe('failed');
  expect(result.reason).toBe('network_error');
});

Step 5: Snapshot the request shape, not just the response

One mistake I made early on: I tested that my code handled mocked responses correctly, but never verified I was sending the request in the shape the real API expects. A schema change on my side could pass every test and still break in production.

it('sends the correctly shaped charge request', async () => {
  let capturedBody;
  server.use(
    http.post('https://api.stripe.com/v1/charges', async ({ request }) => {
      capturedBody = await request.json();
      return HttpResponse.json({ id: 'ch_mock123', status: 'succeeded' });
    })
  );

  await processOrder({ amount: 5000, currency: 'usd' });

  expect(capturedBody).toMatchSnapshot();
});

Real-World Tips I Use in Production

  • Keep mock fixtures in a shared __fixtures__ directory so multiple test files don’t drift into slightly different fake response shapes over time.
  • For APIs with official SDKs (Stripe, Twilio, AWS SDK), check whether they ship a testing/mock mode before building your own — the AWS SDK’s aws-sdk-client-mock package saved me a lot of boilerplate.
  • Contract-test your mocks periodically against the real sandbox API in a separate, non-blocking CI job, so mock drift doesn’t silently accumulate over months.

Common Errors and How I Fixed Them

jest.mock() factory doesn’t hoist correctly with ES modules — I hit ReferenceError: Cannot access 'mockCreateCharge' before initialization because I referenced a variable inside the mock factory that hadn’t been hoisted. Fix: define mock functions inline inside the factory itself, or use jest.mock('../clients/stripeClient', () => ({ createCharge: jest.fn() })).

MSW intercepts nothing and the real network call goes through — this happened because I registered server.listen() in a beforeEach in one file that ran after another file’s afterAll had already called server.close(). Fix: put server lifecycle hooks in a single global jest.setup.js, not per-file.

Snapshot tests failing on every run due to non-deterministic fields — timestamps and generated IDs in the mocked response kept changing. Fix: use Jest’s property matchers, expect(result).toMatchSnapshot({ id: expect.any(String), createdAt: expect.any(Number) }).

[INTERNAL LINK: related article]

FAQ

Q: What’s the difference between jest.mock() and MSW for mocking third-party APIs? A: jest.mock() replaces your own module (like an API client wrapper) at the JavaScript level, while MSW intercepts actual HTTP requests, letting your real network code run against a fake server.

Q: Should I mock axios directly or mock my own API client wrapper? A: Mock your own wrapper. Mocking axios directly couples your tests to a specific HTTP library, so switching to fetch or got later breaks every test even if your business logic hasn’t changed.

Q: How do I test rate-limiting and retry logic without hitting real API limits? A: Use MSW’s server.use() to override a handler for a single test, returning a 429 on the first call and a success on the second, which lets you deterministically verify retry behavior.

Q: Is it bad practice to make real API calls in integration tests? A: For CI pipelines, yes — real calls introduce flakiness from latency and rate limits. I recommend a separate, non-blocking “contract test” suite that hits the real sandbox periodically instead of on every commit.

Q: How do I avoid mock drift when the real third-party API changes its response shape? A: Run a scheduled CI job that validates your mock fixtures against the real sandbox API’s actual response schema, separate from your main fast unit test suite.

Conclusion

Mocking third-party APIs well in Jest comes down to picking the right layer for the right test: jest.mock() for fast unit tests of your business logic, MSW when you need to exercise your real HTTP layer, and always, always testing the failure paths, not just the happy path. If you’ve got a mocking pattern that’s worked well for your team, I’d love to hear about it in the comments.

About the Author

I’m a senior software engineer with eight years of experience building and testing Node.js and React applications, with a focus on payment integrations and API reliability. I currently maintain test infrastructure for a team shipping multiple third-party integrations weekly. You can find more of my writing on testing and backend engineering at SpiritCode.