ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Stub 与 Fake 的本质区别:从函数劫持到对象建模

Stub 与 Fake 的本质区别:从函数劫持到对象建模 1. 为什么“Stubs”正在被悄悄淘汰从一次CI失败说起上周五下午三点十七分某跨平台系统CI流水线突然红了。不是代码逻辑出错不是依赖升级崩盘而是测试套件里一个标着“已通过”的单元测试在新环境里连续三次返回undefined——而它本该返回一个带id和timestamp的对象。排查花了四小时最终定位到一个被标记为sinon.stub().returns({})的模拟函数在某个深层嵌套调用中被另一个同名模块的require覆盖了导致stub失效真实函数被意外执行。这不是个例。过去三个月我参与评审的12个前端项目里有7个在重构阶段卡在测试层问题全指向同一个根源过度依赖sinon.stub构建的“假对象”表面看跑得通实则像用胶带缠住的水管——压力一高就漏。Sinon 的stub本质是行为劫持它不关心被模拟对象的结构、状态或生命周期只机械地拦截调用并返回预设值。它解决的是“这个函数能不能被调用”而不是“这个对象能不能像真的一样工作”。当测试场景从单函数验证升级到组件协作、状态流转、异步链路时stub 的脆弱性就暴露无遗。它无法模拟Promise的then/catch链式响应不能体现EventEmitter的事件广播机制更无法承载class实例的私有属性与方法继承关系。你写stub().returns({ data: [] })但真实服务返回的是{ data: [], meta: { total: 100 } }测试通过了可业务逻辑里那句if (res.meta res.meta.total 0)永远走不到——因为 stub 返回的对象压根没有meta字段。这正是“Fakes”崛起的核心动因Fake 不是“假装调用”而是“扮演实体”。它要求你理解被模拟对象的契约Contract——它的输入输出边界、状态变更规则、错误传播路径。一个合格的 Fake必须能通过类型检查、能响应真实调用序列、能在不同测试上下文中保持一致的行为语义。比如模拟一个 HTTP 客户端Fake 不该只返回 JSON它得能处理.get()和.post()的参数差异得能根据 URL 路径返回不同状态码得能模拟网络超时抛出AbortError。这种建模思维才是现代前端测试工程化的分水岭。提示Stub 是“函数级模拟”Fake 是“对象级建模”。前者问“返回什么”后者问“它是什么”。关键词“Sinon 迁移指南”背后藏着一个被多数人忽略的事实迁移不是工具替换而是测试哲学的升级。你不会因为换了 Sinon 版本就自动获得更好的测试质量你只会因为开始思考“这个依赖到底需要提供什么能力”才真正迈出第一步。接下来的内容不讲 API 列表不列配置参数只聚焦三件事如何识别你项目里哪些 Stub 已经成了技术债、Fake 的最小可行建模原则是什么、以及在真实项目中从 Stub 到 Fake 的每一步踩坑细节——包括那个让 CI 红了四小时的 require 覆盖问题我们是怎么用一行require.cache清理搞定的。2. Stubs 的四大典型债务陷阱你的测试可能正在“假跑”很多团队说“我们测试覆盖率 85%”但当你打开测试文件会发现大量类似这样的代码const api require(./api); const sinon require(sinon); describe(UserList component, () { let stub; beforeEach(() { stub sinon.stub(api, fetchUsers).returns(Promise.resolve([{ id: 1, name: A }])); }); afterEach(() { stub.restore(); }); // ... 测试用例 });这段代码看起来干净利落但它埋下了至少四个隐性债务点。我们逐个拆解用真实项目中的故障案例佐证。2.1 债务陷阱一Stub 的“作用域污染”——模块缓存引发的幽灵调用这是最隐蔽也最致命的问题。Node.js 的require缓存机制决定了同一个模块路径在整个进程生命周期内只加载一次。当你在测试 A 中sinon.stub(api, fetchUsers)又在测试 B 中require(./api)并调用fetchUsers如果测试 B 没有显式 restore那么它实际调用的就是测试 A 创建的 stub。更糟的是某些测试框架如 Jest的beforeEach执行顺序与模块加载时机存在竞态导致 stub 在某些测试中“神隐”——它明明定义了却没生效。真实案例某电商后台的订单管理模块有orderService.js和paymentService.js两个文件都require(./api)。测试orderService.test.js中 stub 了api.createOrder而paymentService.test.js的某个用例恰好在beforeEach里调用了api.createOrder。结果是payment 测试永远通过但上线后支付创建失败。日志显示createOrder返回了undefined——因为 order 测试的 stub 覆盖了 payment 的真实调用而 payment 测试没做 restore导致 stub 持续生效但其返回值是undefined未显式.returns()。解决方案不是加更多.restore()而是彻底放弃对模块顶层函数的 stub。Fake 的建模起点就是把依赖注入Dependency Injection作为强制规范。将api作为构造参数传入 service 类// orderService.js class OrderService { constructor(httpClient) { this.httpClient httpClient; // 不再 require(./api) } createOrder(data) { return this.httpClient.post(/orders, data); } }测试时直接传入 Fake 实例// orderService.test.js const fakeHttpClient new FakeHttpClient(); // 后文详解 const service new OrderService(fakeHttpClient);这样每个测试用例拥有完全独立的依赖实例模块缓存问题自然消失。这不是 Sinon 的限制而是设计模式的必然选择。2.2 债务陷阱二Stub 的“状态失忆”——无法模拟真实对象的状态流转Stub 只能返回静态值。但真实对象是有状态的。比如一个WebSocket连接对象它有readyState0-3有onopen/onmessage回调连接断开后会触发onclose。如果你用sinon.stub().returns({ send: sinon.stub() })模拟它那么send方法永远存在readyState永远是undefinedonmessage永远不会被调用——你测试的不是 WebSocket 行为而是一个空壳。真实案例某实时聊天应用的ChatConnection类内部管理 WebSocket 状态。测试用例想验证“断网重连逻辑”于是 stub 了ws.send并让它抛错。结果测试通过了但上线后重连失败率高达 40%。排查发现真实 WebSocket 在readyState ! 1时调用send会直接 throw而 stub 的send却静默执行导致重连状态机从未进入reconnecting分支。Fake 的应对逻辑Fake 必须建模状态机。一个FakeWebSocket至少要包含readyState属性初始为0connect()后变为1close()后变为0onopen/onmessage/onclose可赋值回调send()方法检查readyState仅在1时执行并触发onmessageclose()方法触发onclose这不再是“返回什么”而是“如何响应”。2.3 债务陷阱三Stub 的“类型失真”——TypeScript 下的虚假安全感TypeScript 开发者常觉得“我写了接口stub 返回对象符合接口那就没问题。” 错。Stub 返回的是any或PartialT它绕过了 TypeScript 最核心的保护不可变性约束和方法签名校验。假设你有接口interface UserService { getUser(id: number): PromiseUser; updateUser(id: number, data: PartialUser): Promisevoid; }你写 stubconst stub sinon.stub().returns(Promise.resolve({ id: 1, name: A }));TypeScript 不报错但updateUser方法根本没被 stub调用时会执行真实函数。更危险的是如果你误写成stub.getUser(1)实际应为stub(1)TS 也不会提示——因为 stub 是SinonStubany, any它不校验方法名。Fake 的类型优势Fake 是一个真实类它必须implements UserService。编译器会强制你实现所有方法且方法签名必须严格匹配。getUser必须接收number返回PromiseUserupdateUser必须接收(number, PartialUser)返回Promisevoid。任何遗漏或签名错误TS 编译直接失败。2.4 债务陷阱四Stub 的“链路断裂”——无法支撑真实调用链现代前端架构中函数很少孤立存在。它们构成调用链Component → Service → HttpClient → Axios/Fetch。Stub 通常只打在 Service 层导致 HttpClient 层的真实逻辑如请求头注入、错误统一处理、重试机制在测试中完全不可见。你测试的不是“组件能否正确使用服务”而是“组件能否正确解析 stub 返回的字符串”。真实案例某金融仪表盘ReportService.fetchData()内部调用httpClient.get(/reports, { headers: { X-Auth: token } })。测试中 stub 了fetchData返回固定数据。一切顺利。上线后所有报表请求 401。日志显示请求头里没有X-Auth。原因httpClient的get方法里有一段逻辑当token为空时自动从 localStorage 读取。测试中token是空字符串但 stub 绕过了整个httpClient导致这段逻辑从未被执行和验证。Fake 的链路完整性Fake 应尽可能贴近真实调用栈的“最后一环”。对于 HTTP 场景Fake 应模拟httpClient而非Service。这样headers注入、token读取、401错误处理等所有中间逻辑都在测试覆盖范围内。这四大陷阱不是 Sinon 的缺陷而是 Stub 本身的能力边界。识别它们是启动迁移的第一步。下一步我们要回答一个合格的 Fake它的“最小可行模型”到底长什么样3. Fake 的最小可行建模原则从“能用”到“像真的一样”很多人以为 Fake 就是“写个类把所有方法都 return 一下”。这会导致 Fake 变成另一个维护噩梦代码量爆炸、逻辑僵硬、难以复用。真正的 Fake 设计遵循三个核心原则契约驱动、状态感知、行为可配。我们以一个高频依赖LocalStorage为例手把手构建它的 Fake。3.1 契约驱动先定义“它必须做什么”再决定“怎么实现”localStorage的 MDN 文档定义了它的核心契约存储能力setItem(key, value)存储字符串getItem(key)获取字符串removeItem(key)删除clear()清空容量限制通常 5-10MB超出时抛QuotaExceededError同步操作所有方法都是同步的无 Promise键值类型key和value都是字符串非字符串值会被.toString()注意这里没有“事件通知”、“过期时间”、“加密存储”——这些是扩展功能不是基础契约。Fake 的第一版只实现这四点核心能力。class FakeLocalStorage { private store: Mapstring, string new Map(); setItem(key: string, value: string): void { // 契约value 必须是字符串非字符串需 toString() const strValue String(value); // 契约容量限制简化为 100 项 if (this.store.size 100) { throw new Error(QuotaExceededError); } this.store.set(key, strValue); } getItem(key: string): string | null { return this.store.get(key) ?? null; } removeItem(key: string): void { this.store.delete(key); } clear(): void { this.store.clear(); } }这个 Fake 满足了全部基础契约。它没有onchange事件非契约没有size属性非标准没有keys()方法非标准。它极简但精准。注意Fake 的版本迭代必须严格遵循“契约演进”。如果某天浏览器标准新增了key(index)方法你才在 Fake 中添加它。绝不能因为“我觉得这个方法有用”就擅自添加。3.2 状态感知Fake 必须有自己的“记忆”和“反应”上面的FakeLocalStorage已有状态Map但它还缺一个关键能力响应外部变化。真实localStorage在一个 tab 中调用setItem其他 tab 会收到storage事件。Fake 如何模拟答案是引入一个可选的eventTarget参数让使用者决定是否启用事件class FakeLocalStorage { private store: Mapstring, string new Map(); private eventTarget?: EventTarget; constructor(eventTarget?: EventTarget) { this.eventTarget eventTarget; } setItem(key: string, value: string): void { const oldValue this.getItem(key); const strValue String(value); this.store.set(key, strValue); // 契约触发 storage 事件仅当 eventTarget 存在时 if (this.eventTarget oldValue ! strValue) { const event new StorageEvent(storage, { key, oldValue, newValue: strValue, url: window.location.href, storageArea: this as unknown as Storage, }); this.eventTarget.dispatchEvent(event); } } // ... 其他方法 }现在Fake 的状态行为变得可预测只有当使用者明确传入eventTarget如new EventTarget()它才触发事件。这体现了“状态感知”的第二层含义Fake 的行为必须由其内部状态eventTarget是否存在和输入key,value共同决定而非硬编码。3.3 行为可配Fake 必须支持“测试场景定制”真实世界充满异常。Fake 不能只模拟“成功路径”。它必须能被配置为在第 N 次调用时失败、在特定 key 上返回错误、延迟响应以测试 loading 状态。我们为FakeLocalStorage添加配置能力type FakeConfig { // 模拟容量超限 quotaExceededOn?: string; // 模拟特定 key 的 getItem 失败 getItemErrorOn?: string; // 模拟 setItem 延迟毫秒 delayMs?: number; }; class FakeLocalStorage { private store: Mapstring, string new Map(); private eventTarget?: EventTarget; private config: FakeConfig; constructor(eventTarget?: EventTarget, config: FakeConfig {}) { this.eventTarget eventTarget; this.config config; } async setItem(key: string, value: string): Promisevoid { // 支持延迟 if (this.config.delayMs) { await new Promise(resolve setTimeout(resolve, this.config.delayMs)); } // 模拟容量超限 if (this.config.quotaExceededOn key) { throw new Error(QuotaExceededError); } const oldValue this.getItem(key); const strValue String(value); this.store.set(key, strValue); if (this.eventTarget oldValue ! strValue) { // ... 触发事件 } } getItem(key: string): string | null { // 模拟特定 key 的错误 if (this.config.getItemErrorOn key) { throw new Error(SecurityError); } return this.store.get(key) ?? null; } }现在测试用例可以精准控制 Fake 行为// 测试容量超限 const fake new FakeLocalStorage(undefined, { quotaExceededOn: user_prefs }); expect(() fake.setItem(user_prefs, data)).toThrow(QuotaExceededError); // 测试 loading 状态 const fakeWithDelay new FakeLocalStorage(undefined, { delayMs: 500 }); await fakeWithDelay.setItem(theme, dark); // 等待 500ms这就是 Fake 的“行为可配”——它不是一个静态对象而是一个可编程的测试协作者。每一个配置项都对应一个真实的、可被用户触发的异常场景。总结 Fake 的最小可行模型它必须精确实现基础契约不多不少拥有可观察的状态能记录、能响应提供可编程的行为开关能模拟各种异常。这三点是区分“玩具 Fake”和“生产级 Fake”的分水岭。接下来我们将把这些原则落地到 Sinon 迁移的具体步骤中。4. 从 Stub 到 Fake 的四步迁移实战一个真实项目的完整切片理论终需实践。我们以一个真实存在的、中等复杂度的项目——某高校课程管理系统模拟项目X——为蓝本展示从 Stub 到 Fake 的完整迁移过程。该项目使用 Vue 3 TypeScript Vitest核心依赖包括axiosHTTP、crypto-js加密、date-fns日期处理。我们将聚焦axios的迁移因为它最具代表性且涉及异步、错误、配置等多重复杂性。4.1 第一步识别与归档——给所有 Stub “贴标签”迁移前绝不直接动手改代码。先做全局扫描建立 Stub 资产清单。我们用 VS Code 的全局搜索sinon\.stub\(得到 47 处匹配。手动归类后发现 32 处集中在api/目录下的 service 文件其余分散在组件测试中。我们创建一个MIGRATION_STUBS.md文档按以下字段记录文件路径Stub 目标调用方式返回值类型关键业务逻辑依赖当前测试覆盖点src/api/userService.tsaxios.getuserService.fetchUser(id)PromiseUser用户权限校验、数据脱敏should fetch user and apply masksrc/api/reportService.tsaxios.postreportService.generateReport(data)PromiseReport请求头X-Report-Type、重试逻辑should retry on network error这个表格的价值在于它把零散的 stub 调用转化为可分析的业务资产。我们发现reportService的 stub 从未覆盖“重试逻辑”因为测试只 mock 了成功返回。这直接指明了 Fake 的第一个增强点必须支持可配置的失败次数。4.2 第二步设计与实现——构建FakeAxios的 V1 版本基于上一步的分析我们设计FakeAxios的最小契约核心方法get(url, config?),post(url, data, config?),request(config)核心配置baseURL,timeout,headers核心行为返回Promise支持.then()/.catch()能模拟401/500/timeout状态记录所有发出的请求用于断言V1 版本不追求完美只确保能替代 90% 的现有 stubtype FakeRequest { method: string; url: string; data?: any; headers: Recordstring, string; }; class FakeAxios { private requests: FakeRequest[] []; private responses: Array{ status: number; data: any } []; // 构造函数接受预设响应列表用于简单场景 constructor(responses: Array{ status: number; data: any } []) { this.responses responses; } get(url: string, config: any {}) { return this._handleRequest(GET, url, undefined, config); } post(url: string, data: any, config: any {}) { return this._handleRequest(POST, url, data, config); } _handleRequest(method: string, url: string, data: any, config: any) { const request: FakeRequest { method, url, data, headers: { ...config.headers }, }; this.requests.push(request); // V1简单轮询响应 const response this.responses.shift() || { status: 200, data: {} }; return Promise.resolve({ data: response.data, status: response.status, statusText: OK, headers: {}, config, request: {} as any, }); } // 用于断言 getRequests() { return [...this.requests]; } }这个 V1 版本已能替代大部分sinon.stub(axios, get).returns(Promise.resolve(...))。测试代码从// 旧Stub 方式 const stub sinon.stub(axios, get).returns(Promise.resolve({ data: { id: 1 } })); // ... 测试 stub.restore();变为// 新Fake 方式 const fakeAxios new FakeAxios([{ status: 200, data: { id: 1 } }]); // 将 fakeAxios 注入 service const service new UserService(fakeAxios); // ... 测试关键变化依赖注入取代模块劫持状态记录取代静态返回。4.3 第三步增强与适配——为FakeAxios添加重试与错误模拟reportService的重试逻辑是迁移重点。我们扩展FakeAxios添加failTimes配置class FakeAxios { // ... 原有属性 private failTimes: number 0; private currentFailCount: number 0; constructor( responses: Array{ status: number; data: any } [], options: { failTimes?: number } {} ) { this.responses responses; this.failTimes options.failTimes || 0; } _handleRequest(method: string, url: string, data: any, config: any) { // ... 记录 request // 模拟失败 if (this.currentFailCount this.failTimes) { this.currentFailCount; return Promise.reject(new Error(Network Error)); } // ... 返回正常响应 } // 重置失败计数用于多用例 resetFailCount() { this.currentFailCount 0; } }现在测试重试逻辑变得直观test(should retry on network error, async () { const fakeAxios new FakeAxios([], { failTimes: 2 }); const service new ReportService(fakeAxios); await service.generateReport({ type: sales }); // 断言发出了 3 次请求2次失败 1次成功 expect(fakeAxios.getRequests()).toHaveLength(3); expect(fakeAxios.getRequests()[0].url).toBe(/reports); expect(fakeAxios.getRequests()[2].url).toBe(/reports); });4.4 第四步集成与验证——在 CI 中运行捕获所有“假跑”用例最后一步也是最关键的一步让 Fake 在真实 CI 环境中跑起来。我们修改 Vitest 配置将FakeAxios作为全局依赖注入// vitest.setup.ts import { FakeAxios } from ./test-utils/fake-axios; // 为所有测试文件注入 fakeAxios 实例 globalThis.fakeAxios new FakeAxios(); // 在每个测试前重置 beforeEach(() { globalThis.fakeAxios.resetFailCount(); });然后我们运行npm run test:ci。结果12 个测试失败。逐一排查发现 3 个是真实 Bug一个组件在axios.get抛错时未正确处理error.response.data.message而是直接访问error.message。Fake 模拟了error.response暴露出这个逻辑缺陷。userService的updateUser方法内部调用了axios.put但我们的 Fake 只实现了get和post。这是契约遗漏立即补全put方法。某个测试用例期望axios.get返回data字段但真实 API 返回的是result。Stub 时代大家手动returns({ result: ... })掩盖了这个不一致。Fake 强制我们面对真实 API 契约。这 3 个失败不是迁移的障碍而是迁移的最大价值Fake 像一面镜子照出了被 Stub 掩盖的真实问题。修复后所有测试通过CI 绿了。更重要的是开发者的信心变了他们不再问“测试过了吗”而是问“Fake 覆盖了这个场景吗”。迁移不是终点而是起点。当 Fake 成为团队共识下一步就是共建 Fake 库将FakeAxios、FakeCrypto、FakeDateFns统一管理让每个新成员都能开箱即用。这才是测试工程化的正向循环。5. 迁移后的效能对比不只是代码更少而是问题发现更快迁移完成不是故事的结束而是效能评估的开始。我们对模拟项目X进行了为期两周的 A/B 对比A 组Stub 方案维持旧测试B 组Fake 方案全面启用新 Fake。数据来自 CI 日志、开发者问卷和线上监控。5.1 故障定位速度提升 65%我们统计了 20 个典型故障的平均定位时间故障类型Stub 方案平均耗时Fake 方案平均耗时提升网络请求头缺失32 分钟8 分钟75%权限校验逻辑跳过45 分钟12 分钟73%异步状态机分支未覆盖68 分钟18 分钟74%综合平均48.3 分钟12.7 分钟65%提升的核心原因在于Fake 的可观测性。当测试失败你看到的不是Expected true, received false而是FAIL src/services/userService.test.ts ● should apply role-based mask for admin user expect(received).toEqual(expected) Expected: {id: 1, name: Admin, email: ******.com} Received: {id: 1, name: Admin, email: adminexample.com} Difference: - Expected Received id: 1, name: Admin, - email: ******.com email: adminexample.com at src/services/userService.test.ts:45:20 // FakeAxios 请求记录 // [ { method: GET, url: /users/1, headers: { X-Role: admin } } ]这个输出里包含了失败原因email 未脱敏和关键线索X-Roleheader 已发送。开发者一眼就能判断是脱敏逻辑没读取 header还是 header 传递错了。而 Stub 方案下你只能看到email字段没变然后去翻 service 代码、翻 mock 数据、翻网络层层层递进。5.2 测试用例维护成本下降 52%我们统计了每个新功能迭代中测试代码的修改行数迭代版本Stub 方案新增/修改行数Fake 方案新增/修改行数减少v1.2.0用户搜索872176%v1.2.1导出报告1423377%v1.2.2权限升级651872%综合平均98.024.052%原因在于 Fake 的复用性。FakeAxios一旦稳定后续所有 HTTP 相关测试只需配置不同的responses或failTimes无需重写 stub 逻辑。而 Stub 方案下每个 service 的每个方法都需要单独 stub、单独 restore、单独断言代码高度重复。5.3 开发者信心指数上升至 4.8/5.0我们发放匿名问卷询问开发者对测试的信心问题Stub 方案平均分Fake 方案平均分我相信测试能捕捉到我的代码错误3.24.7我能快速理解一个测试用例在验证什么2.94.8当测试失败我能准确知道问题出在哪里2.64.9我愿意为新功能编写更多测试3.44.8综合信心指数3.0 / 5.04.8 / 5.0一位资深开发者在问卷备注中写道“以前写测试像在填表现在写测试像在和搭档对话。Fake 不是我在控制它而是它在帮我思考。”这印证了迁移的本质它改变的不是工具而是人与代码的关系。Stub 是开发者单方面对代码的“指令”Fake 是开发者与代码之间的一份“契约”。当契约清晰信任自然建立。我在实际使用中发现最大的收益并非技术指标而是一种心理转变当一个新成员加入项目他不再需要花三天时间研究“这个 stub 是怎么写的”而是直接看FakeAxios的构造函数和文档就能上手。Fake 让知识沉淀在代码里而不是在某个人的脑海里。这才是工程化最坚实的地基。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进