
前些天帮朋友review一个老项目的测试测试报告一片绿但代码评审里发现有个接口返回的version字段已经从number变成了string。奇怪的是所有断言依然passed。翻了几个测试文件问题一下就清楚了到处都是assert.equal。这个API在Node.js assert模块的默认导出里用的是宽松比较1和1在它眼里完全没有区别。这篇想聊的就是从这类“虚假绿屏”里爬出来的经验——用assert.strict以及更推荐的assert/strict入口做严格断言到底能拦住哪些问题实际项目里又该怎么落地。不管你刚装好Node.js第一次跑测试还是维护着有年头的老服务这套东西都值得认真看看。内容不是概念复述都是我写单元测试、接口测试、参数防御时真实遇到的点。1. 先分清一对“同源不同命”的APIassert与assert/strict1.1 两个入口一套方法列表Node.js的assert模块从早期版本就存在了一开始走的是TDD风格assert.equal、assert.notEqual、assert.deepEqual。方法名看起来和很多测试框架的断言一致但equal用的是语义deepEqual是深度宽松比较。从Node 9.9.0开始官方逐渐提供了assert.strict属性和assert/strict入口也就是严格模式。这个入口不是简单加个开关而是直接给了一套行为不同的方法集合。用起来无非三种姿势const assert require(assert); // legacyequal 是宽松的 const strictAssert require(assert/strict); // 推荐入口 const alt require(assert).strict; // 老项目常见写法现在版本的Node LTS都支持node:前缀写起来更明确同时避免和本地同名模块混淆import assert from node:assert/strict; // 或者 const assert require(node:assert/strict);在strict模式下assert.equal其实是assert.strictEqual的别名assert.notEqual是assert.notStrictEqual的别名assert.deepEqual是assert.deepStrictEqual的别名。也就是说你在strict入口下写assert.equal(1, 1)和写assert.strictEqual(1, 1)效果完全一样直接抛AssertionError。这样设计有个容易被忽略的好处老代码从legacy切到strict时方法名基本不用改行为却立即变严格。你可以先在测试入口处把require(assert)统一改成require(assert/strict)跑一遍测试看挂在哪里再逐处修正预期值和类型而不是把所有断言全部重写一遍。1.2 为什么官方后来要单独拆一个strict出来这个问题我是从实际项目里体会出来的。早年写的接口测试经常出现这种场景预期返回一个数字接口某次改动后变成了字符串测试却没拦住。原因就一条——在比较时会做隐式类型转换很多类型不同的值在宽松模式下被判定相等断言形同虚设。当时社区里大家早就不用assert.equal了默认用chai的strictEqual或者自己手写。问题是为了一个“严格相等”的语义就要引入整个第三方断言库对一个Node服务来说有时候是笔不必要的依赖。官方做assert/strict本质是把社区里这条已经被验证了很多年的习惯收敛进内置模块你不需要装chai不需要配expect风格内置能力就够写出严格断言了。这也让assert/strict成了Node内置测试框架node:test的天然搭档后面第4章我会专门展开。现在维护老项目时看到遗留代码里的assert.equal我一般会先全局替换成strict入口再跑一遍测试。这一步通常能立刻炸出一批“假绿”用例看着是坏事其实是把以前欠下的债一次补齐。2. 严格断言到底严格在哪Object.is和deepStrictEqual的比较规则2.1 原始值用Object.is而不是或者很多人以为strict比较就是其实并不完全准确。官方文档写得很清楚assert.strictEqual用的是Object.is()语义。Object.is和在绝大多数情况一致但有两个边界差异恰恰是踩坑高发区NaNObject.is(NaN, NaN)返回true返回false。所以assert.strictEqual(NaN, NaN)会通过。这个反常规的点在数值计算、聚合统计场景里其实很友好——测试里想表达“结果是NaN”用strictEqual反而能写明白。0和-0Object.is(0, -0)返回false返回true。所以assert.strictEqual(0, -0)会抛错专门拦下“正负零混用”这类隐秘问题。下面这个表是三种比较语义的对照比较表达式Object.is1与1truefalsefalseNaN与NaNfalsefalsetrue0与-0truetruefalse实际编码里正负零的场景确实少见但一旦出现往往是算法边界或浮点精度处理出了问题。用strictEqual做断言的好处是它能把这种边界直接暴露出来而不是让测试稀里糊涂地通过。2.2 deepStrictEqual的深层规则deepStrictEqual是严格模式里最常用也最深的一个方法。它不是简单递归比较而是有明确规则的。我把文档里的规则挑重点过一遍都是我实际踩过或者看别人踩过的原始值同样按Object.is比较必须比较原型。两个对象即便字段一模一样只要原型不同就不相等只比较对象自身可枚举属性包括字符串属性和symbol属性属性顺序不影响结果{a:1,b:2}和{b:2,a:1}是相等的Error对象特殊name和message总是参与比较即使它们不可枚举RegExp比较source、flags和lastIndexDate比较时间戳Buffer按字节比较WeakMap、WeakSet只比较引用不比较内容包装对象会被解包new Number(1)和1在deepStrictEqual下相等。这里最关键的是原型比较。deepStrictEqual像个查户口的不仅看你家房子长什么样还看房产证上写的产权类型。一个普通对象和一个class实例哪怕字段完全一样在严格模式下也是两个不相等的东西。这是从宽松deepEqual迁移过来时最容易崩的一批用例。2.3 deepEqual与deepStrictEqual的核心差异速查用表格把高频差异列出来方便你迁移测试时直接对照对比场景legacy deepEqualstrict deepStrictEqual{a: 1} 与 {a: 1}通过报错Object.create(null) 与 {}通过报错原型不同new Animal(x) 与 new Robot(x)可能通过报错原型不同symbol属性有差异忽略参与比较并报错Error的name/message按可枚举属性比较容易漏掉始终参与比较有一点要特别提醒class里用#定义的私有属性不会出现在可枚举属性中deepStrictEqual默认感知不到它们的差异。如果被测对象的对外状态完全由私有字段决定测试里想比较这种实例最好在类上提供toJSON、校验方法或者显式暴露getter否则断言容易被“看似相等”骗过。这个问题在近年Node对#私有字段支持越来越完善之后开始变常见值得留个心眼。3. 把这些方法用进实战参数校验、错误匹配与异步断言3.1 用assert.strict做参数校验和内部不变量Node.js的assert不像C语言的assert依赖NDEBUG宏会被禁用它在运行时始终生效。所以不要拿assert当安全校验或者用户输入合法性校验它更适合表达“程序内部不变量”启动配置合并后必须存在关键项、回调参数必须符合约定、运算结果必须落在预期范围。比如做配置合并校验const assert require(node:assert/strict); function assembleConfig(baseConfig, envConfig) { const merged { ...baseConfig, ...envConfig }; assert.deepStrictEqual( Object.keys(merged).sort(), Object.keys(baseConfig).sort(), 配置键不一致base${JSON.stringify(baseConfig)} env${JSON.stringify(envConfig)} ); return merged; }这样在服务启动早期就能拦下配置项被误删、多出未知字段这类问题。比手写ifthrow简洁得多而且断言失败时错误信息里自带actual和expected一眼能看出差异在哪。另一个小工具是assert.ifErrorconst { execFileSync } require(node:child_process); try { const output execFileSync(git, [status, --porcelain]); assert.ifError(output instanceof Error ? output : null); } catch (err) { // 这里能拿到具体错误而不是被吞掉 }ifError专门断言值是null、undefined或false如果传了一个Error对象会直接把错误抛出来。它适合配合回调风格的API或者需要确认“没有错误发生”的代码路径。3.2 throws/rejects的错误匹配四种写法都要会assert.throws是测试异常路径的关键。很多项目只写第一种写法后面三种在实用场景里能省下不少事我一个个说。传构造函数assert.throws(fn, TypeError)只校验错误类型。RangeError、TypeError这类内置类型没问题但多个自定义Error子类如果继承自同一个父类光靠构造函数可能区分不够细。传正则assert.throws(fn, /message/)校验err.message中的文本。很多业务错误的关键信息在message里这个写法最常用正则还能匹配带变量部分的文本。传对象assert.throws(fn, { name: TypeError, message: 参数不合法 })会拿对象的每个属性对err做深度严格比较要求完全匹配。注意如果属性值是RegExp会按正则匹配对应字符串属性。传校验函数assert.throws(fn, (err) err.code ERR_INVALID_ARG)最灵活适合多条件组合校验。异步路径用assert.rejects用法和throws一样但有一个高频教训一定要await。await assert.rejects( fetchUserById(999), { name: UserNotFoundError, code: USER_NOT_FOUND } );忘了await的话断言在Promise settle之前就结束了异常根本不会被捕获测试照样绿。这个问题我见得太多了很多人排查半天最后发现就是少写一个await。3.3 match/doesNotMatch和message参数match不是断言相等而是断言字符串匹配正则适合校验URL格式、requestId格式、日志输出这类场景assert.match(reqId, /^req_[a-f0-9]{16}$/, requestId 格式不符合约定); assert.doesNotMatch(htmlContent, /scriptalert\(1\)\/script/, 输入没有被转义);message参数是所有断言方法最后一个可选参数。平时可能觉得没必要但一旦deepStrictEqual比较的对象很大默认错误信息会非常长经常被控制台截断。传一个带检查点编号的message失败时能精确定位是哪个断言出了问题尤其是在一条测试用例里连续比较多个对象时这个习惯能显著缩短定位时间。assert.deepStrictEqual( config, expectedConfig, 检查点生产配置 ${env} 与基准配置不一致 );4. 零第三方依赖的测试方案node:test配assert/strict4.1 为什么弃用第三方断言写Node服务端测试早年间主流选择是mochachai。chai功能强但体积不小jest更重。Node 18之后node:test稳定了断言部分就是assert/strict两者加起来能覆盖大部分服务端单测和接口测试场景describe/it/test、before/after、mock、spy这些能力都内置了。减少依赖不只是做好看也意味着少一层版本兼容风险。第三方断言库和Node大版本升级之间偶尔会有适配问题而assert/strict随Node版本走语义稳定。团队里新人看文档也方便官网assert文档就是最权威的参考不用再记一套chai的expect语法。如果你维护的是纯Node服务没有浏览器环境需求现在完全可以把测试栈收敛成node:testassert/strict少装十几个npm包node_modules体积和安装时间都能降下来。4.2 一个完整测试文件长什么样下面是我实际项目里测试文件的一个简化版本改了业务细节结构保留const { describe, it, beforeEach } require(node:test); const assert require(node:assert/strict); const { createUser } require(../src/user-service); describe(POST /api/user, () { let db; beforeEach(() { db createMemoryDb(); }); it(正常创建用户时返回201和用户对象, async () { const res await createUser(db, { name: alice, age: 18 }); assert.strictEqual(res.statusCode, 201); assert.deepStrictEqual(res.body, { id: 1, name: alice, age: 18, createdAt: res.body.createdAt }); }); it(年龄为负数时抛出业务异常, async () { await assert.rejects( createUser(db, { name: bob, age: -1 }), { name: ValidationError, message: /age.*non-negative/ } ); }); });运行只需一行命令node --test test/。想只跑一个文件就node --test test/user.test.js。CI里加进npm scripts后一条命令就能跑完全套测试没有任何额外依赖。对多数服务端测试场景来说这个组合已经够用了。4.3 从mocha/chai迁移的映射表如果手上有一套老测试用的是chai迁移到assert/strict比想象中简单映射很直观基本是机械替换chai 风格assert/strict 写法expect(a).to.equal(b)assert.strictEqual(a, b)expect(a).to.deep.equal(b)assert.deepStrictEqual(a, b)expect(fn).to.throw(TypeError)assert.throws(fn, TypeError)expect(fn).to.throw(/msg/)assert.throws(fn, /msg/)expect(str).to.match(/re/)assert.match(str, /re/)expect(promise).to.be.rejectedWith({name:X})await assert.rejects(promise, {name:X})expect(a).to.be.trueassert.strictEqual(a, true)expect(a).to.existassert.ok(a)迁移后跑一轮测试你会发现原本绿着的用例红掉一批。别慌红掉的基本都是宽松比较放过的“假绿”逐个修正预期类型或预期结构测试可信度反而上来了。我那次迁移一个二十多个测试文件的服务红掉的用例有一半以上是接口返回数字变字符串、字段类型被宽松比较掩盖的问题。5. 从“假绿”到“真红”我在生产环境踩过的断言坑5.1 最典型的false positive类型被悄悄改变我遇到最多的是接口返回结构里number变string。早期接口返回version: 12某次改动后变成version: 12因为数据库字段类型变了或者序列化配置调整。如果测试写的是assert.equal(res.version, 12)宽松比较直接通过。线上客户端恰好对类型不敏感还好一旦前端有类型判断立刻出问题。换成assert.strictEqual之后这类回归一进测试就暴露。另一个相似场景是配置合并。baseConfig里是retries: 3环境配置传入3对象spread后拼接出了字符串数字。老代码用deepEqual对比配置完全没察觉deepStrictEqual立刻给出actual和expected的diff。这也是我在配置校验里首选deepStrictEqual的原因——它拦下的不是“值对不对”而是“类型对不对”。5.2 原型差异和私有无枚举字段切到strict之后很多测试挂在原型比较上。印象最深的一次是一个对象从普通字面量改成了class实例字段完全一样测试却红了。当时第一反应是“断言写错了”后来定位发现不是业务错了是原型确实不同。这里有两种处理方式要么测试里单独校验关键字段要么对象提供toJSON方法后在比较前先序列化。但要小心序列化会丢失undefined和symbol属性反而掩盖其他问题。还有私有字段问题。#count从一个类里增加后deepStrictEqual无法感知因为#count不可枚举。如果类的对外状态完全由私有字段决定最好在类上提供一个校验方法或者维护一个toJSON避免断言被“看似相等”骗过。这个问题不像类型错误那么高频但对依赖私有状态的对象来说是个实打实的盲区。5.3 错误匹配里最容易忽略的细节assert.throws传对象做校验时很多人以为只校验name其实它会逐属性做深度严格比较。比如assert.throws( () { throw new RangeError(越界); }, { name: TypeError, message: 越界 } );这个断言会失败因为name不匹配。这种“对象属性必须全部一致”的语义一方面很强大另一方面容易让测试写得过细。我的习惯是需要精确控制message就用正则只需要类型用构造函数需要多个字段组合用校验函数尽量让断言意图清晰而不是塞一大堆属性进去。意图清晰的断言比一个看似“更严格”的断言对维护更友好。5.4 读懂AssertionError并快速定位断言失败时Node会输出actual和expected的diff但对象层级深、数组长的时候会被截断。这里有三个调试技巧平时我一直在用给断言传第三个参数message标注检查点避免在长diff里大海捞针在测试里临时用util.inspect(actual, {depth: null, colors: true})完整打印对象对超大对象先把对象JSON.stringify落盘再用diff工具比较比肉眼在终端里翻快得多。遇到“明明看起来一样却失败”我的排查顺序基本固定先检查原型再检查不可枚举字段再检查symbol属性最后检查是否有字段经过了序列化转换。按这个顺序走大多数情况下几分钟就能定位。5.5 一条值得养成的习惯最后分享一个我自己的习惯新项目里测试文件头部统一写const assert require(node:assert/strict)代码评审时看到assert.equal直接打回。这个习惯坚持下来后续排查问题的成本会低很多。老项目迁移时也不用一次性全部重写可以先在测试入口统一换成strict入口让“假绿”暴露出来再逐个修正。这个懒值得偷。