ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Chai 断言库实战:三种断言风格、链式原理与自定义插件

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.strictEqual,Chai 让这两种人在同一个项目里各写各的,互不干扰。这一点听起来是小事,实际上是降低推行阻力的关键:强制所有人换风格,往往会引发一轮无谓的争论。

至于依赖体积,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 chai@4

装完之后第一件事是验证引用是否正常,我一般直接起一个最小的测试文件跑一遍,而不是等写完一堆用例才发现模块解析有问题:

// 老工程里验证 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 assert:TDD 风格,迁移成本最低

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、.uuid
addChainableMethod添加可链式方法既能.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.js

package.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的第二个函数是“访问时执行的行为”,很多时候留空即可。这块的复杂度主要来自使用者可能以各种顺序组合修饰符,我的建议是非必要不用addChainableMethod,addMethod加参数默认值就能覆盖绝大部分需求,实现和维护成本低得多。

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 或 assert
ESM 工程里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},成本几乎为零,收益是排查时间能砍一半。

还有个纯经验的东西:插件别写太多。我曾经在项目里一口气封了十几个自定义断言,结果半年后新人接手,看到满屏不认识的链式方法,第一反应是“这什么鬼”,然后绕开不用。后来我砍到只留四五个真正高频的,其余全部退回用内置断言组合,可读性反而更好。自定义断言的判断标准是“这条断言在项目里出现了十次以上,且每次都写三行以上”,达不到这个标准就别封,老老实实写组合断言行,别人一眼能看懂,比优雅重要。

RELATED READING

延伸阅读

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