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.
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.
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 thatfetchdoes 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
XMLHttpRequestis not the browser’s. jsdom implements enough of the API for most code, but its CORS behaviour,withCredentialshandling 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.
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.
Related
- How to Intercept Fetch Requests in React — the fetch-side companion to this page
- Abstracting Network Layers for Frontend Apps — removing the transport question from feature code entirely
- Choosing Between Proxy and Service Worker Mocks — the layer decision one level further out
← Back to Request Interception Patterns