1. 项目概述为什么我们需要一个“终极”的HTTP请求模拟工具在前后端分离、微服务架构大行其道的今天前端开发、后端测试、接口联调几乎每一个环节都离不开HTTP请求。但现实往往是骨感的你正兴致勃勃地开发一个前端功能后端接口还没开发完或者你正在编写一个后端服务的单元测试它依赖的另一个服务却极不稳定动不动就给你来个“502 Bad Gateway”。这时候一个可靠的HTTP请求模拟工具就成了开发流程中的“定海神针”。你可能用过 Postman 来手动模拟请求或者在 Node.js 里用node-fetch配合jest的jest.mock来笨拙地模拟。但这些方法要么是手动操作无法集成到自动化流程中要么就是配置繁琐难以模拟复杂的请求-响应场景比如网络延迟、特定状态码、甚至是请求失败的重试逻辑。而fetch-mock正是为了解决这些痛点而生的。它不是一个简单的“假接口”而是一个功能强大、配置灵活、能与现代前端测试框架如 Jest、Mocha无缝集成的声明式HTTP请求模拟库。它的核心思想是在测试环境中完全接管全局的fetch函数让你可以精确地定义当某个URL被请求时应该返回什么响应从而将测试环境与真实网络环境彻底解耦。简单来说fetch-mock让你能像搭积木一样构建出一个完全可控的“虚拟网络”在这个网络里你可以为所欲为让某个接口瞬间成功、立刻失败、延迟响应或者返回任何你想要的JSON、文本甚至二进制数据。这对于编写健壮的单元测试、集成测试以及前端在没有后端支持下的独立开发价值巨大。接下来我们就从零开始彻底掌握这个“终极工具”。2. 核心概念与设计哲学不仅仅是“模拟”在深入代码之前理解fetch-mock的设计哲学至关重要。这能帮助你在遇到复杂场景时知道该如何思考而不是机械地复制代码。2.1 全局接管与沙盒模式fetch-mock最核心的能力是全局接管fetch。在引入并配置后你代码中所有通过fetch发起的请求都会被fetch-mock拦截并根据你预先定义的规则我们称之为“模拟”或“mock”返回响应而不会真正地发送网络请求。注意这种全局接管是“侵入式”的。这意味着在你的测试文件或模拟环境中fetch已经不是浏览器原生的那个fetch了。因此务必在每次测试结束后清理模拟规则否则模拟规则会污染后续测试导致难以调试的诡异错误。fetch-mock提供了fetchMock.reset()和fetchMock.restore()等方法来确保测试的隔离性。2.2 声明式配置匹配器Matcher与响应器Responsefetch-mock的配置是声明式的主要由两部分组成匹配器Matcher定义“什么样的请求”会被拦截。这可以是一个简单的字符串精确匹配URL一个正则表达式匹配一类URL或者一个函数进行更复杂的逻辑判断比如检查请求头或请求体。响应器Response定义被拦截的请求应该“返回什么样的响应”。这可以是一个状态码如200, 404, 502、一个JSON对象、一段文本、一个Response对象甚至是一个抛出错误的Promise用于模拟网络异常。这种“请求-响应”的映射关系使得模拟逻辑非常清晰。例如你可以声明“所有向/api/users发起的GET请求都返回一个200状态码和用户列表JSON而向同一个地址的POST请求则返回201状态码和新创建的用户数据。”2.3 为什么是“终极”对比其他方案让我们快速对比一下常见的HTTP模拟方案你就能明白fetch-mock的优势所在工具/方案优点缺点适用场景手动启动Mock服务器(如 json-server, Mock.js)功能强大可模拟完整RESTful API有独立进程。需要额外启动和维护一个服务配置稍重与单元测试集成不够紧密。前端独立开发、演示原型。测试框架内置Mock(如 Jest的jest.mock)与测试框架深度集成无需额外库。配置相对底层模拟复杂HTTP场景如延迟、失败重试代码冗长。简单依赖的模块模拟。Postman / Insomnia图形化界面方便手动调试和文档化。无法自动化不能集成到CI/CD流程。接口调试、文档编写、手动测试。fetch-mock声明式配置功能全面与测试框架无缝集成轻量级专注于单元/集成测试。主要作用于测试环境不适合替代完整的开发用Mock服务器。前端/Node.js单元测试、集成测试、组件测试。实操心得在我的项目中fetch-mock主要用于Vue/React 组件测试和Node.js 服务层单元测试。在组件测试中我可以确保组件发出的每个API请求都在掌控之中从而专注于测试组件自身的渲染逻辑和交互行为测试用例运行速度极快且100%稳定。在Node.js服务测试中当我的服务需要调用外部API时用fetch-mock模拟外部API的各种响应包括5xx错误能完美测试我服务的错误处理和重试逻辑。3. 从零开始安装与基础配置理论说再多不如动手试。我们从一个最简单的例子开始。3.1 安装假设你正在一个基于 Jest 的现代前端项目比如使用 Create React App 或 Vite中工作。首先通过 npm 或 yarn 安装fetch-mock# 使用 npm npm install --save-dev fetch-mock # 使用 yarn yarn add --dev fetch-mock3.2 第一个模拟模拟一个成功的GET请求假设我们有一个getUser函数它使用fetch获取用户信息。// api.js export async function getUser(userId) { const response await fetch(/api/users/${userId}); if (!response.ok) { throw new Error(HTTP error! status: ${response.status}); } return await response.json(); }现在我们要为这个函数写一个测试并且不希望测试真的去请求网络。// api.test.js import fetchMock from fetch-mock; import { getUser } from ./api.js; describe(getUser, () { // 在每个测试用例运行后重置fetchMock的状态防止模拟规则交叉影响 afterEach(() { fetchMock.reset(); }); it(should fetch user data successfully, async () { // 1. 定义模拟规则匹配URL并返回模拟响应 const mockUserId 123; const mockUser { id: mockUserId, name: John Doe, email: johnexample.com }; fetchMock.get(/api/users/${mockUserId}, { status: 200, body: mockUser, }); // 2. 执行被测试的函数 const userData await getUser(mockUserId); // 3. 断言函数返回了正确的数据 expect(userData).toEqual(mockUser); // 4. (可选) 断言fetch确实以预期的参数被调用了一次 expect(fetchMock.called(/api/users/${mockUserId})).toBe(true); // 也可以检查调用详情 const lastCall fetchMock.lastCall(/api/users/${mockUserId}); expect(lastCall?.[0]).toBe(/api/users/${mockUserId}); // 请求URL expect(lastCall?.[1]?.method).toBe(GET); // 请求方法 }); it(should throw an error when the response is not ok, async () { const mockUserId 999; // 模拟一个404 Not Found响应 fetchMock.get(/api/users/${mockUserId}, 404); // 断言函数抛出了错误 await expect(getUser(mockUserId)).rejects.toThrow(HTTP error! status: 404); }); });代码解读与注意事项fetchMock.get(matcher, response)这是一个快捷方法专门用于模拟GET请求。同理还有.post(),.put(),.delete(),.patch()等。匹配器Matcher这里我们用了字符串精确匹配。fetch-mock会检查请求的URL是否完全等于这个字符串。响应器Response我们传递了一个对象{status: 200, body: mockUser}。status对应HTTP状态码body是响应体fetch-mock会自动将其序列化为JSON字符串因为我们在body里放了对象。fetchMock.reset()这是生命线它会在每个测试后清除所有已注册的模拟规则和调用记录。忘记调用它是导致测试间相互干扰的最常见原因。fetchMock.called()和fetchMock.lastCall()这些是间谍Spy功能。它们让你能验证fetch是否被调用、调用了多少次、以及调用时的参数是什么。这对于测试函数的行为比如“是否发送了请求”至关重要。3.3 配置响应头与响应延迟真实的网络请求往往带有响应头并且可能有延迟。fetch-mock可以轻松模拟这些场景。it(should handle response headers and delay, async () { fetchMock.get(/api/slow-data, { status: 200, body: { data: some content }, headers: { Content-Type: application/json, X-Custom-Header: MyValue, }, // 延迟2秒后返回响应模拟网络延迟 delay: 2000, }); const startTime Date.now(); const response await fetch(/api/slow-data); const endTime Date.now(); const data await response.json(); expect(data.data).toBe(some content); expect(response.headers.get(X-Custom-Header)).toBe(MyValue); // 验证延迟大致在2秒左右允许一定误差 expect(endTime - startTime).toBeGreaterThanOrEqual(1900); });实操心得模拟延迟在测试UI交互时特别有用。比如你可以测试一个“加载中”的旋转图标是否在请求发出时显示并在收到响应后消失。这能确保你的加载状态管理逻辑是正确的。4. 进阶匹配器如何精准拦截你想要的请求基础的字符串匹配往往不够用。fetch-mock提供了强大的匹配器系统让你能进行模糊匹配、正则匹配甚至函数匹配。4.1 正则表达式匹配当你需要匹配一组具有共同模式的URL时正则表达式是利器。// 匹配所有以 /api/posts/ 开头后跟数字的URL fetchMock.get(/^\/api\/posts\/\d$/, { body: { title: A Post } }); // 这将匹配 await fetch(/api/posts/123); await fetch(/api/posts/456); // 这不会匹配 await fetch(/api/posts/abc); // 不是数字 await fetch(/api/posts/); // 缺少ID4.2 函数匹配器终极灵活性当字符串和正则都无法满足你的需求时你可以使用函数作为匹配器。这个函数接收两个参数url请求URL和options请求配置对象如method,headers,body等。函数需要返回true或false。fetchMock.mock((url, options) { // 只拦截POST请求并且请求体包含特定字段的请求 return ( options.method POST url /api/login options.body JSON.parse(options.body).username admin ); }, { status: 200, body: { token: fake-jwt-token } }); // 这个请求会被拦截 await fetch(/api/login, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ username: admin, password: 123 }) }); // 这个请求不会被拦截用户名不对 await fetch(/api/login, { method: POST, body: JSON.stringify({ username: user, password: 123 }) });4.3 基于方法的匹配除了使用.get()、.post()这些快捷方法你也可以在匹配器对象或函数中检查options.method。但更常见的做法是使用fetchMock.mock这个通用方法并配合一个对象作为匹配器。// 使用对象匹配器可以同时匹配URL和方法 fetchMock.mock({ url: /api/resource, method: PUT, // 甚至可以匹配请求头 headers: { Authorization: /^Bearer/ } }, { status: 204 // No Content });避坑技巧当你同时定义了多条模拟规则时fetch-mock会按照定义的先后顺序进行匹配并使用第一条匹配的规则。这意味着你应该把最具体的规则放在前面把最通用的规则如兜底的“捕获所有”规则放在最后。否则通用规则可能会意外地拦截掉本该由特定规则处理的请求。5. 模拟复杂场景与边缘情况一个健壮的测试套件需要覆盖各种边缘情况而不仅仅是“快乐路径”。fetch-mock让模拟这些场景变得简单。5.1 模拟网络错误与异常状态码模拟服务器错误5xx或客户端错误4xx是测试错误处理逻辑的关键。// 模拟服务器内部错误 (500) fetchMock.get(/api/broken, 500); // 或提供更详细的错误响应 fetchMock.get(/api/broken, { status: 500, body: { error: Internal Server Error, message: Something went wrong. } }); // 模拟未找到 (404) fetchMock.get(/api/not-found, 404); // 模拟认证失败 (401) fetchMock.get(/api/protected, { status: 401, headers: { WWW-Authenticate: Bearer realmAccess to protected resource } }); // 模拟网关错误 (502) - 这在微服务调用中很常见 fetchMock.get(/api/gateway, { status: 502, body: Bad Gateway });5.2 模拟超时与网络连接失败有时你需要模拟fetch请求本身失败例如网络断开而不是服务器返回错误。这可以通过让模拟响应返回一个rejected Promise来实现。import { RequestError } from fetch-mock; // fetch-mock v9 可能需要 it(should handle network failure, async () { // 模拟一个因网络原因失败的请求 fetchMock.get(/api/unreachable, { throws: new TypeError(Failed to fetch) // 浏览器原生fetch在网络错误时会抛出此类型错误 }); // 或者使用Promise.reject // fetchMock.get(/api/unreachable, Promise.reject(new Error(Network Error))); await expect(fetch(/api/unreachable)).rejects.toThrow(Failed to fetch); });5.3 模拟动态响应与请求依赖响应并不总是静态的。有时你需要根据请求的细节来动态生成响应。这可以通过将响应器定义为一个函数来实现。// 模拟一个创建资源的POST请求响应体应包含请求发送的数据和生成的ID fetchMock.post(/api/items, (url, options) { // 解析请求体 const requestBody JSON.parse(options.body); // 动态生成响应 return { status: 201, body: { id: Date.now(), // 模拟一个生成的ID ...requestBody, createdAt: new Date().toISOString() }, headers: { Location: /api/items/${Date.now()} // 模拟RESTful API创建后的Location头 } }; }); // 测试 const newItem { name: New Item, price: 100 }; const response await fetch(/api/items, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(newItem) }); const createdItem await response.json(); expect(createdItem).toHaveProperty(id); expect(createdItem.name).toBe(newItem.name); expect(response.status).toBe(201); expect(response.headers.get(Location)).toMatch(/\/api\/items\/\d/);实操心得动态响应函数非常强大可以用于模拟分页查询根据page参数返回不同数据、搜索接口根据keyword过滤返回结果等复杂场景。这能让你的测试更贴近真实接口的行为。6. 集成到现代前端测试框架fetch-mock本身是环境无关的但要发挥最大威力需要与你的测试框架Jest, Vitest, Mocha等和测试运行环境Node.js, JSDOM良好集成。6.1 与 Jest / Vitest 集成Jest 是目前最流行的测试框架。集成fetch-mock的关键在于正确设置和清理全局的fetch。方案一在每个测试文件中手动管理推荐用于灵活性这就是我们前面例子中的做法在beforeEach/afterEach钩子中调用fetchMock.reset()和fetchMock.restore()。这是最清晰、最可控的方式。import fetchMock from fetch-mock; describe(My API tests, () { beforeEach(() { // 如果需要可以在这里进行一些全局的模拟配置 }); afterEach(() { // 清理这是必须的。 fetchMock.reset(); fetchMock.restore(); }); // ... 你的测试用例 });方案二使用 Jest 的全局安装文件Setup Files如果你在大多数测试中都需要fetch-mock可以将其配置为全局可用并自动清理。在jest.config.js中设置setupFilesAfterEnv// jest.config.js module.exports { setupFilesAfterEnv: [rootDir/jest.setup.js], // ... 其他配置 };创建jest.setup.js文件// jest.setup.js import fetchMock from fetch-mock; // 确保在每个测试套件之前fetch是干净的 beforeEach(() { if (typeof global.fetch undefined) { global.fetch require(node-fetch); // 在Node环境下可能需要polyfill } fetchMock.reset(); fetchMock.restore(); }); afterEach(() { fetchMock.reset(); fetchMock.restore(); }); // 将fetchMock挂载到全局方便在测试中直接使用可选 global.fetchMock fetchMock;然后在测试文件中你可以直接使用global.fetchMock或者仍然导入fetchMock。重要提示在 Node.js 环境中运行测试时全局对象global上没有原生的fetch函数。你需要安装一个 polyfill比如node-fetch并在测试入口处将其赋值给global.fetch。Jest 27 版本在某些配置下可能自带了fetch但为了保险起见显式设置是个好习惯。6.2 在组件测试中的应用以React为例假设你有一个UserComponent它在挂载时会调用getUserAPI。// UserComponent.jsx import React, { useState, useEffect } from react; import { getUser } from ./api; function UserComponent({ userId }) { const [user, setUser] useState(null); const [loading, setLoading] useState(true); const [error, setError] useState(null); useEffect(() { const fetchUser async () { setLoading(true); try { const data await getUser(userId); setUser(data); setError(null); } catch (err) { setError(err.message); setUser(null); } finally { setLoading(false); } }; fetchUser(); }, [userId]); if (loading) return divLoading.../div; if (error) return divError: {error}/div; return divHello, {user.name}!/div; }使用testing-library/react和fetch-mock来测试这个组件// UserComponent.test.jsx import React from react; import { render, screen, waitFor } from testing-library/react; import fetchMock from fetch-mock; import UserComponent from ./UserComponent; describe(UserComponent, () { afterEach(() { fetchMock.reset(); }); it(renders user name after successful fetch, async () { const mockUser { id: 1, name: Alice }; fetchMock.get(/api/users/1, { status: 200, body: mockUser }); render(UserComponent userId{1} /); // 初始应该显示 Loading expect(screen.getByText(Loading...)).toBeInTheDocument(); // 等待异步操作完成并断言最终渲染了用户名 await waitFor(() { expect(screen.getByText(Hello, ${mockUser.name}!)).toBeInTheDocument(); }); // 确保Loading文本消失 expect(screen.queryByText(Loading...)).not.toBeInTheDocument(); }); it(renders error message on fetch failure, async () { fetchMock.get(/api/users/2, 404); render(UserComponent userId{2} /); await waitFor(() { expect(screen.getByText(/Error:/)).toBeInTheDocument(); // 检查错误信息是否包含状态码 expect(screen.getByText(404)).toBeInTheDocument(); }); }); });避坑技巧在组件测试中务必使用waitFor或findBy*查询来等待异步状态更新。直接断言会导致测试在组件完成渲染前就断言失败。testing-library/react的findBy*查询内置了等待逻辑用起来更简洁。7. 高级特性与最佳实践掌握了基础之后我们来看看fetch-mock的一些高级特性和能让你事半功倍的最佳实践。7.1 模拟连续调用与响应序列有时一个函数可能会多次调用同一个接口比如重试逻辑或者调用不同的接口。fetch-mock允许你为同一个匹配器定义一系列响应它会按顺序返回。// 模拟一个不稳定的接口第一次失败第二次成功 fetchMock.get(/api/unstable, [ { throws: new Error(Network Error) }, // 第一次调用返回错误 { status: 200, body: { data: success } } // 第二次调用成功 ], { repeat: 2 } // 这个规则最多使用2次即匹配列表中的两个响应 ); // 测试重试逻辑 async function fetchWithRetry(url, retries 3) { for (let i 0; i retries; i) { try { const response await fetch(url); if (response.ok) return await response.json(); } catch (err) { if (i retries - 1) throw err; // 等待片刻后重试 await new Promise(r setTimeout(r, 100 * (i 1))); } } throw new Error(Max retries reached); } await expect(fetchWithRetry(/api/unstable, 2)).resolves.toEqual({ data: success }); // 第一次调用会失败模拟网络错误函数会重试第二次调用成功。7.2 使用“Spy”模式进行行为验证除了模拟响应fetch-mock还是一个强大的间谍工具。你可以用它来验证函数是否按预期发起了请求。it(should send correct authentication header, async () { // 使用 .spy() 方法。它会放行请求到真正的网络或你配置的底层实现但同时记录调用信息。 // 更常见的做法是结合 .mock() 使用因为我们需要控制响应。 // 这里我们用 .mock 并配合 .called() 等方法来验证。 fetchMock.mock(*, { status: 200 }); // 匹配所有请求返回200 await fetch(/api/secure, { headers: { Authorization: Bearer my-token-123 } }); // 验证请求被调用 expect(fetchMock.called()).toBe(true); // 验证特定的URL被调用 expect(fetchMock.called(/api/secure)).toBe(true); // 获取最后一次调用的详细信息 const lastCallArgs fetchMock.lastCall(); expect(lastCallArgs[0]).toBe(/api/secure); // URL expect(lastCallArgs[1].headers.Authorization).toBe(Bearer my-token-123); // 请求头 });7.3 最佳实践总结隔离测试及时清理永远在afterEach或afterAll中调用fetchMock.reset()和fetchMock.restore()。这是保证测试独立性的第一原则。模拟要尽可能真实模拟的响应状态码、头部、数据格式应尽量与真实API保持一致。这能发现更多集成问题。为错误场景编写测试不要只测试成功路径。确保你的测试覆盖了4xx、5xx状态码、网络超时、JSON解析失败等异常情况。避免过度模拟不要模拟那些与你当前测试单元无关的依赖。如果你的函数内部调用了另一个你完全控制的工具函数也许应该直接测试那个工具函数而不是通过fetch来模拟它。给模拟起个名字在复杂场景下fetch-mock允许你为模拟规则命名通过name属性这在调试多条复杂规则时非常有用。谨慎使用通配符fetchMock.mock(*, ...)会匹配所有请求非常强大但也危险。确保它不会意外拦截你不想拦截的请求比如测试框架内部发起的请求。通常把它放在规则列表的最后作为兜底或者仅在特定测试中使用。8. 常见问题排查与调试技巧即使掌握了所有功能在实际使用中还是会遇到一些坑。这里记录了一些常见问题和解决方法。8.1 问题模拟没有生效请求仍然发送到了网络可能原因与解决方案fetch未被正确接管确保你在调用被测试代码之前就已经调用了fetchMock.mock()等方法设置了模拟。检查你的测试代码顺序。引入了多个fetch实现如果你的项目或测试环境中有多个fetch的 polyfill 或包装库fetch-mock可能没有覆盖到实际使用的那个。确保fetch-mock是最后被引入并配置的。在 Jest 的setupFiles中尽早配置global.fetch fetchMock.sandbox()有时能解决此问题。使用了fetchMock.restore()过早restore()会恢复原生的fetch。确保它在测试结束后才被调用而不是在测试中间。匹配器不匹配仔细检查你的URL字符串、正则表达式或函数匹配器逻辑。一个多余的斜杠/或大小写问题都可能导致匹配失败。使用fetchMock.calls()打印出所有被拦截的请求记录看看你的请求是否在其中。// 调试打印所有捕获到的请求 console.log(fetchMock.calls()); // 或者打印未匹配的请求 console.log(fetchMock.calls(false));8.2 问题测试因“Network Error”或“CORS Error”失败可能原因你的模拟规则没有覆盖到某个请求导致请求“漏网”并真的尝试发送到网络。在测试环境中如Jest的Node环境没有真实的网络和域名这会导致请求立即失败。解决方案添加一个兜底的、匹配所有请求的模拟规则并让它返回一个明确的错误或警告这样你就能立刻发现是哪个请求没有被正确模拟。// 在测试文件的开头或setup文件中 beforeEach(() { // 捕获所有未匹配的请求并抛出清晰错误 fetchMock.catch((url, options) { console.error(Unmatched fetch request: ${options.method} ${url}); // 返回一个错误响应或者直接抛出错误 return { status: 500, body: No mock defined for: ${url} }; // 或者throw new Error(No mock defined for: ${url}); }); });8.3 问题模拟的响应体格式不对导致代码解析出错可能原因fetch-mock的body参数可以是多种类型。如果你传递一个对象它会默认被序列化为JSON字符串并自动设置Content-Type: application/json响应头。但如果你需要返回纯文本、FormData或ArrayBuffer就需要手动处理。解决方案明确设置body和headers。// 返回纯文本 fetchMock.get(/api/text, { status: 200, body: Plain text response, headers: { Content-Type: text/plain } }); // 返回二进制数据 (如图片) fetchMock.get(/api/image, { status: 200, body: new ArrayBuffer(8), // 模拟一个小的二进制数据 headers: { Content-Type: image/png } }); // 如果你的代码期望得到JSON但模拟返回了字符串就会解析失败。 // 错误示例 // fetchMock.get(/api/data, This is not JSON); // 这会导致 response.json() 报错 // 正确示例 // fetchMock.get(/api/data, { body: { message: This is JSON } });8.4 与 TypeScript 一起使用如果你使用 TypeScript为了获得更好的类型提示可以安装types/fetch-mock。但请注意fetch-mockv9 之后可能自带了类型定义。npm install --save-dev types/fetch-mock在测试文件中你可以获得完整的类型支持。一个常见的技巧是将fetchMock的类型与你项目中使用的fetch类型对齐。我个人在使用中更喜欢在全局测试设置文件中创建一个类型安全的包装器或使用fetchMock.sandbox()它能提供一个类型与原生fetch完全一致的模拟实例集成起来更顺畅。最后记住fetch-mock的核心价值在于让测试变得确定、快速和独立。它把不可控的网络因素从你的测试方程式中移除让你能专注于测试代码本身的逻辑。花时间设计好你的模拟就像为你的代码搭建一个稳固的测试舞台最终的回报是更快的测试运行速度、更少的脆弱测试用例以及面对复杂异步逻辑时那满满的信心。