Chai 断言库实战:三种断言风格、链式原理与自定义插件
写测试用例这件事写着写着就会发现真正让人头疼的不是跑通而是断言写得够不够清楚。if (a ! b) throw new Error(不相等)这种手写判断在第一个月还凑合等到几百个用例铺开报错信息全靠自己拼字符串失败一次要翻三层栈才能定位。Chai 断言库就是在这个环节里被我捡起来的工具它把断言从“自己写 if”变成“读起来接近自然语言的链式调用”失败时给出的信息也更像人话。这篇内容围绕 Chai 断言库展开从安装接入、should / expect / assert 三种断言风格的取舍一路讲到插件机制与手写一个自定义断言插件的完整过程。适合刚接触单元测试、正在选断言库的前端或 Node 开发者也适合已经在用 Chai、但只停留在expect(x).to.equal(y)这一层、想弄明白链式语法背后怎么运转的人。文中所有代码都可以直接拿去改吧改吧放进项目遇到坑的地方我会标出来。1. 断言库在测试链路里的位置以及 Chai 的取舍1.1 断言失败信息决定了你调试要花多久先把断言库的职责说清楚。测试框架比如常见的 Mocha、Jest、Vitest负责的是“发现用例、组织生命周期、汇总结果”它不负责判断“这个值对不对”。判断这件事落在我写的断言上。所以断言库真正的价值不在于“能不能判断”任何语言原生的比较运算符都能判断价值在于失败那一刻输出的信息量。我踩过最典型的一次某个接口返回的对象里有个嵌套字段data.items[0].status期望是done但实际是pending。用自己写的 if 判断报错是一行干巴巴的status error我得回过头去读代码才知道是哪个对象、期望值是什么、实际值是什么。换成expect(res.data.items[0].status).to.equal(done)失败信息直接给出期望值、实际值对象被截断展示路径一目了然。这就是断言库最实在的收益把调试成本从十几分钟压到十几秒。第二层价值是可读性。测试代码的读者往往不是写它的人半年后回来维护的那个“别人”很可能就是你自己。expect(list).to.have.lengthOf(3).and.to.include(a)这种句子扫一眼就知道在验证什么比一串和好读太多。这也是我后来在所有 JS 项目里都固定用 Chai 的原因链式语言带来的表达力是实打实的。第三层是扩展性。业务里总有一些断言是通用库覆盖不到的比如“这个金额字符串必须保留两位小数”“这个 ID 必须匹配某种格式”“这个时间戳必须落在某个区间内”。好的断言库允许我把这些判断封装成自定义断言之后整个团队的用例都用同一个入口语义统一、报错统一。这一点是本文后半段的重点也是 Chai 相比很多轻量断言库最有优势的地方。1.2 为什么最后落到 Chai 上一次选型复盘选断言库这件事市面上可选的方案大致分三类我把当时对比的几个维度整理成了表格用的都是通用描述不涉及具体产品对比。方案类型优点短板适用场景语言原生断言模块零依赖、启动快链式表达弱可读性一般扩展要自己包一层小脚本、工具函数自测测试框架内置断言装一次就有无需额外配置与框架绑定换框架要重写用例新项目、框架短期不会变独立断言库风格可选、可扩展、与框架解耦需要多一步安装和初始化中大型项目、长期维护的代码库Chai 属于第三类它的关键优势是与测试框架解耦。断言写在用例里测试运行器换成别的断言部分基本不用动。这对那些“老项目用一套、新项目换一套”的团队特别有用我经历过一次测试运行器迁移断言部分几乎零改动只改了钩子函数的写法。另一个决定性因素是三种风格并存。团队里总有人喜欢expect有人习惯 Node 那种assert.strictEqualChai 让这两种人在同一个项目里各写各的互不干扰。这一点听起来是小事实际上是降低推行阻力的关键强制所有人换风格往往会引发一轮无谓的争论。至于依赖体积Chai 本身不算重安装之后对构建产物的影响可以忽略因为断言只会在测试环境被引用不会打进生产包。这条也是我敢在业务项目里放心引入的前提。注意断言库只在测试代码里引用。如果某天你发现chai出现在了生产打包结果里八成是某个工具函数文件误把它 import 进去了这种问题在依赖分析工具里一眼就能看出来。1.3 三种风格先看个大概后面逐个拆不绕弯子先看一眼同一个断言在三种风格下的样子建立个直观印象。假设要验证一个字符串变量name等于chai// expect 风格 expect(name).to.equal(chai); // should 风格 name.should.equal(chai); // assert 风格 assert.equal(name, chai);再看一个稍微复杂点的验证数组长度和包含关系// expect expect(tags).to.have.lengthOf(3).and.to.include(test); // should tags.should.have.lengthOf(3).and.include(test); // assert assert.lengthOf(tags, 3); assert.include(tags, test);差别很明显expect和should是链式 DSL一句话读完像英文句子assert是函数调用式参数顺序固定写起来更像传统编程。这三种风格没有绝对优劣只有场景匹配度接下来的章节我会把每种风格的语法细节、典型坑位和适用边界都过一遍。2. 安装与工程接入版本、模块体系与初始化顺序2.1 版本分水岭先确认你的模块体系再装安装本身只有一行命令但装哪个大版本这件事比安装命令重要得多。Chai 在 5.x 版本做了一次彻底的模块体系切换新版本是纯 ESM 包不再提供 CommonJS 入口4.x 系列则以 CommonJS 为主兼容性最好对老工程、老打包器、老 Node 版本都友好。所以安装之前先回答三个问题项目用的是require还是import测试文件是.js还是.mjs/.ts当前的 Node 大版本是多少这三个答案基本能定下版本。# 新项目、纯 ESM、Node 版本较新 npm install --save-dev chai # 老项目、CommonJS、需要最大兼容性 npm install --save-dev chai4装完之后第一件事是验证引用是否正常我一般直接起一个最小的测试文件跑一遍而不是等写完一堆用例才发现模块解析有问题// 老工程里验证 CJS 引用 const { expect } require(chai); console.log(expect(1).to.equal(1)); // ESM 工程里验证 import { expect } from chai; console.log(expect(1).to.equal(1));如果 ESM 工程里用了 CommonJS 语法去require一个纯 ESM 的包会直接抛出模块解析错误反过来CommonJS 工程里用了import语法但没配转译也会报语法错误。这类问题的根源永远是模块体系不匹配和 Chai 本身的功能无关。我解决这类问题最省事的方式就是降大版本老工程直接用 4.x别硬上为了一行断言去重构整个模块体系不划算。2.2 插件与全局配置放在测试启动的最前面Chai 的初始化逻辑必须在任何用例执行之前跑完。这个顺序搞错了表现就是某些用例偶尔失败、某些断言报“方法不存在”。我的做法是单独建一个初始化文件比如test/setup.js把插件注册、全局配置、自定义断言全部塞进去然后在测试运行器的配置里指定它作为启动入口。// test/setup.js import chai from chai; // 全局配置让失败信息更好读 chai.config.includeStack true; // 失败信息里带上调用栈 chai.config.truncateThreshold 0; // 关闭对象截断长对象完整展示 chai.config.showDiff true; // 开启差异对比 export const expect chai.expect; export const assert chai.assert;然后测试运行器的配置指向它。以常见的配置方式为例// .mocharc.cjs module.exports { require: [./test/setup.js], spec: test/**/*.spec.js, timeout: 5000 };truncateThreshold这个参数值得单独说一句。它默认会把长对象截断到几十个字符好处是终端输出整洁坏处是对象很大时你只能看到{ a: 1, b: 2, ... 3 more }然后被迫去打日志。我一般在本地开发时把它设成0关掉截断CI 环境再打开避免日志过长。includeStack在几个大版本之间的默认值不一样显式写清楚最省心。还有一个容易被忽略的配置项是 Proxy 相关的开关。较新的 4.x 版本用 Proxy 来检测“你写了一堆语法上合法但语义上无效的链式调用”这个能力在大多数环境下是好事但在某些老打包器或特殊运行时里可能引发奇怪的属性访问错误。真遇到了把这个开关关掉即可不建议一上来就关因为它的报错提示确实能帮你发现拼写错误。2.3 TypeScript 工程里的类型接入别硬扛TS 项目里用 Chai需要单独装类型声明包npm install --save-dev types/chai装完之后基础断言是有类型的expect(obj).to.equal(...)能正确提示。但自定义断言不会有类型这是必然的编译器不可能知道你私下给Assertion加了个withinRange方法。解决办法是声明合并把自定义方法补进去// types/chai.d.ts declare global { namespace Chai { interface Assertion { withinRange(min: number, max: number): Assertion; } } } export {};这里有个细节declare global必须放在一个有export语句的文件里才会被当成模块处理否则声明会变成全局脚本声明在某些配置下不生效。另外命名空间的名字必须是Chai这是类型声明包约定的入口名写错了编译不报错但断言方法依然没提示很隐蔽。还有个坑如果你的项目同时用expect和assert两种风格类型声明里的Assertion接口只覆盖链式风格assert风格走的是另一个接口。自定义断言如果只实现了链式版本assert那边调用不到是正常的需要单独补方法下一节讲插件开发时会更清楚。3. 三种断言风格的实战拆解3.1 expect主力风格链式语法覆盖最全expect是我在项目里的默认选择原因很实际它不受任何原型改造的影响不会因为某个对象是null就整体失效而且链式语言的可组合性最强。基本用法是从chai解构出expect然后不断地往链子上接修饰符。import { expect } from chai; // 基础类型与相等判断 expect(1 1).to.equal(2); expect(chai).to.be.a(string); expect([1, 2, 3]).to.be.an(array).that.has.lengthOf(3); // 对象深度比较注意必须用 deep expect({ a: 1, b: { c: 2 } }).to.deep.equal({ a: 1, b: { c: 2 } }); // 包含关系的三种粒度 expect([1, 2, 3]).to.include(2); // 数组含元素 expect(hello chai).to.include(chai); // 字符串含子串 expect({ a: 1, b: 2 }).to.include({ a: 1 }); // 对象含子集 // 否定与链式组合 expect([]).to.be.an(array).that.is.empty; expect({ a: 1 }).to.not.have.property(b);链式里有两个词特别容易被误用to和be。它们叫语言链本身不做任何判断纯粹是为了让句子读起来通顺。所以expect(x).to.be.equal(y)、expect(x).to.equal(y)、expect(x).equal(y)三者行为完全一致。新人常问“to和be有什么区别”答案是没区别选一个读起来顺的写法固定下来就行我自己的习惯是数字比较用.to.be.above()字符串用.to.equal()纯粹是读着舒服。真正需要留意的是deep这个修饰符。JS 里对象比较默认是引用比较expect({a:1}).to.equal({a:1})会失败必须写成.to.deep.equal()。这个坑几乎所有新手都会踩一次而且失败信息看起来很像“值明明一样却不相等”非常迷惑。我在团队里定的规矩是只要断言的对象是引用类型一律写deep宁可多打四个字母也不要靠记忆判断这次比较走的是引用还是结构。再补几个实战中出现频率很高、但很容易写错的断言// 对象键集合校验all 要求键完全一致any 只要命中一个 expect({ a: 1, b: 2 }).to.have.all.keys(a, b); expect({ a: 1, b: 2 }).to.have.any.keys(a, z); // 数组成员校验members 忽略顺序ordered 强调顺序 expect([1, 2, 3]).to.have.members([3, 1, 2]); expect([1, 2, 3]).to.have.ordered.members([1, 2, 3]); // 函数抛错校验第一个参数是错误类型第二个是消息匹配 expect(() JSON.parse({)).to.throw(SyntaxError); expect(() { throw new Error(bad input); }).to.throw(/bad/); // 数值区间与近似值 expect(0.1 0.2).to.be.closeTo(0.3, 1e-10); expect(score).to.be.within(60, 100);closeTo那个例子值得多说一句。浮点数相加精度丢失是老问题0.1 0.2 0.3是false用equal断言必挂。closeTo(期望值, 容差)才是正确写法。容差取多少要看业务金额场景通常取到分位即0.001或更小。这个细节在金融、计费类项目里几乎必踩。3.2 should读起来最顺坑也最集中should风格的语法是把断言挂在对象本身读起来最像英文user.name.should.equal(tom)这种句子确实漂亮。用之前必须先调用一次chai.should()来扩展原型import chai from chai; chai.should(); const user { name: tom, age: 18 }; user.name.should.equal(tom); user.age.should.be.above(17); user.should.have.property(name).that.is.a(string);漂亮归漂亮should有四个坑我在项目里全都遇到过这里一次说清楚。第一个坑null和undefined直接失效。因为should是挂在Object.prototype上的 getter而null和undefined没有原型链访问属性直接抛 TypeError。也就是说你本想断言“这个值是 null”结果拿到的是一个类型错误报错信息和你的意图完全不搭。// 错误写法直接抛 TypeError const value null; value.should.be.null; // TypeError: Cannot read properties of null // 正确写法用 expect 或 assert expect(value).to.be.null; assert.isNull(value);第二个坑Object.create(null)造出来的对象没有原型同样挂不上should。这在用 Map 模拟字典、或某些库返回纯净对象时会出现。第三个坑原型污染的心理负担。chai.should()改的是全局Object.prototype在测试代码里问题不大但如果测试文件和业务代码跑在同一个运行时里有些老工程确实这样就存在互相干扰的风险。要撤掉扩展可以调用chai.should()之后的清理逻辑但更稳妥的办法是干脆别在混合环境里用它。第四个坑断言顺序反直觉。should风格的链式修饰符位置和expect略有差别比如arr.should.have.lengthOf(3)和expect(arr).to.have.lengthOf(3)语法一致但涉及not时容易写反str.should.not.equal(x)是对的写成str.should.equal.not(x)就报错了。我的实际结论是should适合纯测试环境、纯对象断言、团队已经形成统一习惯的项目只要项目里存在可能为null的值几乎必然存在就应该准备好expect作为兜底或者干脆统一用expect。混用会让代码风格割裂反而不如早统一。3.3 assertTDD 风格迁移成本最低assert风格是函数调用式语法继承自 Node 原生断言模块的思路参数顺序固定判断逻辑明确。它的最大优势是不需要链式语法也不需要改原型在任何环境下都能直接用尤其是那些跑在特殊运行时、或者断言代码要被非测试文件引用的场景。import chai from chai; const assert chai.assert; // 类型与相等 assert.typeOf(chai, string); assert.isString(chai); assert.equal(1 1, 2); assert.strictEqual(1 1, 2); // 结构比较 assert.deepEqual({ a: 1 }, { a: 1 }); assert.deepStrictEqual({ a: [1] }, { a: [1] }); // 数值与范围 assert.isAbove(10, 5); assert.isAtMost(10, 10); assert.isWithin(5, 1, 10); // 集合与属性 assert.include([1, 2, 3], 2); assert.lengthOf([1, 2, 3], 3); assert.property({ a: 1 }, a); assert.hasAllKeys({ a: 1, b: 2 }, [a, b]); // 异常 assert.throws(() { throw new TypeError(x); }, TypeError, /x/);这里有个必须拎清楚的点assert.equal走的是宽松相等assert.strictEqual走的是严格相等。这俩名字太像看代码时经常一眼滑过去。我的习惯是永远写strictEqual然后在代码审查里把它当成规则来抓。同理deepEqual和deepStrictEqual后者会连原型和类型一起比较更严格也更安全。另外assert风格在否定断言上的可读性会下降。expect(x).to.not.equal(y)读起来很自然assert.notEqual(x, y)也能读但复杂一点的否定就难了比如“这个数组不包含某个对象”assert.notInclude面对引用类型时行为需要额外确认。所以我的用法是结构简单、判断明确的地方用assert需要组合多个条件、或者需要精细否定的时候切回expect。迁移成本这块也补充一下。如果团队原本就是用 Node 原生assert写的用例切到chai.assert的改动量极小大部分方法名是兼容甚至一致的这也是我在接手老项目时优先选assert风格的原因改动少风险低能快速把断言信息质量提上来。3.4 混用边界一张表定规矩三种风格可以共存但必须定规矩否则同一个文件里三种写法混着来维护成本会暴涨。我给自己团队定的规则是这样的场景推荐风格理由日常业务断言expect链式表达力强否定写法自然不受原型影响可能为 null / undefined 的断言expect 或 assertshould 在这类值上直接失效简单类型与数值判断assert一行搞定参数顺序固定阅读成本低老代码迁移assert与原生断言思路接近改动量最小共享断言库 / 自定义插件expect 为主插件方法在链式风格下组合性最好定完规则之后最好在代码规范里加一条单个测试文件内风格不超过两种且必须按 describe 块划分。这条规矩解决的是“读文件时大脑要在两种语法间反复切换”的疲劳问题实际收益比想象中大。4. 链式 DSL 的实现原理与插件机制4.1 链式调用到底是怎么串起来的想写插件先得知道链子是怎么串的。expect(x).to.be.an(array).that.includes(2)这一长串中间每个词返回的到底是什么如果每个词都返回一个全新的对象那x这个被测值怎么一路传下去答案是被测值存在一个内部标志位上链子上每一个环节返回的都是同一个断言对象或它的代理。可以把它理解成一个“手里拿着被测值的小人”expect(x)时把x塞进小人手里之后每经过一个修饰符小人只是换了句台词手里的东西没变。最终碰到真正的断言方法比如equal、include时才拿手里的东西去比较。to、be、been、is、that、which、and、has、have、with、at、of、same、but、does这些词就是纯粹的“台词”它们被注册成链式属性访问时返回自身不做任何判断。所以写多少个都不影响结果expect(1).to.be.a.and.that.equal(1)完全合法只是读起来啰嗦。not和deep就不一样了它们是修饰标志。not会在内部打一个“取反”标记后续断言方法在内部把结果翻转deep会打一个“深度比较”标记后面的equal就会走结构比较而不是引用比较。这些标记存在断言对象的内部状态里插件开发时可以通过工具函数读写这是实现自定义修饰符的基础。理解这一层的实际意义在于你写的插件方法拿到的永远是原始的this被测值通过this._obj取。搞清楚这点写插件时就不会犯“在方法里重新expect(this._obj)导致链式丢失”这种低级错误。实际上正确做法就是在插件方法内部重新包一层断言来做前置校验后面给例子。4.2 插件入口与四个扩展点插件在 Chai 里就是一个函数签名固定为(chai, utils)调用chai.use(pluginFn)注册。函数体里做的事情本质上是往chai.Assertion上挂东西。可挂的位置有四个用途各不相同扩展点作用典型用途addMethod添加方法型断言.withinRange(1, 10)、.matchFormat(YYYY-MM-DD)addProperty添加属性型断言.even、.empty、.uuidaddChainableMethod添加可链式方法既能.closeTo(1)又能.closeTo.within(0.5)overwriteMethod/overwriteProperty覆写内置断言让equal对特殊类型走自定义比较// 插件骨架 export default function myPlugin(chai, utils) { const { Assertion } chai; Assertion.addMethod(withinRange, function (min, max) { // this._obj 是被测值 }); Assertion.addProperty(even, function () { // 属性型断言直接 this.assert }); }addMethod和addProperty的区别说白了一个是expect(x).foo()带括号一个是expect(x).foo不带括号。语义上方法型适合需要传参的判断属性型适合“是不是某种状态”的判断。这个区分不算强制但按这个思路组织用起来最顺手。4.3 断言消息模板与内部标志插件里最重要的一个调用是this.assert()它有五个参数this.assert( expression, // 布尔结果 errorMessage, // 正向失败时的消息 negatedMessage, // 加了 .not 时失败的消息 expectedValue, // 期望值会填入 #{exp} actualValue // 实际值会填入 #{act} );消息里可以用的占位符主要是#{this}被测值的展示、#{exp}期望值、#{act}实际值。这套模板看起来简陋但实际够用而且和 Chai 内置断言的消息格式一致团队用起来没有割裂感。内部标志通过utils.flag读写utils.flag(this, deep, true); // 设置深度比较 const isDeep utils.flag(this, deep); // 读取 utils.flag(this, object); // 读取被测值等价于 this._obj插件里如果想让自定义断言也支持deep就得在比较逻辑里读这个标志。同理想让自定义断言支持.not不需要自己处理this.assert会自动根据取反标志决定用哪条消息、要不要翻转结果。这一点是 Chai 设计得比较好的地方插件作者只关心“正向情况下对不对”取反逻辑框架帮你兜了。5. 手写一个 Chai 插件从骨架到发布5.1 需求定义与目录结构假设我在一个涉及金额计算的模块里写测试反复出现三类断言金额必须落在某个区间、金额必须保留两位小数、金额必须是正数。每次都写三条expect太啰嗦封装成插件更合适。目录结构按标准 npm 包来chai-money-assert/ ├── package.json ├── src/ │ └── index.js └── test/ └── index.spec.jspackage.json里注意main、exports、type三个字段要和目标工程的模块体系匹配。如果要做成通用插件建议同时提供 ESM 和 CJS 两套产物或者干脆只发 4.x 兼容的 CJS 版本覆盖面更广。5.2 方法型断言区间校验先写最常用的一个校验数值落在区间内// src/index.js export default function chaiMoney(chai, utils) { const { Assertion } chai; Assertion.addMethod(withinMoneyRange, function (min, max) { const actual this._obj; // 前置校验被测值必须是数字否则报错信息会更精确 new Assertion(actual).to.be.a(number); this.assert( actual min actual max, expected #{this} to be within money range #{exp}, but got #{act}, expected #{this} to not be within money range #{exp}, ${min}..${max}, actual ); }); }拆开看几个要点。第一this._obj拿到的就是被expect()包住的那个值不需要额外传参。第二new Assertion(actual).to.be.a(number)这行是前置校验用的是 Chai 已有的断言能力好处是类型不对时报错信息直接告诉你“期望是 number实际是 string”比你自己拼一句“参数错误”精确得多。第三this.assert的第五个参数传了实际值这样失败信息里能看到具体数字调试时省一步。注册之后的使用方式import chai from chai; import chaiMoney from chai-money-assert; chai.use(chaiMoney); expect(99.5).to.be.withinMoneyRange(0, 100); expect(120).to.not.be.withinMoneyRange(0, 100);.not不用做任何额外处理就能生效这就是this.assert的价值。5.3 属性型断言与链式断言接着加一个属性型断言判断金额是否保留两位小数。这里涉及一个数值表示的细节浮点数判断小数位数不能靠字符串截取会被科学计数法坑到比较稳的做法是把值转成字符串后看小数部分长度同时考虑整数情况。Assertion.addProperty(twoDecimals, function () { const actual this._obj; new Assertion(actual).to.be.a(number); const str String(actual); const dotIndex str.indexOf(.); const decimals dotIndex -1 ? 0 : str.length - dotIndex - 1; this.assert( decimals 2, expected #{this} to have at most 2 decimals, expected #{this} to not have at most 2 decimals, 2 decimals, decimals ); });用的时候直接挂属性expect(12.34).to.have.twoDecimals;。这里要注意一个语言链的小问题have是语言链后面可以直接接属性断言读起来是通顺的。再进一步如果想支持.positive.and.twoDecimals这种组合属性型断言天然支持链式因为它们返回的还是同一个断言对象。真正需要addChainableMethod的场景是“方法后面还能再接一层”比如.closeTo(1).within(0.01)这种写法。实现上需要提供两个函数断言本体和行为函数。Assertion.addChainableMethod(closeToMoney, function (expected, delta 0.01) { const actual this._obj; new Assertion(actual).to.be.a(number); this.assert( Math.abs(actual - expected) delta, expected #{this} to be close to #{exp} within ${delta}, expected #{this} to not be close to #{exp} within ${delta}, expected, actual ); }, function () { // 链式行为不做额外处理保持链子继续 });addChainableMethod的第二个函数是“访问时执行的行为”很多时候留空即可。这块的复杂度主要来自使用者可能以各种顺序组合修饰符我的建议是非必要不用addChainableMethodaddMethod加参数默认值就能覆盖绝大部分需求实现和维护成本低得多。5.4 覆写内置断言与类型声明补齐还有一种插件形态是覆写内置断言。典型场景项目里有个特殊的数值类型包装对象希望.equal()遇到它时自动走值比较而不是引用比较。Assertion.overwriteMethod(equal, function (_super) { return function (expected) { const actual this._obj; if (isMoneyWrapper(actual) isMoneyWrapper(expected)) { return this.assert( actual.value expected.value, expected #{this} to equal #{exp}, expected #{this} to not equal #{exp}, expected.value, actual.value ); } // 其他情况交还给原实现 return _super.apply(this, arguments); }; });_super是原实现的引用必须保留兜底分支否则所有普通类型的equal断言都会失效而且失败信息会变得莫名其妙。这是覆写类插件最危险的地方我在自己的插件里加了一条硬规则覆写方法里第一行先判断类型是否命中不命中立刻_super.apply不做任何多余逻辑。类型声明也要配套补上否则 TS 用户用不了// types/index.d.ts declare global { namespace Chai { interface Assertion { withinMoneyRange(min: number, max: number): Assertion; twoDecimals: Assertion; closeToMoney(expected: number, delta?: number): Assertion; } } } export default function chaiMoney(chai: any, utils: any): void;5.5 自测与发布前检查插件本身也需要测试而且要用它自己来测自己这有点绕但很有必要。测试文件里注册插件然后用插件提供的方法去断言同时补一组“应该失败”的用例验证失败信息是否符合预期。import { expect } from chai; import chaiMoney from ../src/index.js; import chai from chai; chai.use(chaiMoney); describe(chai-money-assert, () { it(区间内应通过, () { expect(50).to.be.withinMoneyRange(0, 100); }); it(区间外应失败并给出可读信息, () { try { expect(150).to.be.withinMoneyRange(0, 100); throw new Error(本应失败却通过了); } catch (err) { expect(err.message).to.include(within money range); } }); it(小数位断言对整数也应通过, () { expect(100).to.have.twoDecimals; }); });发布前我会过一遍这份清单插件函数是否幂等同一进程里被chai.use两次会怎样建议在函数开头加个标记判断避免重复注册导致的副作用叠加。所有自定义断言是否都有清晰的失败消息并且包含实际值前置类型校验是否覆盖了null、undefined、NaN这些边界是否测试了.not分支很多插件的取反消息是直接复制正向消息改的读起来别扭。README 里是否写明了兼容的 Chai 大版本这一点极其重要5.x 和 4.x 的模块体系不同用户装错版本会直接跑不起来。6. 常见问题排查速查6.1 报错速查表下面这张表是我在实际项目里攒下来的高频问题基本覆盖了 Chai 使用中 80% 的困惑。现象大概率原因处理方式断言方法不存在报 is not a function插件没注册或注册顺序晚于用例执行把注册放到测试启动配置的最前面对象值看起来一样但断言失败没有用deep走的是引用比较引用类型断言统一加.deep失败信息里对象被省略成...moretruncateThreshold生效本地设为 0 关闭截断Cannot read properties of null在 null 上用了 should 风格改用 expect 或 assertESM 工程里require报模块解析错误装成了纯 ESM 的大版本降到 4.x 或改用 import 语法TS 里自定义断言提示不存在缺少声明合并补declare global namespace Chai异步断言永远通过断言返回的 Promise 没有 await 或 return用例里显式 return 或 await同一用例内断言顺序混乱链式修饰符位置写错按“语言链 修饰符 断言方法”顺序书写这张表里我觉得最值得展开的是最后两条一个是异步假通过一个是链式顺序都属于“代码能跑但结果不可信”的类型比直接报错更危险。6.2 异步断言与 Promise 的假通过陷阱异步相关的断言最容易出的问题是断言根本没被执行到用例却显示通过。原因通常是 Promise 没被 await 或 return测试运行器看不到这个异步任务用例提前结束了。// 危险写法没有返回用例可能提前结束 it(应该拒绝, () { expect(fetchData()).to.be.rejectedWith(Error); }); // 正确写法返回 Promise it(应该拒绝, () { return expect(fetchData()).to.be.rejectedWith(Error); }); // 或者用 async/await it(应该拒绝, async () { await expect(fetchData()).to.be.rejectedWith(Error); });这里依赖rejectedWith这类针对 Promise 的断言它们不是 Chai 核心自带的需要额外的插件提供。装完插件记得在初始化文件里注册顺序放在核心配置之后即可。另一类问题是断言写在了回调里但回调永远不触发。比如验证某个事件监听器被调用用例里setTimeout里做断言超时了但没抛错。这类问题的排查思路是给用例加一个明确的失败兜底比如在setTimeout里先done(new Error(未触发))再在真正回调里done()。看似笨但比“用例绿着但逻辑是坏的”安全太多。我的经验是只要用例里出现 Promise、定时器、事件监听就必须显式确认执行路径要么返回 Promise要么用 done 回调两条路选一条别混着来。混着来的结果往往是 done 被调用两次或者永远不调用报错信息还很难懂。6.3 深度比较的边界别假设它万能deep.equal好用但它不是万能的几个边界情况需要心里有数// Map 和 Set 的深度比较行为随版本变化建议显式转成数组再比 expect([...map.entries()]).to.deep.equal([[a, 1]]); // 类实例的深度比较会带上原型信息普通对象和实例通常不相等 expect(new Foo(1)).to.not.deep.equal({ value: 1 }); // 函数属性参与比较时引用不同就不相等 expect({ fn: () {} }).to.not.deep.equal({ fn: () {} });实际写用例时我倾向于把被测对象先规整成简单结构再断言比如把 Map 转成数组、把类实例转成普通对象、把 Date 转成时间戳。这样断言结果稳定跨版本升级也不会突然挂掉。这个习惯养成之后因为深度比较踩坑的次数几乎降到零。还有一个细节deep.equal遇到循环引用会直接抛错如果你的数据结构里存在自引用断言前要先处理掉。这一点在验证树形结构、图结构时特别容易撞上处理方式一般是断言关键路径上的字段而不是整个对象。另外补充一个实用技巧关于失败信息里的差异对比。开启showDiff之后失败时会输出期望值和实际值的差异但差异展示对深层嵌套的对象帮助有限。我的做法是把断言拆细一个大对象拆成三五个针对关键字段的断言哪个字段出问题一眼就能看到而不是对着一坨嵌套差异慢慢找。7. 我在这套技术栈上的几个实际体会写插件这件事我从“不敢碰”到“随手就写”花了大概两个项目的时间中间最大的认知转变是Chai 的插件不是黑魔法它就是往一个对象上挂函数。搞清楚this._obj是被测值、this.assert处理取反和消息、utils.flag存内部状态这三件事剩下全是普通 JS 逻辑。另一个体会是关于消息模板的。一开始我写插件图省事失败消息就写一句金额不在范围内结果线上排查时完全不够用还得回头加日志。后来养成习惯每条自定义断言的消息里都带上期望值和实际值多打几个#{exp}#{act}成本几乎为零收益是排查时间能砍一半。还有个纯经验的东西插件别写太多。我曾经在项目里一口气封了十几个自定义断言结果半年后新人接手看到满屏不认识的链式方法第一反应是“这什么鬼”然后绕开不用。后来我砍到只留四五个真正高频的其余全部退回用内置断言组合可读性反而更好。自定义断言的判断标准是“这条断言在项目里出现了十次以上且每次都写三行以上”达不到这个标准就别封老老实实写组合断言行别人一眼能看懂比优雅重要。