axios-mock-adapter、fetch-mock、msw 和 nock 都是用于在 JavaScript 项目中模拟 HTTP 请求的工具,但它们的实现原理和适用场景截然不同。axios-mock-adapter 专门针对 Axios 库,通过拦截 Axios 内部的适配器层来返回假数据,不经过真实网络。fetch-mock 全局替换原生的 fetch API,适用于使用原生 fetch 或基于 fetch 封装的项目。msw (Mock Service Worker) 采用了独特的 Service Worker 方案,在浏览器网络层拦截请求,能够同时支持浏览器端和 Node.js 端(通过 node 环境模拟),提供最真实的网络模拟体验。nock 则是 Node.js 领域的老牌工具,直接拦截底层的 HTTP/HTTPS 模块,主要用于后端单元测试或 Node.js 脚本测试,无法在浏览器中运行。
在前端工程化中,模拟 HTTP 请求是单元测试、集成测试和本地开发的基石。面对 axios-mock-adapter、fetch-mock、msw 和 nock 这四个主流工具,许多开发者容易混淆它们的适用边界。本文将深入底层原理,对比它们在真实工程场景中的表现,帮助你做出符合架构长远利益的决策。
理解这些工具的关键,在于明白它们究竟在“哪一层”拦截了请求。拦截层级决定了你能模拟的真实度以及运行的环境限制。
axios-mock-adapter 工作在 应用层库内部。
它不接触真实的网络栈,而是直接替换了 Axios 实例内部的 adapter 函数。当代码调用 axios.get 时,请求甚至不会离开 JavaScript 运行时。
// axios-mock-adapter: 直接挂钩 Axios 内部
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
const mock = new MockAdapter(axios);
// 拦截特定 URL,直接返回 Promise .resolve
mock.onGet('/users').reply(200, { data: [{ id: 1, name: 'Alice' }] });
// 代码执行时,没有网络包发出
axios.get('/users').then(response => {
console.log(response.data); // { data: [{ id: 1, name: 'Alice' }] }
});
fetch-mock 工作在 全局 API 层。
它通过替换全局上下文中的 window.fetch (浏览器) 或 global.fetch (Node) 函数来生效。任何调用原生 fetch 的代码都会被它捕获。
// fetch-mock: 替换全局 fetch 函数
import fetchMock from 'fetch-mock';
// 定义全局拦截规则
fetchMock.get('/users', { body: { data: [{ id: 1, name: 'Alice' }] }, status: 200 });
// 任何地方的 fetch 调用都会被拦截
fetch('/users').then(res => res.json()).then(data => {
console.log(data); // { data: [{ id: 1, name: 'Alice' }] }
});
msw (Mock Service Worker) 工作在 网络层 (Service Worker)。
这是它最独特的地方。它在浏览器中注册一个 Service Worker,利用浏览器原生的网络拦截能力。请求会真正发出,但在离开浏览器前被 Service Worker 截获并返回响应。在 Node.js 中,它通过补丁 http 模块来模拟这一行为。
// msw: 通过 Service Worker 拦截网络流
import { setupWorker, rest } from 'msw';
const worker = setupWorker(
rest.get('/users', (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({ data: [{ id: 1, name: 'Alice' }] })
);
})
);
worker.start();
// 这里的 fetch 是真实的网络调用,但被 SW 拦截
fetch('/users').then(res => res.json()).then(data => {
console.log(data); // { data: [{ id: 1, name: 'Alice' }] }
});
nock 工作在 Node.js 底层 HTTP 模块层。
它直接拦截 Node.js 核心的 http 和 https 模块。这意味着它只能运行在 Node 环境中,完全无法在浏览器中工作。
// nock: 拦截 Node.js 底层 HTTP 请求
import nock from 'nock';
import axios from 'axios'; // 或者 https 模块
// 拦截对 example.com 的请求
nock('http://example.com')
.get('/users')
.reply(200, { data: [{ id: 1, name: 'Alice' }] });
// 在 Node 环境中,axios 底层调用 http 模块,被 nock 捕获
axios.get('http://example.com/users').then(response => {
console.log(response.data); // { data: [{ id: 1, name: 'Alice' }] }
});
环境支持往往是选型的一票否决项。
msw 是唯一真正意义上 同构 (Isomorphic) 的方案。同一套 Mock 规则可以直接用于浏览器的开发调试(通过 DevTools 可见)和 Node.js 的 CI 测试。这消除了“测试通过但线上报错”的环境差异风险。axios-mock-adapter 和 fetch-mock 虽然可以在 Node 和浏览器中运行,但它们的机制不同。fetch-mock 在 Node 中需要确保全局 fetch 可用(通常需 polyfill)。nock 是 Node.js 专属。如果你尝试在浏览器打包代码中引入 nock,构建通常会失败,或者运行时报错,因为它依赖 Node 的核心模块。在测试复杂交互(如加载状态、重试机制、超时处理)时,模拟的真实度至关重要。
msw 提供最真实的体验。因为它处于网络层,它可以轻松模拟网络延迟、丢包、超时,甚至模拟浏览器控制台看到的网络错误。
// msw: 轻松模拟 500ms 网络延迟
rest.get('/slow-api', (req, res, ctx) => {
return new Promise(resolve => {
setTimeout(() => {
resolve(res(ctx.json({ message: 'Delayed response' })));
}, 500);
});
});
// msw: 模拟网络错误 (如断网)
rest.get('/flaky-api', (req, res, ctx) => {
return res.networkError('Failed to connect');
});
axios-mock-adapter 也可以模拟延迟,但需要手动包装 Promise,且无法模拟真实的 TCP 连接超时或 DNS 解析失败等底层错误。
// axios-mock-adapter: 手动模拟延迟
mock.onGet('/slow-api').reply(() => {
return new Promise(resolve => {
setTimeout(() => resolve([200, { message: 'Delayed' }]), 500);
});
});
// 很难模拟真实的“网络断开”错误,通常只能 reject Promise
mock.onGet('/error').replyRejected();
fetch-mock 和 nock 也支持延迟模拟,但同样受限于它们是在函数调用层而非真实网络栈层。
// fetch-mock: 模拟延迟
fetchMock.get('/slow-api', () => {
return new Promise(resolve => {
setTimeout(() => resolve({ body: { message: 'Delayed' } }), 500);
});
});
// nock: 模拟延迟
nock('http://api.example.com')
.get('/slow-api')
.delay(500)
.reply(200, { message: 'Delayed' });
开发体验的差异在调试阶段尤为明显。
使用 msw 时,你可以在浏览器的 DevTools Network 面板 中清晰地看到被拦截的请求。它们显示为真实的网络请求,只是发起者是 Service Worker。这让开发者能直观地检查请求头、Payload 和响应,就像在调用真实后端一样。
// msw: 配合浏览器 DevTools
// 请求出现在 Network 标签页,状态码、Headers 完全可见
// 支持热重载:修改 mock 文件,无需刷新页面即刻生效
使用 axios-mock-adapter 或 fetch-mock 时,Network 面板通常是空的(因为没有真实网络流量)。你只能通过 Console 日志或断点来调试。这在排查“为什么请求没发出去”或“Headers 对不对”的问题时,效率较低。
// axios-mock-adapter / fetch-mock
// Network 面板无记录
// 必须依赖 console.log 或 debugger 断点来确认拦截是否命中
从架构角度看,msw 提倡将 Mock 定义与测试代码分离。你可以维护一个独立的 mocks/browser.js 文件,供整个团队共享,甚至可以作为“后端契约”的临时替代品供前端开发使用。
// msw: 独立的 mock 定义文件 (src/mocks/handlers.js)
export const handlers = [
rest.get('/users', handler)
];
// 测试文件中只需引入并启动
import { setupServer } from 'msw/node';
import { handlers } from '../mocks/handlers';
const server = setupServer(...handlers);
而 axios-mock-adapter 通常紧密耦合在测试文件内部,导致每个测试文件都要重复定义 mock 逻辑,难以复用。
// axios-mock-adapter: 往往散落在各个测试文件中
it('should fetch users', () => {
const mock = new MockAdapter(axios);
mock.onGet('/users').reply(200, users);
// 测试逻辑...
// 测试结束后需要 mock.reset() 防止污染其他测试
});
| 特性 | msw | axios-mock-adapter | fetch-mock | nock |
|---|---|---|---|---|
| 拦截层级 | 网络层 (Service Worker) | Axios 适配器层 | 全局 fetch 函数 | Node http 模块 |
| 浏览器支持 | ✅ 完美 (原生体验) | ✅ 支持 | ✅ 支持 | ❌ 不支持 |
| Node.js 支持 | ✅ 支持 (模拟层) | ✅ 支持 | ✅ 支持 (需 polyfill) | ✅ 完美 (原生) |
| Network 面板可见 | ✅ 是 | ❌ 否 | ❌ 否 | N/A (Node) |
| 模拟真实延迟/错误 | ✅ 极高 | ⚠️ 中等 (Promise 级) | ⚠️ 中等 | ⚠️ 中等 |
| 代码耦合度 | 低 (独立定义) | 高 (绑定 Axios) | 中 (绑定 fetch) | 高 (绑定 Node) |
| 推荐场景 | 全栈模拟、E2E 测试、现代前端 | 纯 Axios 项目快速单元测试 | 原生 fetch 项目简单测试 | Node.js 后端/脚本测试 |
首选 msw:对于 2024 年及以后的现代前端项目,msw 是架构上的最优解。它消除了环境与行为的差异,提供了最接近真实的开发调试体验。虽然上手需要配置 Service Worker,但这笔投资会在长期的维护和协作中获得丰厚回报。
保留 axios-mock-adapter:仅当你维护一个老旧的、重度依赖 Axios 且测试覆盖率要求不高的小型项目时,为了快速上手可以继续使用。但在新功能开发中,建议逐步迁移至 MSW。
慎用 nock:请严格将其限制在 Node.js 后端服务、CLI 工具或构建脚本的测试中。不要试图用它来测试前端浏览器逻辑。
关于 fetch-mock:它是一个合格的工具,但在 MSW 出现后,其生态位略显尴尬。除非你有极特殊的理由不能使用 Service Worker(如极旧的浏览器兼容需求且不使用 polyfill),否则 MSW 通常是更好的选择。
如果你的项目强依赖 Axios 且只需要在单元测试中快速返回静态数据,选择 axios-mock-adapter 是最简单的方案。它配置极其简单,无需处理网络层细节,适合纯逻辑测试。但请注意,它无法模拟真实的网络延迟、超时或浏览器特有的网络行为,且耦合了 Axios,如果未来迁移到 fetch 会增加重构成本。
如果你的项目主要使用原生 fetch API 且需要在 Jest 等环境中进行全局拦截,fetch-mock 是一个轻量级的选择。它适合那些不希望引入 Service Worker 复杂度,但又需要比 Axios 适配器更贴近浏览器原生行为的场景。不过,随着 MSW 的成熟,除非你有特殊的全局覆盖需求且不愿配置 Service Worker,否则新项目更推荐 MSW。
对于现代前端应用,尤其是需要同时在浏览器开发环境和 Node.js 测试环境中保持一致行为的团队,msw 是首选架构方案。它通过 Service Worker 拦截请求,能真实模拟网络延迟、错误码、Cookie 和缓存策略,极大提升了端到端测试和开发体验的可信度。虽然初始配置稍复杂,但它解耦了业务代码与模拟逻辑,支持热更新 mock 规则而无需刷新页面。
仅当你在编写纯 Node.js 环境下的后端服务测试、CLI 工具或构建脚本,且需要拦截底层 HTTP 请求时,才选择 nock。它在前端浏览器环境中完全无效。如果你的测试涉及复杂的 Node.js 内部模块交互或对底层 TCP/HTTP 行为有精细控制需求,nock 依然是该领域的权威工具,但切勿将其用于前端浏览器测试。
Axios adapter that allows to easily mock requests
Using npm:
$ npm install axios-mock-adapter --save-dev
It's also available as a UMD build:
axios-mock-adapter works on Node as well as in a browser, it works with axios v0.17.0 and above.
Mocking a GET request
const axios = require("axios");
const AxiosMockAdapter = require("axios-mock-adapter");
// This sets the mock adapter on the default instance
const mock = new AxiosMockAdapter(axios);
// Mock any GET request to /users
// arguments for reply are (status, data, headers)
mock.onGet("/users").reply(200, {
users: [{ id: 1, name: "John Smith" }],
});
axios.get("/users").then(function (response) {
console.log(response.data);
});
Mocking a GET request with specific parameters
const axios = require("axios");
const AxiosMockAdapter = require("axios-mock-adapter");
// This sets the mock adapter on the default instance
const mock = new AxiosMockAdapter(axios);
// Mock GET request to /users when param `searchText` is 'John'
// arguments for reply are (status, data, headers)
mock.onGet("/users", { params: { searchText: "John" } }).reply(200, {
users: [{ id: 1, name: "John Smith" }],
});
axios
.get("/users", { params: { searchText: "John" } })
.then(function (response) {
console.log(response.data);
});
When using params, you must match all key/value pairs passed to that option.
To add a delay to responses, specify a delay amount (in milliseconds) when instantiating the adapter
// All requests using this instance will have a 2 seconds delay:
const mock = new AxiosMockAdapter(axiosInstance, { delayResponse: 2000 });
You can restore the original adapter (which will remove the mocking behavior)
mock.restore();
You can also reset the registered mock handlers with resetHandlers
mock.resetHandlers();
You can reset both registered mock handlers and history items with reset
mock.reset();
reset is different from restore in that restore removes the mocking from the axios instance completely,
whereas reset only removes all mock handlers that were added with onGet, onPost, etc. but leaves the mocking in place.
Mock a low level network error
// Returns a failed promise with Error('Network Error');
mock.onGet("/users").networkError();
// networkErrorOnce can be used to mock a network error only once
mock.onGet("/users").networkErrorOnce();
Mock a network timeout
// Returns a failed promise with Error with code set to 'ECONNABORTED'
mock.onGet("/users").timeout();
// timeoutOnce can be used to mock a timeout only once
mock.onGet("/users").timeoutOnce();
Passing a function to reply
mock.onGet("/users").reply(function (config) {
// `config` is the axios config and contains things like the url
// return an array in the form of [status, data, headers]
return [
200,
{
users: [{ id: 1, name: "John Smith" }],
},
];
});
Passing a function to reply that returns an axios request, essentially mocking a redirect
mock.onPost("/foo").reply(function (config) {
return axios.get("/bar");
});
Using a regex
mock.onGet(/\/users\/\d+/).reply(function (config) {
// the actual id can be grabbed from config.url
return [200, {}];
});
Using variables in regex
const usersUri = "/users";
const url = new RegExp(`${usersUri}/*`);
mock.onGet(url).reply(200, users);
Specify no path to match by verb alone
// Reject all POST requests with HTTP 500
mock.onPost().reply(500);
Chaining is also supported
mock.onGet("/users").reply(200, users).onGet("/posts").reply(200, posts);
.replyOnce() can be used to let the mock only reply once
mock
.onGet("/users")
.replyOnce(200, users) // After the first request to /users, this handler is removed
.onGet("/users")
.replyOnce(500); // The second request to /users will have status code 500
// Any following request would return a 404 since there are
// no matching handlers left
Mocking any request to a given url
// mocks GET, POST, ... requests to /foo
mock.onAny("/foo").reply(200);
.onAny can be useful when you want to test for a specific order of requests
// Expected order of requests:
const responses = [
["GET", "/foo", 200, { foo: "bar" }],
["POST", "/bar", 200],
["PUT", "/baz", 200],
];
// Match ALL requests
mock.onAny().reply((config) => {
const [method, url, ...response] = responses.shift();
if (config.url === url && config.method.toUpperCase() === method)
return response;
// Unexpected request, error out
return [500, {}];
});
Requests that do not map to a mock handler are rejected with a HTTP 404 response. Since
handlers are matched in order, a final onAny() can be used to change the default
behaviour
// Mock GET requests to /foo, reject all others with HTTP 500
mock.onGet("/foo").reply(200).onAny().reply(500);
Mocking a request with a specific request body/data
mock.onPut("/product", { id: 4, name: "foo" }).reply(204);
Using an asymmetric matcher, for example Jest matchers
mock
.onPost(
"/product",
{ id: 1 },
{
headers: expect.objectContaining({
Authorization: expect.stringMatching(/^Basic /),
})
}
)
.reply(204);
Using a custom asymmetric matcher (any object that has a asymmetricMatch property)
mock
.onPost("/product", {
asymmetricMatch: function (actual) {
return ["computer", "phone"].includes(actual["type"]);
},
})
.reply(204);
.passThrough() forwards the matched request over network
// Mock POST requests to /api with HTTP 201, but forward
// GET requests to server
mock
.onPost(/^\/api/)
.reply(201)
.onGet(/^\/api/)
.passThrough();
Recall that the order of handlers is significant
// Mock specific requests, but let unmatched ones through
mock
.onGet("/foo")
.reply(200)
.onPut("/bar", { xyz: "abc" })
.reply(204)
.onAny()
.passThrough();
Note that passThrough requests are not subject to delaying by delayResponse.
If you set onNoMatch option to passthrough all requests would be forwarded over network by default
// Mock all requests to /foo with HTTP 200, but forward
// any others requests to server
const mock = new AxiosMockAdapter(axiosInstance, { onNoMatch: "passthrough" });
mock.onAny("/foo").reply(200);
Using onNoMatch option with throwException to throw an exception when a request is made without match any handler. It's helpful to debug your test mocks.
const mock = new AxiosMockAdapter(axiosInstance, { onNoMatch: "throwException" });
mock.onAny("/foo").reply(200);
axios.get("/unexistent-path");
// Exception message on console:
//
// Could not find mock for:
// {
// "method": "get",
// "url": "http://localhost/unexistent-path"
// }
As of 1.7.0, reply function may return a Promise:
mock.onGet("/product").reply(function (config) {
return new Promise(function (resolve, reject) {
setTimeout(function () {
if (Math.random() > 0.1) {
resolve([200, { id: 4, name: "foo" }]);
} else {
// reject() reason will be passed as-is.
// Use HTTP error status code to simulate server failure.
resolve([500, { success: false }]);
}
}, 1000);
});
});
Composing from multiple sources with Promises:
const normalAxios = axios.create();
const mockAxios = axios.create();
const mock = new AxiosMockAdapter(mockAxios);
mock
.onGet("/orders")
.reply(() =>
Promise.all([
normalAxios.get("/api/v1/orders").then((resp) => resp.data),
normalAxios.get("/api/v2/orders").then((resp) => resp.data),
{ id: "-1", content: "extra row 1" },
{ id: "-2", content: "extra row 2" },
]).then((sources) => [
200,
sources.reduce((agg, source) => agg.concat(source)),
])
);
The history property allows you to enumerate existing axios request objects. The property is an object of verb keys referencing arrays of request objects.
This is useful for testing.
describe("Feature", () => {
it("requests an endpoint", (done) => {
const mock = new AxiosMockAdapter(axios);
mock.onPost("/endpoint").replyOnce(200);
feature
.request()
.then(() => {
expect(mock.history.post.length).toBe(1);
expect(mock.history.post[0].data).toBe(JSON.stringify({ foo: "bar" }));
})
.then(done)
.catch(done.fail);
});
});
You can clear the history with resetHistory
mock.resetHistory();