Stubbing axios interceptors in Vitest

Axios interceptors are the hardest part of an axios client to test because they hold cross-cutting logic — auth-token injection, error normalisation, retry signalling — that runs on every request and response. This guide is for frontend and full-stack engineers using Vitest 1.x or 2.x who need to stub, replace, or assert on axios request and response interceptors without leaking handlers between tests. It covers three approaches — vi.spyOn on the interceptor manager, vi.mock factories, and adapter-level mocking — and the restore discipline that keeps each one clean. It builds directly on the interception layers described in HTTP request stubbing techniques and treats Vitest as the runner throughout.

Root Cause Analysis

Interceptors are not ordinary functions you can mock by name; they are entries in a stack managed by axios.interceptors.request and axios.interceptors.response. Calling use() returns a numeric id and pushes a handler; the handler stays registered for the lifetime of that axios instance. This design is the source of three distinct testing problems.

First, registration is stateful and shared. When you import the default axios singleton, every test file that registers an interceptor on it mutates the same global stack. A token-injecting interceptor added in one test fires in every subsequent test, silently rewriting requests and producing assertions that pass for the wrong reason. Second, interceptors run real logic on synthetic data. If you stub only the network layer, your response interceptor still executes against the mocked response — which is usually what you want, but it means a bug in the interceptor surfaces as a confusing failure in an unrelated assertion. Third, ejection is easy to forget. axios.interceptors.request.eject(id) removes a handler, but if a test throws before reaching its teardown, the handler survives, and the leak compounds exactly like the memory and handler retention problems seen with un-reset stubs.

The fix in every case is the same principle that underpins all service isolation: isolate the unit, run the real pipeline only where it is the thing under test, and restore aggressively so no state crosses a test boundary.

Reproducible Setup

Install the dependencies and create a small client that registers both a request and a response interceptor — this is the unit under test.

npm install axios
npm install --save-dev vitest axios-mock-adapter
// src/api/client.ts
import axios, { AxiosInstance, InternalAxiosRequestConfig, AxiosResponse } from 'axios';

export function createClient(getToken: () => string | null): AxiosInstance {
  const instance = axios.create({ baseURL: '/api' });

  instance.interceptors.request.use((config: InternalAxiosRequestConfig) => {
    const token = getToken();
    if (token) config.headers.set('Authorization', `Bearer ${token}`);
    return config;
  });

  instance.interceptors.response.use(
    (res: AxiosResponse) => res,
    (error) => Promise.reject(new Error(`normalized: ${error.response?.status ?? 'network'}`)),
  );

  return instance;
}

Using axios.create() rather than the default singleton is the single most important setup decision: each test can build a fresh instance with its own interceptor stack, eliminating cross-file bleed before it can start.

Implementation

Step 1: Spy on the interceptor manager with vi.spyOn

When you want to assert that a client registers an interceptor — without running it — spy on interceptors.request.use. This verifies wiring without invoking network logic.

// register.test.ts
import axios from 'axios';
import { afterEach, expect, it, vi } from 'vitest';
import { createClient } from '../src/api/client';

afterEach(() => vi.restoreAllMocks());

it('registers exactly one request interceptor', () => {
  const instance = axios.create();
  vi.spyOn(axios, 'create').mockReturnValue(instance);
  const useSpy = vi.spyOn(instance.interceptors.request, 'use');

  createClient(() => 'tok');

  expect(useSpy).toHaveBeenCalledTimes(1);
});

vi.restoreAllMocks() in afterEach returns interceptors.request.use to its original implementation, so no spy leaks into the next test.

Step 2: Exercise the real interceptor against a mocked adapter

To test what the request interceptor actually does, let it run and inspect the outgoing config via axios-mock-adapter, which intercepts at the adapter layer so the full interceptor pipeline executes first.

// inject.test.ts
import MockAdapter from 'axios-mock-adapter';
import { afterEach, beforeEach, expect, it } from 'vitest';
import { createClient } from '../src/api/client';

let client = createClient(() => 'secret-token');
let mock: MockAdapter;

beforeEach(() => {
  client = createClient(() => 'secret-token');
  mock = new MockAdapter(client);
});
afterEach(() => { mock.reset(); mock.restore(); });

it('injects the Authorization header via the request interceptor', async () => {
  mock.onGet('/api/me').reply(200, { ok: true });
  await client.get('/me');
  expect(mock.history.get[0].headers?.Authorization).toBe('Bearer secret-token');
});

Because the adapter records mock.history, you assert on the header the interceptor wrote — proof the interceptor ran — while mock.restore() removes the adapter so it never accumulates.

Step 3: Assert the response interceptor normalises errors

The response interceptor’s error branch is the most valuable thing to test. Drive it by replying with a failing status and asserting the normalised error shape.

// normalize.test.ts
import MockAdapter from 'axios-mock-adapter';
import { afterEach, beforeEach, expect, it } from 'vitest';
import { createClient } from '../src/api/client';

let client = createClient(() => null);
let mock: MockAdapter;

beforeEach(() => {
  client = createClient(() => null);
  mock = new MockAdapter(client);
});
afterEach(() => { mock.reset(); mock.restore(); });

it('rewrites a 500 into a normalized error', async () => {
  mock.onGet('/api/data').reply(500);
  await expect(client.get('/data')).rejects.toThrow('normalized: 500');
});

Step 4: Replace an interceptor wholesale with vi.mock

When the interceptor depends on an external module — say a token store — stub that module with a vi.mock factory rather than the interceptor itself. This keeps the interceptor’s own logic real while controlling its inputs deterministically.

// token.test.ts
import { afterEach, expect, it, vi } from 'vitest';
import MockAdapter from 'axios-mock-adapter';

vi.mock('../src/api/token-store', () => ({
  getToken: vi.fn(() => 'mocked-token'),
}));

import { getToken } from '../src/api/token-store';
import { createClient } from '../src/api/client';

afterEach(() => vi.restoreAllMocks());

it('reads the token from the mocked store', async () => {
  const client = createClient(getToken);
  const mock = new MockAdapter(client);
  mock.onGet('/api/ping').reply(200, {});
  await client.get('/ping');
  expect(mock.history.get[0].headers?.Authorization).toBe('Bearer mocked-token');
  mock.restore();
});

Step 5: Restore interceptors between tests when you must use the singleton

If legacy code registers on the default axios singleton, capture the id and eject it in teardown so the handler cannot bleed.

import axios from 'axios';
import { afterEach, beforeEach } from 'vitest';

let interceptorId: number;
beforeEach(() => {
  interceptorId = axios.interceptors.request.use((c) => c);
});
afterEach(() => {
  axios.interceptors.request.eject(interceptorId);
});

Verification

A correctly isolated interceptor suite is order-independent: run it with npx vitest run and then with --sequence.shuffle — both must pass. Shuffling surfaces bleed instantly, because a handler that leaked from one test will rewrite a request in an unrelated test and flip an assertion. With per-instance axios.create() and mock.restore() in teardown, the shuffled run is green.

For the spy-based tests, assert exact call counts (toHaveBeenCalledTimes(1)) rather than just toHaveBeenCalled(); a count of two means a previous test’s spy or interceptor survived teardown. For the adapter tests, mock.history.get.length should equal the number of requests the test itself made — any surplus is a leaked interceptor re-firing. These two checks together prove both that your interceptor ran and that nothing else did.

Troubleshooting

Authorization header appears in tests that should have none. A request interceptor leaked from another file because it was registered on the default axios singleton. Switch the client to axios.create() per test, or eject the handler in afterEach as in Step 5.

vi.mock of the token store does nothing. vi.mock is hoisted to the top of the file, so the mocked module must be imported after the vi.mock call, and the path must match the import in the code under test exactly. A relative-path mismatch silently mocks nothing.

Response interceptor runs twice per request. You registered the response interceptor in both the client factory and a test beforeEach on the same instance. Register interceptors in exactly one place; if the test needs an extra one, add it to a fresh instance and eject it on teardown.

FAQ

Should I stub the interceptor function or the network layer?

Prefer stubbing the network layer with axios-mock-adapter and letting the real interceptor run, because the interceptor is usually the behaviour you actually want to verify. Stub the interceptor itself only when you need to assert that it was registered without executing its logic, which is what vi.spyOn(instance.interceptors.request, 'use') gives you.

Does this work with the global axios singleton or only with axios.create()?

Both work, but axios.create() is strongly preferred for tests because each instance owns an independent interceptor stack, so there is nothing to leak across files. With the singleton you must capture each use() id and eject() it in teardown, and a single missed ejection causes order-dependent failures.

How is stubbing axios interceptors different from mocking fetch?

fetch has no interceptor concept — you replace the whole function with vi.stubGlobal, which bypasses all client logic. Axios interceptors are a real pipeline you usually want to execute, so the adapter approach keeps that pipeline intact while controlling only the transport. The contrast is covered alongside the leak-safe fetch patterns in the memory-leak guide.

Why must I call mock.restore() and not just mock.reset()?

reset() clears the registered routes but leaves the mock adapter installed on the instance, so adapters accumulate if you create one per test. restore() removes the adapter entirely and returns the instance to its real adapter, which is what prevents the cross-test accumulation that drives heap growth.