Intercepting XMLHttpRequest and Axios Calls

Your handlers work perfectly for everything written with fetch, and the one legacy module using XMLHttpRequest sails straight past them to the real network. This page explains why that happens, how to cover both transports with a single handler set, and when an Axios-level mock is the right choice instead.

Context: two transports, one intention

The browser has two HTTP transports that share nothing. fetch is promise-based and built on the Request/Response objects a modern mock layer speaks natively. XMLHttpRequest is event-based, older, and still what a large amount of code uses — directly in legacy modules, and indirectly through libraries that chose it as their default.

Axios sits above both and picks one at runtime. In the browser it has historically used XMLHttpRequest; in Node it uses the http module; recent versions can be configured with a fetch adapter. Which one runs depends on the version, the environment and the configuration, which means you cannot reason about the transport from the call site.

That is the whole problem. A mock that patches globalThis.fetch covers one of three possibilities, and which possibility you got is decided by a dependency’s minor version.

Where to cut: library level versus transport level Application code reaches the network through three routes: a direct fetch call, a direct XMLHttpRequest, or an Axios call that itself resolves to XHR in the browser or the Node http module on the server. A library-level mock patches only the Axios adapter and misses the two direct routes. A transport-level mock patches fetch, XMLHttpRequest and the http module, so all three routes are covered by one handler set. Application code fetch(...) new XMLHttpRequest() axios(...) picks a transport at runtime node: http module browser: XHR Library-level mock replaces the Axios adapter misses direct fetch and XHR skips interceptors below it Transport-level mock patches fetch, XHR and http one handler set covers all three library behaviour still exercised

Solution

1. Confirm which transport actually runs

Before changing anything, find out. A one-line probe in the browser console settles it:

// Paste into the console before triggering the call you care about.
const realOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
  console.info('[xhr]', method, url);
  return realOpen.call(this, method, url, ...rest);
};
const realFetch = window.fetch;
window.fetch = (...args) => {
  console.info('[fetch]', args[0] instanceof Request ? args[0].url : args[0]);
  return realFetch(...args);
};

Whichever prefix appears is the transport the mock has to cover. This takes thirty seconds and routinely overturns a confident assumption.

2. Intercept below the library

MSW patches all three transports, so the same handlers serve fetch, XMLHttpRequest and Node’s http:

// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('https://api.example.com/orders/:id', ({ params }) =>
    HttpResponse.json({ id: params.id, status: 'paid', totalMinor: 4250 })
  ),
  http.post('https://api.example.com/orders', async ({ request }) => {
    const body = (await request.json()) as { totalMinor: number };
    return HttpResponse.json({ id: 'ord_new', status: 'pending', ...body }, { status: 201 });
  }),
];

Nothing in those handlers mentions a transport, and that is the point. The same file now covers this:

// Direct fetch
const a = await (await fetch('https://api.example.com/orders/ord_1')).json();

// Direct XHR
const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://api.example.com/orders/ord_1');
xhr.onload = () => console.log(JSON.parse(xhr.responseText));
xhr.send();

// Axios, whichever transport it picks
import axios from 'axios';
const c = (await axios.get('https://api.example.com/orders/ord_1')).data;

Crucially, the Axios path still runs Axios’s own request interceptors, header defaults, parameter serialisation and response transforms — because the mock sits underneath it. A library-level mock replaces the adapter and therefore skips all of that, so a bug in your own Axios interceptor is invisible to it.

3. Handle the Node test environment

Under Vitest or Jest with the default Node environment there is no XMLHttpRequest global at all. Code reaching for it throws ReferenceError, which is easy to misread as a handler problem. Either run those specs under jsdom, or assert the transport explicitly:

// vitest.config.ts
export default {
  test: {
    // XHR-using modules need a DOM environment to have XMLHttpRequest at all.
    environmentMatchGlobs: [
      ['src/legacy/**', 'jsdom'],
      ['src/**', 'node'],
    ],
    setupFiles: ['./vitest.setup.ts'],
  },
};
// src/legacy/upload.test.ts
import { expect, it } from 'vitest';
import { uploadViaXhr } from './upload';

it('is intercepted even though it uses XMLHttpRequest', async () => {
  expect(typeof XMLHttpRequest).toBe('function');   // fails fast under the wrong environment
  const result = await uploadViaXhr('/api/orders', { totalMinor: 1200 });
  expect(result.status).toBe('pending');
});

The typeof assertion looks redundant until the day someone changes the environment glob and every XHR spec starts failing with an error that says nothing about environments.

4. When an Axios-level mock is right

There is one case where replacing the adapter is the correct tool: when the test is about Axios configuration. Verifying that a retry interceptor is registered, or that paramsSerializer produces the expected query string, is a test of the library, and mocking below it adds noise.

import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import { expect, it } from 'vitest';
import { apiClient } from './client';

it('serialises array params in the bracket style the API expects', async () => {
  const mock = new MockAdapter(apiClient);
  mock.onGet('/orders').reply(200, []);

  await apiClient.get('/orders', { params: { status: ['paid', 'shipped'] } });

  expect(mock.history.get[0].url).toContain('status[]=paid&status[]=shipped');
  mock.restore();
});

Keep these tests few and clearly labelled. Once adapter-level mocks spread into feature tests, half the suite stops exercising the transport and nobody can tell which half.

Choosing the interception layer Four rows pairing a kind of test with the interception layer it should use. Feature behaviour, legacy XHR modules and cross-transport coverage all belong at the transport layer. Only tests about Axios configuration itself belong at the adapter layer, and the consequence of each choice is named. What the test is about Intercept at Because feature behaviour transport one handler set, library still exercised a legacy XHR module transport an adapter mock cannot see it at all the same endpoint from two clients transport both routes converge on one definition Axios interceptors or serialisation adapter the library itself is the subject

Verification

# Both transports must be covered by the same handlers
npx vitest run src/legacy src/features --reporter=basic

# Nothing escaped to the network — requires onUnhandledRequest: 'error'
npx vitest run 2>&1 | grep -i 'intercepted a request without a matching handler' && exit 1 || echo 'all requests handled'

Setting onUnhandledRequest: 'error' in server.listen() is what makes the second command meaningful. Without it, an XHR call that escapes the mock reaches the real network and the suite stays green.

Gotchas and edge cases

  • responseType: 'blob' and progress events are not free. XHR exposes upload and download progress that fetch does not, and a mocked response delivered in one chunk fires a single progress event at 100%. A progress bar that looks smooth in production will jump straight to full locally, so assert on completion rather than on intermediate progress values.

  • Axios throws on non-2xx; fetch does not. A component that works under Axios error handling can silently render an empty success view when the same endpoint is called with fetch. If your codebase mixes clients, write the error-path assertion against the rendered surface, as returning HTTP 500 errors on demand sets out.

  • A jsdom XMLHttpRequest is not the browser’s. jsdom implements enough of the API for most code, but its CORS behaviour, withCredentials handling and binary support differ. A spec that passes under jsdom is evidence, not proof; keep one browser-level test for anything that depends on those, using browser test runner integration.


Identifying the transport in seconds Three signals that identify the transport without reading any library source: the DevTools initiator column, a temporary console patch on both globals, and whether the failure is a rejection or a resolved non-ok response. Each takes seconds and settles the question definitively. The DevTools initiator column names the calling code and the API used fastest, needs no code change A temporary patch on both globals logs every call with a prefix definitive, works in tests too Rejection versus a resolved non-ok Axios rejects, fetch does not tells you which client, not which transport The middle one takes thirty seconds and routinely overturns a confident assumption about which transport is in use.

Auditing a codebase for its transports

Before deciding what to intercept, it is worth knowing what is actually in use. A short audit answers it definitively.

Grep for new XMLHttpRequest, for axios, for fetch( and for whichever wrapper the project has accumulated. Then check the dependency tree for libraries that make requests of their own — analytics clients, feature-flag SDKs, error reporters and payment widgets are the usual ones, and they frequently use a transport nobody chose.

The third-party clients are the interesting result. They are rarely covered by a handler, they often fire on page load, and in a test environment they will happily reach a real endpoint. Adding them to the mock — or blocking them explicitly — closes a gap that most suites have and few notice.

← Back to Request Interception Patterns