
最近在给一个内部工具库补测试用例遇到一个让我愣了好一会儿的现象构造函数还没执行new它的prototype方法居然能直接拿过来用。这在JavaScript里其实是再正常不过的机制但很多写了几年代码的朋友也未必真正理解过——为什么函数“没实例化”就带了一个prototype对象这个对象跟实例之间到底是什么关系后来我干脆把这块知识完整梳理了一遍顺便把相关的测试场景都补齐了写成了这套“JavaScript测试与Prototype”的实战笔记。这篇文章不打算讲学院派概念纯从实际开发里的疑惑出发聊清楚三件事prototype到底什么时候存在、测试工程里怎么设计对原型方法的用例、以及那些让你排查到怀疑人生的原型链报错。适合前端开发、测试开发以及准备进阶的JavaScript初学者参考看到最后你会发现很多看似无关的bug根源都在原型链这条线上。1. 一个让我卡壳的问题还没new的对象prototype怎么就“能用”了1.1 函数“出生”时就自带的prototype到底从哪来先还原当时的场景。我在调试一段老代码里面有一个构造函数function Animal(name) { this.name name; } Animal.prototype.sayName function () { console.log(this.name); }; // 还没new直接访问prototype console.log(Animal.prototype); // { sayName: ƒ, constructor: ƒ }我当时的第一反应是不对啊对象都没创建怎么会有一个跟实例相关的prototype后来查了规范才想明白函数对象在创建的那一刻引擎就自动为它挂了一个prototype属性这个属性指向一个普通的JavaScript对象。也就是说无论你后面调不调用new Animal()Animal.prototype这个引用始终存在。这就像你开了一家餐厅菜单prototype在餐厅装修时就印好了顾客实例还没上门菜单已经挂在墙上了。顾客来了之后按菜单点菜但菜单不会因为顾客没来就消失。有个细节容易忽略这个自动创建的原型对象自带一个constructor属性指回构造函数本身。所以Animal.prototype.constructor Animal成立。很多工具库会利用这个特征做类型判断也有人在继承场景里因为忘了修正constructor导致isPrototypeOf判断异常。1.2 new操作符到底做了什么Prototype在new前后有何区别既然prototype在函数声明时就存在那new到底干了什么活规范里new一个构造函数大致经历下面四步创建一个全新的空对象把这个空对象的隐式原型__proto__指向构造函数的prototype将构造函数的this绑定到这个新对象上并执行函数体如果构造函数没有显式返回一个对象就将这个新对象返回。用代码拆开看就是function myNew(Constructor, ...args) { // 1. 创建空对象 const obj {}; // 2. 链接原型 Object.setPrototypeOf(obj, Constructor.prototype); // 3. 绑定this并执行 const result Constructor.apply(obj, args); // 4. 返回值处理 return result typeof result object ? result : obj; }真正的纽带在第2步。new之前Animal.prototype是独立存在的对象new之后实例通过内部的[[Prototype]]也就是__proto__指向同一个原型对象。这带来一个关键推论实例与构造函数的prototype是“共享同一个对象”的关系。你往Animal.prototype上新增方法之前已经创建的所有实例都能在原型链上访问到新方法不需要重新new。这也是JavaScript不像Java那样在实例上拷贝一份方法的原因。把共享方法放在prototype上内存里只保留一份所有实例通过原型链往上找省内存又便于统一维护。但共享也意味着风险后面讲测试陷阱时我会展开。2. 把Prototype机制落到测试设计首先要搞清楚该测什么2.1 从“测试Prototype”出发理清测试范围弄懂了prototype的存在时机测试设计的思路就顺了。但“测prototype”本身不是一个绝对的测试单元你得先明确被测代码放在哪一层测试的边界才会清晰。我一般把方法划分成三类静态方法挂在构造函数自身上的方法例如UserManager.createEmpty()不依赖实例直接调用时不涉及this原型方法挂在ClassName.prototype上的方法例如UserManager.prototype.addUser()必须通过实例调用this指向实例实例方法写在构造函数内部的属性方法每个实例单独拥有一份例如this.getInfo function(){}。测试设计最怕的是把这三类混在一起。原型方法因为共享同一个函数对象最容易踩“this丢失”和“原型被污染”的坑所以在写用例时要重点覆盖正常调用、边界输入、实例间相互隔离、继承后的覆盖行为等。另外ES6的class语法本质上还是构造函数加原型方法的语法糖。你写的类方法最终都会挂到ClassName.prototype上。所以用Jest测试class时断言的角度和测试普通构造函数没有区别。2.2 测试框架选型为什么我最终选Jest而不是Mocha热词列表里有“自动化测试”“jmter并发测试接口”这些说明不少人已经在测试工具选型上纠结过。我也经历过Mocha到Jest的迁移这里直接给结论如果项目以JavaScript/前端为主Jest的性价比最高。理由用一张对比表说明特性JestMochaVitest断言库内置expect需要额外引入Chai内置expectMock能力内置mock一个原型方法很方便需要引入Sinon内置vi.mock覆盖率统计内置v8/istanbul需额外配置istanbul内置配置复杂度低零配置可跑需要组装低依赖Vite运行速度中等中等快基于esbuild生态成熟度最成熟老牌且稳定快速上升中选择Jest的核心原因是它对“测试一个原型对象”的场景支持最直接。你可以轻松地jest.spyOn(Class.prototype, method)mock某个原型方法对类实例的影响还能通过restoreAllMocks还原避免用例之间互相污染。Mocha更像搭积木灵活但组合成本高Vitest虽然快但在老项目里接入要看Vite的兼容情况。如果项目已经用了Vite那Vitest确实不错否则Jest更省心。3. 实操手写一个类并用Jest覆盖典型Prototype场景3.1 准备被测代码先能跑起来再谈测试理论说再多不如直接撸一段代码。我构造了一个非常贴合业务场景的UserManager类既有原型方法也有静态方法还涉及对象合并、数组过滤这些热词里的常见操作// userManager.js class UserManager { constructor(initialUsers []) { // 这里特意拷贝一层避免外部直接篡改内部状态 this.users initialUsers.map((u) ({ ...u })); } addUser(user) { if (!user || typeof user.name ! string || user.name.trim() ) { throw new TypeError(用户名必须是非空字符串); } this.users.push({ ...user }); return this.users.length; } findByName(keyword) { // 对应热词里的 filter 函数场景 return this.users.filter((u) u.name.includes(keyword)); } mergeUsers(newUsers) { if (!Array.isArray(newUsers)) { throw new TypeError(参数必须是数组); } // 合并时同样做一次浅拷贝避免两个数组共享对象引用 const copied newUsers.map((u) ({ ...u })); this.users.push(...copied); return this.users.length; } static createEmpty() { return new UserManager(); } } module.exports UserManager;这个类覆盖了原型方法的常规场景构造、新增、查询、批量合并、静态工厂。有一点值得新手注意addUser和mergeUsers里我都做了浅拷贝而不是直接把入参对象push进去。原因后面会细说但设计一个可测的类第一要务就是尽量避免意外的引用共享。3.2 写测试用例重点覆盖原型方法的行为和边界接下来写Jest用例。我没有先急着跑一条“完整业务流”而是把测试拆成多个小用例尽量把原型方法的行为边界覆盖全面// userManager.test.js const UserManager require(./userManager); describe(UserManager 原型方法测试, () { let manager; beforeEach(() { manager new UserManager(); }); test(addUser 能正常增加用户并返回最新长度, () { manager.addUser({ name: 张三, age: 30 }); expect(manager.users).toHaveLength(1); expect(manager.findByName(张三)).toHaveLength(1); }); test(addUser 传入空用户名时抛错, () { expect(() manager.addUser({ name: })).toThrow(TypeError); }); test(findByName 支持模糊查询, () { manager.addUser({ name: 张小三 }); manager.addUser({ name: 李四 }); const result manager.findByName(张); expect(result).toEqual([{ name: 张小三 }]); }); test(mergeUsers 合并对象后互不影响外部引用, () { const original { name: 王五 }; manager.addUser({ name: 张三 }); manager.mergeUsers([original]); // 篡改外部变量不影响内部已保存的数据 original.name 被篡改了; expect(manager.users[1]).toEqual({ name: 王五 }); }); test(静态方法 createEmpty 返回新实例, () { const empty UserManager.createEmpty(); expect(empty).toBeInstanceOf(UserManager); expect(empty.users).toEqual([]); }); });第三条用例里findByName返回的数组元素是内部this.users里的对象引用。如果返回后外部改了对象类内部数据也会跟着变。这一点在代码里我没专门做防御但测试断言已经隐含了这个风险。实际项目里如果对外暴露查询结果最好也做一次拷贝否则调用方一不留神就会污染内部状态。3.3 运行测试并查看结果顺便聊聊覆盖率命令行执行npx jest --coverage跑完的结果大致是Tests: 5 passed, 5 total Coverage Statements : 82.35% Branches : 75% Functions : 85.71% Lines : 82.35%没有覆盖到的地方主要是两个异常分支中的一部分比如mergeUsers对非数组入参的报错分支以及addUser对user为空对象的判断。覆盖率数字不必盲目追求100%但异常分支建议尽量覆盖因为很多线上事故恰恰是异常分支没被测试到。说个实用心得写测试时别只盯着“能不能跑通”要刻意去构造边界输入。一次addUser({})、一次mergeUsers(abc)看似无聊却能提前拦住大量低级错误。这比我以前“写个主流程就交付”的方式好了不止一点。4. 测试中绕不开的坑原型链问题与常见报错排查4.1 原型方法中的this丢失最隐蔽也最频繁我见过最多的“诡异bug”就是从实例中取出的方法调用时this不再是实例。举个例子const manager new UserManager(); const addFn manager.addUser; // 把原型方法“拆”出来 addFn({ name: 张三 }); // 报错Cannot read properties of undefined原因不复杂manager.addUser拿到的是UserManager.prototype.addUser这个函数本身它的内部使用了this。直接调用时this取决于调用方式普通函数调用下this是undefined严格模式或全局对象非严格模式于是this.users就不存在。排查技巧很简单看报错里有没有“undefined”相关提示再想一下这个方法是不是从对象里单独拆出来用了。修复方式可以是addFn.call(manager, ...)、addFn.bind(manager)或干脆在定义原型方法时保留对this的谨慎使用。测试的时候我习惯在用例里额外加一条“方法即使被单独取出也不应该破坏原型链上的逻辑”——虽然JavaScript本身不保护这一点但可以通过代码规范比如大部分方法只用入参、不依赖this来规避。4.2 模块加载报错failed to load module script到底是谁的问题热词列表里有“failed to load module script: expected a javascript module script but the se”这个报错我实在见得太多。它一般在浏览器控制台出现完整的错误提示往往是Failed to load module script: Expected a JavaScript module script but the server responded with a MIME type of text/html形成原因通常是script typemodule src.../script去加载一个不存在的文件或服务器尤其是一些轻量级静态服务器没有为.js文件返回正确的application/javascript类型。排查方向有两个检查src路径是不是相对路径写错了导致实际请求到了HTML页面检查静态服务器有没有正确配置MIME类型。用Jest做单元测试时一般不涉及这个错误但一旦你把ES Module代码直接放到浏览器里调试这类问题就来了。建议在代码里统一使用.mjs或显式在package.json中声明type: module能减少不少路径解析上的歧义。4.3 对象合并里的深浅拷贝陷阱热词里出现了“javascript合并两个对象”我在mergeUsers的实现里故意做了浅拷贝目的就是防止外部变量篡改内部数据。但浅拷贝本身也有局限如果对象里还有嵌套对象那么嵌套层的引用依然共享。const nested { name: 赵六, address: { city: 北京 } }; manager.addUser(nested); nested.address.city 上海; // 内部数据也会变这种情况的解法是深拷贝但深拷贝在测试代码里要慎重使用。如果被测代码频繁调用深拷贝性能会明显下降。更可靠的方案是在代码设计层面就用不可变数据例如每次更新都返回新对象或者在构造函数里强制拷贝一层。测试用例要关注的是“外部修改不影响类内部数据”这一层浅拷贝已经能覆盖大多数业务场景。还有一个经典问题直接用Object.assign({}, a, b)合并对象只做浅合并嵌套对象依然共引用。遇到嵌套结构需要深合并时建议用结构化克隆加递归处理或者直接引入成熟工具库别重复造轮子。4.4 常见问题速查表现象可能原因排查/解决方向未new直接调用构造函数prototype方法this报错原型方法中的this没有绑定实例检查调用方式使用call/apply/bind测试中mock原型方法后其他用例被“污染”mock未还原使用jest.restoreAllMocks()或在afterEach中还原浏览器报module script MIME错误服务器没有返回js MIME类型或请求路径错误查路径、查服务器MIME配置合并对象后修改外部变量导致内部数据变化浅拷贝或引用共享拷贝一层或重构为不可变数据class测试正常但直接调用prototype方法失败class默认严格模式必须实例化后再调方法继承场景里子类实例没有父类方法原型链指向错误检查extends和super是否正确表格里列的是高频问题但我在实际开发中还有一个习惯遇到任何奇怪的报错先打开控制台看调用栈。调用栈上每一层的函数名、文件路径基本能把问题定位到具体原型链条的哪一环。JavaScript的报错信息并不总是直观但调用栈极少数情况下会骗人。5. 测试工程层面的扩展从单元测试到更多形态5.1 自动化测试与并发测试单元测试只是测试金字塔的最底层。在真实项目里你还需要考虑自动化回归、接口并发等场景。热词里的“jmter并发测试接口”其实就属于服务端接口层面的并发验证它不是用来测单条原型方法的而是用来测试“大量请求同时打到接口时系统是否稳定”。作为前端开发我们平时写Jest用例并不需要关心Jmeter的并发模型但有一点相通测试用例之间必须相互独立。每个用例执行前重置状态比如beforeEach里重新new一个实例本质上就是在测试层面对“并发/顺序”的隔离。如果用例共享同一个实例前面用例改动数据后面的断言就会不可控。我在写自动化测试时会强制自己在beforeEach里创建新实例而不是在describe顶层创建共享实例。这个习惯帮我排查掉大量“用例单独跑通过、一起跑挂掉”的怪问题。5.2 表单提交与H5场景的测试差异热词里有“javascript中表单提交和h5的区别”这块在测试里也有讲究。传统的表单提交浏览器会直接发送请求并跳转页面而H5/前端形式下我们用JavaScript异步提交、局部刷新。测试策略完全不同传统表单更多关注字段校验、action地址是否正确、提交后页面跳转H5异步关注请求参数、返回数据渲染、错误提示、防重复提交等。前端写测试时我一般会用一个submit函数作为业务逻辑入口Jest用例直接调用它断言请求参数对不对、回调处理是否合理而不是真的在浏览器里走一遍完整表单。这一步可以大大提升测试效率也符合“单元测试只测一个函数行为”的原则。5.3 安全与性能测试需要知道但不必过度设计热搜词里出现了“安全测试”“渗透测试”“pikachu漏洞测试平台”等。作为开发人员我们至少要建立安全意识不要在前端代码里写死敏感信息、不要在原型对象上挂可以任意修改核心逻辑的方法。原型对象是全局共享的一旦被污染影响的是所有实例。这也是为什么我在测试用例里会专门检查“原型是否被意外改动”。性能方面原型链查找本身很快但如果你在getter上做复杂计算或者每次访问属性都触发深层遍历性能问题就会浮现。测试时可以粗略统计一下单个方法的执行时间或者用performance.now()在用例里包一层。如果方法执行超过预期阈值就有必要考虑优化方案。6. 一些我踩过坑之后养成的测试习惯6.1 每写一个原型方法先写“异常分支”用例很多人写测试习惯先把正常流程写一遍跑通了就算完成任务。我的建议是反过来先从异常分支开始写传空参、传错类型、传undefined、传null。这些分支一旦被测试覆盖后续重构时心里会踏实很多。test(mergeUsers 传入非数组会抛出 TypeError, () { expect(() manager.mergeUsers(abc)).toThrow(TypeError); });这样一条用例看着简单但它锁定了方法对非法输入的边界行为。如果未来有人重构mergeUsers不小心把类型判断删了这条用例立刻会亮红灯。6.2 用jest.spyOn验证方法之间的调用关系原型方法之间经常互相调用。比如addUser内部可能调用某个validateUser方法。如果你想验证“addUser确实调用了validateUser”可以直接spy原型上的方法test(addUser 会调用原型链上的校验方法, () { const spy jest.spyOn(UserManager.prototype, validateUser); manager.addUser({ name: 张三 }); expect(spy).toHaveBeenCalledTimes(1); spy.mockRestore(); });这个能力是Jest相对Mocha的一大优势。它能让你在“不真正执行校验逻辑”的前提下确认调用关系是否正确。mock和spy的边界也值得记一下spy不改变原方法行为只做观察mock会替换原方法实现。不改变业务逻辑时优先用spy。6.3 测试代码也要保持简洁别让测试比业务代码还难读最后想说一个心态问题。测试代码不是写得多就好而是要读起来像一篇文档每个test(描述)读下来基本能明白被测方法“在什么输入下应该有什么行为”。我会刻意控制每个测试文件的行数把用户管理的测试分成addUser、findByName、mergeUsers三个describe块。当某个测试挂掉时扫一眼测试名脑中大概能定位出错位置。结尾这套笔记写下来我自己的收获远不止“搞懂了prototype什么时候存在”这么简单。JavaScript原型的核心思想——对象之间通过委托共享行为——其实贯穿了语言设计的方方面面。你在测试中遇到的this丢失、引用共享、mock污染追根溯源都能回到原型链这个基础概念上。我现在的习惯是遇到奇怪的JavaScript报错先停下来想一想“这个对象是怎么创建出来的、方法挂在原型链的哪一层、this到底指向谁”然后再去搜报错信息。希望你也能从这篇文章里找到一套属于自己的排查路径。