
先说一个我真实踩过的坑有一回线上页面突然白屏排查到最后发现罪魁祸首是有人在一段公共工具代码里给Object.prototype挂了个自定义方法第三方图表库内部用for...in遍历配置对象时把这个继承来的属性也枚举进去了配置被污染渲染直接崩溃。那次之后我彻底想明白了一件事JavaScript 的 Prototype 不是测试的边角料它恰恰是测试最容易漏掉、又最容易引发连锁故障的那一层。这篇文章就围绕“JavaScript 测试”和“Prototype”这两个关键词把我多年写测试过程中踩过的坑、摸出来的套路、以及沉淀下来的测试策略一次性说清楚。不管你是刚开始给 JS 项目补单元测试的初学者还是已经在维护中大型测试套件的工程师这篇都能给你一些可以直接落地的参考。1. 先搞懂测试真正要面对的原型机制很多人写测试时遇到的问题表面上是“方法调用报错”“mock 不生效”根子其实是对原型机制的理解不到位。所以先花点篇幅把原型机制里和测试最相关的部分拆开揉碎。1.1 属性查找不是继承是一条回退路径初学者总把“原型链”理解成面向对象里的“继承链”其实不太准确。更准确的理解是当访问obj.someMethod时JavaScript 会先在对象自身属性里找没找到就去obj.__proto__指向的原型对象上找再没找到就继续往上一直到Object.prototype再往上就是null。function Person(name) { this.name name; } Person.prototype.greet function() { return Hello, this.name; }; const p new Person(Alice); console.log(p.greet()); // Hello, Alice console.log(p.hasOwnProperty(greet)); // false因为greet不在实例自身上 console.log(Person.prototype.hasOwnProperty(greet)); // true这个机制对测试最大的影响在于你在测试里调用的方法未必是你以为的那个方法。如果实例自身上有一个同名属性它会把原型链上的方法整个遮住。遇到这种情况测试结果可能完全正常但这正是因为遮蔽导致测错了对象。后面我会专门讲这个问题。1.2 原型就是一个普通对象这决定了它能被测试、也容易被污染很多人把Person.prototype看得很神秘其实它就是一个再普通不过的对象普通到可以用Object.keys枚举它的属性用delete删掉它的属性甚至把整个 prototype 重新赋值为另一个对象。const proto Person.prototype; console.log(typeof proto); // object也正因为它是一个普通对象测试才可以对它做断言比如检查某个方法是否真的挂在了原型上test(greet方法应该挂在Person.prototype上, () { expect(Object.getOwnPropertyNames(Person.prototype)).toContain(greet); });反过来说正因为它是普通对象任何人都可以在运行时往里塞属性。这种灵活性就是原型污染的温床一旦你在测试代码里不小心给Array.prototype或Object.prototype加了东西整个测试套件都可能变得不稳定。1.3 new运算符背后藏着的三件事用new调用构造函数时引擎偷偷做了三件事创建一个空对象把这个空对象的原型指向构造函数的prototype对象把这个空对象绑定为构造函数里的this。所以p.__proto__ Person.prototype永远是truep.constructor Person也通常是true。这个constructor属性就挂在Person.prototype上指向函数自身。测试里要留意的是如果你为了给原型一次性加多个方法写成了Person.prototype { ... }这种整体赋值的写法那Person.prototype.constructor会丢失p.constructor就不再指向Person了。很多依赖constructor做判断的代码就会出问题。Person.prototype { greet() { return hi; } }; const p new Person(Alice); console.log(p.constructor Person); // false指向的是Object所以在测试设计里只要涉及原型整体重写就必须补一个constructor回归断言。这个细节看起来小真踩到了会浪费很多排查时间。2. 一个真实案例原型方法是如何逃过测试眼睛的前面把机制讲清楚了接下来用一个我亲身经历过的案例把“原型修改如何绕过测试直接炸到线上”的完整链路还原出来。2.1 场景还原给所有数组加一个 sum 方法事情起因很简单业务方觉得[1, 2, 3].reduce((a, b) a b, 0)写起来太啰嗦就有人在公共工具文件里加了这样一段Array.prototype.sum function() { return this.reduce((acc, cur) acc cur, 0); };当时这个文件是配有单元测试的用例大概是这种test(sum方法可以对数组求和, () { expect([1, 2, 3].sum()).toBe(6); });单测跑起来全绿大家都觉得稳了。但“测试通过”和“没有故障”是两码事。2.2 问题爆发第三方库为什么突然全坏了上线后某个用了图表库的页面开始出现白屏控制台报错指向了图表库内部一段用for...in遍历配置对象的逻辑。这个报错看起来和我们的代码毫无关系以至于一开始完全没人往原型污染方向想。问题根源在于for...in会遍历对象自身和原型链上所有可枚举的属性。一旦Object.prototype或者Array.prototype上出现新的可枚举属性全项目所有for...in遍历都会被波及。图表库内部就是一个典型的for...in用户它没做hasOwnProperty过滤很多老库都这样于是那个新加的原型属性就混进了配置项里导致后续逻辑全部错乱。2.3 排查链路从复现到定位根因那次排查花了不少时间我把完整链路写下来大家下次遇到类似问题可以照着走看调用栈确认崩溃发生在哪个第三方库内部而不是业务代码里看第三方库对数据结构的遍历方式重点找for...in因为它在现代代码里已经不算常见了在控制台对比两组输出分别打印Object.keys(config)和for (const k in config) console.log(k)如果第二组多出几个不属于配置项本身的键那基本就是原型污染全局搜索可疑代码搜代码库里所有修改内置原型的地方比如Array.prototype.xxx 、Object.prototype.xxx 一搜一个准临时注释掉那一行页面恢复正常根因确认。2.4 测试为什么没拦住单测目标不等于系统风险事后复盘大家最困惑的问题是为什么单测全绿线上还是炸了因为单测验证的是“sum 自己能算对”而不是“往 Array.prototype 上挂方法会不会影响其他模块”。这是两个完全不同的测试目标。原型是一种全局状态对全局状态的任何修改都会产生跨模块影响。你可以在测试里验证功能但很难用单测验证“不影响整个世界”。这也是我后来立下一条铁律的原因业务代码里一律不修改内置原型所有扩展都以独立函数形式提供。从这个案例也能看出真正有效的防线不是在测试里模拟所有第三方库而是从代码规范层面直接杜绝风险。工具函数就交给工具函数不要贪图调用方便去污染原型。3. 为原型方法写单元测试的三种正确姿势如果你确实在维护一个需要扩展原型的工具库那么测试就必不可少。这一节讲三种可靠的测试姿势每种都附上可以直接抄的示例。3.1 不通过实例直接通过构造函数的 prototype 调用最直接、最不容易被遮蔽的方式就是绕过实例直接调用原型上的方法function Calculator() {} Calculator.prototype.add function(a, b) { return a b; }; test(Calculator.prototype.add 应该正常相加, () { const result Calculator.prototype.add.call(null, 2, 3); expect(result).toBe(5); });用.call(null, ...)把this置空可以确保测试对象是“纯粹的原型方法本身”不掺杂任何实例状态。这种写法适合那些不依赖this的纯逻辑方法简单直接而且不会受到实例自建属性的干扰。3.2 通过实例调用但要提防实例属性遮蔽原型方法大多数场景下我们还是要通过实例来测因为原型方法最终就是被实例调用的。这时候最需要注意的是实例上是否出现了和原型方法同名的属性。function Widget(name) { this.name name; // 注意如果这里写 this.render ...就会遮蔽原型上的render } Widget.prototype.render function() { return rendering this.name; }; test(实例可以正常调用原型上的render, () { const widget new Widget(button); expect(widget.render()).toBe(rendering button); });如果你在测试覆盖原型方法时发现代码在构造函数里有同名属性赋值那你要测的就已经不是原型方法而是实例自建属性了。这时候应该明确测试意图要么改代码避免遮蔽要么写两个用例分别覆盖。3.3 在继承链场景下测试原型方法当存在构造函数继承时子类实例可以直接调用父类原型上的方法。这种场景测试要关注两点方法的this是否正确绑定到子类实例以及子类是否无意中破坏了父类原型。function Animal(name) { this.name name; } Animal.prototype.speak function() { return this.name makes a sound; }; function Dog(name) { Animal.call(this, name); } Dog.prototype Object.create(Animal.prototype); Dog.prototype.constructor Dog; Dog.prototype.speak function() { return this.name barks; }; test(Dog实例可以调用自己的speak, () { const dog new Dog(Rex); expect(dog.speak()).toBe(Rex barks); expect(dog instanceof Animal).toBe(true); }); test(Dog继承自AnimalAnimal原型方法未被破坏, () { const animal new Animal(Cat); expect(animal.speak()).toBe(Cat makes a sound); });这个测试里必须确认Dog.prototype.constructor Dog前面也说了用Object.create重设原型后如果不手动补 constructor原型链关系就是错的。这也是一个非常经典的测试点。3.4 mock原型方法要极其克制必要时用 spyOn有些场景下测试驱动代码调用了某个原型方法而你想隔离掉它就产生了“mock 原型方法”的需求。这里我要明确表态能不动就不动能局部 mock 就不要全局覆盖。真到必须 mock 的时候建议用 Jest 的jest.spyOn它会在测试结束后自动恢复原始方法不用手动还原test(使用spyOn临时替换原型方法, () { const spy jest.spyOn(Array.prototype, map).mockImplementation(() [1, 2, 3]); const result [10, 20, 30].map(item item * 2); expect(result).toEqual([1, 2, 3]); spy.mockRestore(); });如果你用Array.prototype.map function() {...}这种手动覆盖一个最常见的后遗症就是忘了还原导致后面的测试文件全部受影响。手动改原型 mock 是测试套件崩溃的头号杀手没有之一。4. 自动化测试套件里的原型污染陷阱单元测试还只是“单点控制”到了自动化测试套件层面原型的坑会被放大得更加夸张。这一节讲几个我在实际项目里遇到的典型问题。4.1 NodeList类数组对象与 Array 原型方法在浏览器环境里document.querySelectorAll返回的是NodeList它本身不是数组但很多老代码习惯用Array.prototype.forEach.call(nodeList, fn)来遍历。这种做法在测试环境尤其 jsdom里需要特别小心jsdom 对 NodeList 的实现和真实浏览器可能不完全一致某些原型方法可能意外可用也可能意外不可用。比如真实浏览器已经原生支持NodeList.prototype.forEach但测试环境里可能没有。如果你在代码里依赖了nodeList.forEach在 jsdom 里就会报错。这种不一致不是代码逻辑问题而是运行环境差异测试时最好显式注明所依赖的 DOM API或者在代码里自己实现一个防御性判断。function forEachNode(nodeList, callback) { if (typeof nodeList.forEach function) { nodeList.forEach(callback); } else { Array.prototype.forEach.call(nodeList, callback); } }这种写法在测试里就不会因为环境差异而挂掉算是一个典型的跨环境鲁棒性处理。4.2 全局原型的修改会污染测试间的快照对比自动化测试里经常用到快照snapshot对比。如果你在某个测试文件里给Object.prototype加了可枚举属性那么后续所有对象的for...in遍历、对象展开、序列化结果都可能带上这个属性进而导致快照对不上。我当时遇到的一个案例测试代码里有人用Object.prototype.someHelper function() {}来给对象加“通用方法”结果所有组件的快照测试开始疯狂失败点开快照一看每个对象下面都多了一个函数属性。最后定位到这一行删掉、恢复快照基线才恢复正常。所以任何对内置原型的修改只要发生在测试代码里就要把它当作全局状态污染来处理。最好的策略是不做次好的策略是在afterEach里清理最差的策略是放任不管。4.3 清理原型修改的规范动作如果因为某些历史包袱你确实需要在测试中临时修改原型那至少要把清理动作写成模板避免遗漏。可以参考这个写法afterEach(() { delete Array.prototype.sum; delete Object.prototype.someHelper; });但delete只对可配置的属性有效如果原型属性是用Object.defineProperty以configurable: false定义出来的那delete是没有效果的。这种情况下唯一的保险方案是不要在生产代码或测试代码里用defineProperty去改内置原型的不可配置属性这是最危险的操作。提示如果你发现自己的代码里出现了“给内置原型添加属性”这类操作请把它当作架构设计问题来看待而不是把它当成功能开发。解决方式通常是提供一个独立模块导出函数而不是扩展原型。5. 用测试撕开“原型链设计”的质量问题原型机制不仅能测“方法是否存在”它还承载了一个对象的身份信息、属性归属信息。这一节说说如何用测试检验原型链设计是否健康。5.1 instanceof的局限跨执行环境会失效instanceof是通过检查原型链来判断对象类型的但它依赖“同一个构造函数引用”。如果代码运行在多个执行环境比如主页面和 iframe、不同模块副本同一个构造函数可能有两份不同的原型对象导致instanceof判断失败。测试时要注意这种跨环境场景不要盲目相信instanceof。比如const iframe document.createElement(iframe); document.body.appendChild(iframe); const iframeArray new iframe.contentWindow.Array(); console.log(iframeArray instanceof Array); // false因为两个Array不是同一个引用在自动化测试中如果涉及跨 iframe 或跨模块引用的场景建议用Object.prototype.toString.call(value)配合类型标签来判断它在跨环境下的表现更稳定。function isArray(value) { return Object.prototype.toString.call(value) [object Array]; }5.2 hasOwnProperty与属性归属断言测试原型方法时明确“属性属于谁”非常重要。hasOwnProperty就是用来做这个判断的但要注意它查的是“自身属性”不包含原型链上的属性。function Shape() {} Shape.prototype.draw function() {}; test(draw应该在原型上而不是实例上, () { const shape new Shape(); expect(shape.hasOwnProperty(draw)).toBe(false); expect(Shape.prototype.hasOwnProperty(draw)).toBe(true); });如果你在设计一个高性能工具库想尽量把方法放在原型上以节省内存那这类断言就能帮你长期守住设计约定。测试用例写清楚“实例不持有方法、原型持有方法”后续有人在构造函数里给每个实例都复制一遍方法时测试就会立刻变红。5.3 用Object.getPrototypeOf断言原型身份__proto__虽然几乎所有浏览器都支持但它不是规范推荐的标准接口。测试代码更稳妥的做法是用Object.getPrototypeOf来获取原型对象。const proto { type: custom }; const obj Object.create(proto); test(对象的原型应该是我们指定的proto, () { expect(Object.getPrototypeOf(obj)).toBe(proto); });这个断言还有一层实际价值它能检测原型是否被外部代码意外替换。比如某个模块初始化时偷偷执行了Object.setPrototypeOf(obj, somethingElse)你用这个断言就能抓出来。5.4 原型链回归测试清单我给自己项目里的原型相关代码固定维护了一张回归测试清单每次改动原型相关代码都会逐条核对测试点断言方式目的原型方法是否存在expect(Proto.method).toBeTypeOf(function)防止方法丢失或拼写错误方法绑定在原型而非实例instance.hasOwnProperty(method) false防止每个实例都复制一份方法constructor 指向是否正确instance.constructor Constructor防止整体重写原型后丢失 constructor原型关系是否正确Object.getPrototypeOf(instance) Constructor.prototype防止原型被替换继承链是否完整child instanceof Parent true防止继承断链这张表差不多能覆盖 90% 的原型设计问题。每次改动构造函数或原型相关代码把这张表上的用例跑一遍能省下大量排查时间。6. 常见原型报错的排查清单最后把我在测试和现网里遇到的典型原型相关报错整理成一份排查清单遇到问题可以按图索骥不必从零开始分析。报错现象极可能原因排查路径xxx is not a function原型方法名写错或方法被实例属性遮蔽看调用处实例自身是否有同名属性再查原型上方法名拼写Cannot read properties of undefined (reading xxx)原型链断裂某个原型为 null检查Object.create(null)或某个对象被setPrototypeOf改成了 null跨 iframe 后instanceof为 false两个执行环境中同名构造函数是不同引用改用Object.prototype.toString做类型判断所有测试文件突然失败某个测试或工具代码污染了内置原型全局搜索原型名.prototype.xxx 清理并收敛快照里莫名多出属性原型上被挂上了可枚举属性用for...in和Object.keys对比定位污染来源某个原型方法 mock 不生效实例上存在同名属性方法调用走的是实例属性而不是原型去掉实例属性或直接 mock 实例属性最后这种“mock 不生效”值得单独说两句。很多时候你以为自己在 mock 原型方法但被测对象在构造函数里已经给实例赋值了一个同名函数调用时 JavaScript 会优先命中实例属性。mock 原型方法自然毫无效果。遇到这种情况先检查对象自身属性再做原型层面的操作顺序不能反。我在实际项目中还立了几条硬规矩帮团队少踩坑业务代码和工具代码一律禁止修改内置原型如果必须扩展某个原生的能力通过独立模块导出纯函数再在需要的地方引用测试代码里的原型操作只允许出现在对应的工具库项目中而且必须配afterEach清理维护工具库时把原型测试当作 API 文档的一部分来写测试即契约一旦原型行为有变测试会在第一时间告诉你。这些规矩听起来保守但在长期维护的项目里它们省下的排查时间远比放弃的“语法糖”更值钱。JavaScript 的 Prototype 机制本身没有好坏之分它在语言设计上非常灵活但灵活性一旦变成全局修改就变成了一种风险。测试能帮我们守住最后一道防线最根本的防线还是来自代码设计本身。