☰
Chai断言库实战:三种风格、异步断言与插件开发避坑指南
2026/10/29 21:07:57 网站建设 项目流程

凌晨一点多,CI 流水线又红了,日志拉下来只有孤零零一行:AssertionError: expected undefined to be true。既没有上下文,也没有实际值对比,翻到测试代码才发现,有人写了一句expect(res.ok).to.be.true,而res本身就是undefined。这类问题的根子往往不在业务逻辑,而在断言库的用法本身——Chai 断言库把「可读性」做到了极致,但它的链式语法、三种接口风格、以及插件机制,恰恰是新手最容易踩偏的地方。这篇内容把 Chai 从安装到插件开发整条链路拆开讲:三种断言风格(expect、should、assert)到底怎么选,语言链和 flag 是怎么执行的,异步断言怎么写才不假通过,以及怎么从零手写一个能直接落到业务项目里的断言插件。适合已经写过一些测试、但被断言报错卡住过的同学,也适合想把测试基础设施做规整的团队参考。

1. 断言库在测试链路里的真实位置

1.1 断言库和测试运行器不是一回事

很多人一开始会把这两个东西混为一谈,觉得「装了测试框架就有断言了」。实际上运行器负责的是发现测试文件、组织用例、执行生命周期钩子、统计结果、输出报告;断言库只负责一件事——判断某个值是不是符合预期,不符合就抛出一个带信息的错误。Mocha 是典型的运行器,它本身不带断言能力,你必须自己接一个断言库;Jest 则是把运行器、断言、mock、覆盖率全打包了。Chai 的定位很清楚:它只做断言,其他一概不管,所以它才能同时挂在 Mocha、Vitest、Karma、甚至自己写的简易 runner 上。

这个分工带来一个很实际的后果——断言失败时的错误信息质量,完全取决于断言库,跟运行器没关系。运行器只是把抛出来的AssertionError捕获、染色、打印。所以你看到expected 1 to be 2这种干巴巴的消息,怪不到 Mocha 头上,得回头改断言写法或者换一个信息更丰富的扩展方法。理解这一点,后面看错误日志才有方向感。

1.2 Chai 今天还值不值得用

说实话,现在新起一个前端项目,直接上 Jest 或者 Vitest 内置的expect是最省事的,Chai 的可见度确实不如几年前。但它在几个场景里依然很难被替代。第一是 Node 侧的老项目,Mocha + Chai 的组合沉淀了大量用例,迁移成本远大于收益;第二是需要精细控制断言行为的场景,Chai 的 flag 体系(后面会讲)能让你用一行链式表达出很复杂的比较逻辑;第三是插件生态,Chai 的插件机制设计得非常轻,一个几十行的文件就能把团队内部的业务校验逻辑封装成可读的断言,这一点内置断言库反而不好做。

还有一个容易被忽略的点:Chai 的断言是可组合的中间件式结构,overwriteMethod允许你在不改变调用方代码的前提下,悄悄增强某个断言的实现。这种能力在需要统一处理字符串归一化、时间容差、浮点精度的团队里非常好用。所以与其说 Chai 过时了,不如说它的价值区间从「通用默认选项」收敛到了「需要定制断言层的项目」。

1.3 三种风格先给个结论

先把选型结论摆出来,后面再逐个拆细节。三种风格不是互斥的,同一个项目里可以共存,但最好有一个主次。

风格调用形态适合的团队习惯主要代价
expectexpect(v).to.equal(1)绝大多数场景,推荐作为默认需要导入,句子长
shouldv.should.equal(1)追求最短表达、老项目污染原型,null 直接报错
assertassert.equal(v, 1)偏 TDD、喜欢函数式直白无链式,可读性靠命名

我给的建议很明确:新代码一律用 expect 打底,should 只在历史代码里保留,assert 用于那些「断言逻辑极其简单、不需要链式修饰」的场合。下面几节会把每个理由讲透。

2. 装依赖这一步的三个岔路口

2.1 Chai 5 与 4.x:模块体系决定你能不能 require

装 Chai 的命令本身没有任何技术含量:

npm install --save-dev chai

但麻烦从require那一刻就开始了。Chai 5 是一个纯 ESM 包,你在 CommonJS 文件里写const { expect } = require('chai'),会直接收到ERR_REQUIRE_ESM。这不是 Chai 特有的问题,但它是最早一批全面转向 ESM 的常用库之一,很多人第一次撞上这个错就是在这里。

三条路可选:一是把项目整体切到 ESM,package.json里加"type": "module",测试文件全部用import;二是运行时支持 ESM 加载器,或者用构建工具先转译测试代码;三是最省事的——锁在chai@4上,那个版本同时提供 CJS 和 ESM 两个入口,require和import都能跑。

// Chai 4.x,CommonJS 环境下可以直接 require const { expect, assert } = require('chai'); const should = require('chai').should();
// Chai 5.x,ESM 环境下 import { expect, assert, should, use } from 'chai';

这里有个细节值得提醒:should不是一个现成的对象,它是一个函数,必须调用一次才会把 getter 挂到Object.prototype上。写成import { should } from 'chai'之后就放在那儿不用,运行时会提示should is not a function或者断言找不到。正确姿势是导入后立刻执行should()。

2.2 TypeScript 项目的类型与断言签名

TypeScript 项目里,Chai 4.x 时期类型定义靠@types/chai单独安装,Chai 5 开始自带类型,装完包里就有.d.ts。这一步不出错,但插件相关的地方经常出问题——chai-as-promised、自定义插件都会往Assertion接口上挂方法,而 TypeScript 并不知道这些方法存在,编辑器里一片红色波浪线。

解决方式是模块声明合并,在项目里放一个types/chai.d.ts:

declare global { namespace Chai { interface Assertion { withinDays(days: number, reference?: number | Date): Assertion; even: Assertion; } } } export {};

注意这里必须放在declare global里,并且文件末尾要有export {}让它变成一个模块,否则声明不会生效。这个坑踩过一次就记住了:不加export {},TypeScript 会把它当成全局脚本文件处理,namespace Chai的合并目标找不到,声明静默失效,但编译器不报错,你只会看到编辑器依然标红。

另外@types/chai和包内自带的类型不要同时存在,两套声明会打架,出现「不能重复声明属性」之类的错误。检查方式很简单,看node_modules/@types目录里有没有 chai 文件夹,有就卸掉。

2.3 和不同测试运行器拼装时的注意点

Chai 和 Mocha 是经典搭配,Mocha 的默认报告器能正确识别AssertionError并染色显示+ expected - actual的差异块,前提是断言抛的确实是AssertionError而不是普通的Error。如果你在插件里手写throw new Error(...),报告器不会给你算作断言失败,某些配置下还会导致整个测试文件中断而不是单条用例失败。这个区别在写自定义插件时特别重要。

和 Vitest 拼装时要注意,Vitest 自己有expect,全局注入的expect会覆盖你导入的 Chai 版本,两者同名但接口不同。要么在配置里关掉 globals,要么用别名导入import { expect as chaiExpect } from 'chai'。别小看这个命名冲突,症状是用例跑起来报chaiExpect(...).toBe is not a function,看到toBe就知道是拿错了对象——Chai 里对应的方法叫equal。

浏览器端跑 Karma 或者直接<script>引入的情况现在少了,但如果真要做,Chai 提供了 UMD 构建,挂到window.chai上,路径是包内的chai.js。用的时候记得const { expect } = window.chai;。

3. expect 风格:那串英文句子到底怎么执行

3.1 语言链是一组空转的 getter

expect(user).to.be.a('object').that.has.property('id').that.is.a('string')这行代码读起来像英语,很多人以为 Chai 在做语法解析,其实完全不是。to、be、been、is、that、which、and、has、have、with、at、of、same、but、does、still、also这些词,全部是定义在断言原型上的 getter,每个 getter 内部只做一件事——return this。

也就是说,它们对断言结果没有任何影响,纯粹是为了让代码读起来连贯。这解释了为什么expect(x).to.be.be.be.be.equal(1)也能跑通,虽然看起来像绕口令。理解这一点之后,你写断言时就不会纠结「这里到底该用 that 还是 which」了,随便挑一个读着顺的就行,不影响行为。

反过来说,deep、nested、own、any、all、ordered这些词看起来也是链式的一部分,但它们不是空转的,它们会往当前 assertion 实例上写状态标记。这就是下一小节要讲的东西。

3.2 flag 才是改变断言行为的开关

Chai 内部用一套叫 flag 的机制存储断言状态,本质是挂在 assertion 对象上的键值对。deep这个 getter 执行时,实际做的是flag(this, 'deep', true)。等到真正执行equal时,equal的实现会先读一次flag(this, 'deep'),为真就走深度比较逻辑,否则走===。

这套设计的好处是状态可以层层叠加,expect(a).to.deep.own.include(b)里 deep、own 各自打上标记,最终include一次性读到全部状态,决定用哪种比较策略。坏处是状态是隐式的,不看源码根本不知道哪个词在起作用,出问题时容易怀疑人生。

几个高频 flag 的实际效果我列一下,这些都是实测出来的:

flag 写法实际作用典型误用
.deep切换到深度比较,递归比较结构以为对任何断言都生效,其实只影响支持 deep 的方法
.nestedproperty 断言改为按路径取值用在数组下标上容易拿不到值
.own只检查对象自有属性,忽略原型链忘了它只管 property,不影响 equal
.anykeys 断言中只要命中一个键就通过与.all混用会互相覆盖
.orderedmembers 断言要求顺序一致对 Set 之类的无序结构没意义
.negate由.not触发,翻转最终判断链式太长时容易放错位置

.nested这个 flag 值得单独说。expect(obj).to.have.nested.property('a.b.c')会按点号拆路径去取值,等价于obj.a.b.c。但如果你要检查的键名本身带点(比如某个配置项字面量就叫"a.b"),nested 就会误判。这时候要么改用expect(obj['a.b']),要么用.own.property配合方括号访问,别硬套 nested。

3.3 常用断言写法对照与易错点

这一块是日常写得最多的,我按「意图 → 推荐写法 → 坑」整理成表,可以直接当速查卡用。

意图推荐写法容易踩的点
严格相等expect(a).to.equal(b)NaN和自身不相等,要用.to.be.NaN
深度相等expect(a).to.deep.equal(b)原型参与比较,实例和字面量对象不相等
存在性expect(a).to.exist0、''、false都算存在,这个是对的
真假值expect(a).to.be.ok0和空串会失败,业务里慎用
抛错expect(fn).to.throw(TypeError, /bad/)传类引用不是实例,传new TypeError()无效
数组包含expect(arr).to.include(item)对象比较是子集语义,不是全等
对象包含expect(obj).to.include({ a: 1 })嵌套对象需要配合.deep.include
长度expect(arr).to.have.lengthOf(3).length已废弃,别再用
键集合expect(obj).to.have.all.keys('a', 'b')少一个键就失败,顺序无关
成员集合expect(arr).to.have.members([1, 2])默认顺序无关,加.ordered才比较顺序

关于deep.equal的原型问题,值得展开说一句。Chai 用的深度比较实现会检查两边对象的原型是否一致,所以class User {}创建的实例,deep.equal({ name: 'x' })是肯定不过的,哪怕字段完全一样。这不是 bug,是设计选择——它认为「类型不同就不该相等」。要绕过就去比较序列化后的普通对象,比如expect(JSON.parse(JSON.stringify(user))).to.deep.equal({...}),但这样会丢掉undefined字段和 Date 类型,得权衡。

还有一个使用频率很高但容易写错的:expect(fn).to.throw()系列。第一个参数既可以传构造函数,也可以传字符串、正则、或者错误消息片段,但传构造函数时必须是类本身,不能是实例。expect(fn).to.throw(new TypeError('x'))会被当成「期望抛出这个实例」处理,结果永远不通过,而报错信息又很模糊,容易卡很久。

4. should 与 assert:把适用边界划清楚

4.1 should 拿原型换可读性,代价在哪

should 风格的卖点是把断言写成user.name.should.equal('x'),读起来最像自然语言,而且不需要expect前缀,代码更短。它的实现原理是给Object.prototype加一个should的 getter,任何对象访问.should时自动包装成 assertion 实例。

这个设计在 2015 年前后非常流行,现在看问题不少。最直接的是它必须修改内建原型,虽然 Chai 用了Object.defineProperty加enumerable: false来降低影响,但这是全局副作用,在某些沙箱环境、被冻结的对象、以及Object.freeze(Object.prototype)的场景下会直接失效。如果你的项目里还有别的库也在往原型上挂东西,冲突风险是实打实的。

更常见的崩溃点是null和undefined。(null).should根本执行不到 getter,浏览器和 Node 都会在属性访问这一步抛Cannot read properties of null。所以 should 风格的代码里,只要断言对象可能为空,就必须先判空:

// 错误示范:data 为 null 时直接抛 TypeError,报错信息毫无参考价值 data.should.have.property('id'); // 正确做法 const should = require('chai').should(); expect(data).to.exist; data.should.have.property('id');

我的实际体会是:should 适合那种「对象一定存在,只想验证字段」的窄场景,而且项目里最好统一规定——只在断言链的起点用一次,中间全部改用 expect。混着用虽然语法上没问题,但读代码的人要不停切换心智模型,收益远小于成本。

4.2 assert 风格的直白和局限

assert 是最朴素的函数式接口,assert.equal(actual, expected)、assert.isTrue(v)、assert.deepEqual(a, b),一眼能看出在比什么,没有链式语法的心智负担。它特别适合两种人:一种是刚接触测试、还没建立链式语法直觉的新人;另一种是写工具函数、需要断言逻辑极其确定的场景。

局限也很明显——没有链式,就没法表达「深度且包含且有序」这种复合约束。比如要验证数组包含某个元素且不考虑顺序,assert 风格只能写assert.include(arr, item),遇到复杂的嵌套结构就没辙了,得自己写辅助函数。另外 assert 系列的错误消息相对固定,不像 expect 可以自定义模板。

还有一个历史包袱值得一提:assert的命名和 Node 内置的assert模块重名,如果文件里同时require('assert')和require('chai').assert,后引入的会覆盖前者,出错时非常隐蔽。建议在需要 Node 内置模块的场合,要么重命名导入(const nodeAssert = require('node:assert')),要么干脆只保留一个来源。

4.3 一个仓库里怎么调度三种风格

现实中同一个仓库三种风格并存是常态,因为代码是不同时期、不同人写的。硬性统一成一种会有巨大的改造成本,我的做法是定一条「新增代码规则」,存量代码按模块逐步收敛。

具体规则是:所有新写的测试文件用 expect;utils或helpers里那些一次性的、只做简单比较的校验用 assert;should 只在已经用了 should 的文件里继续用,不去扩散。另外在 lint 层面加一条约束,禁止在新文件里导入should,靠工具而不是靠人自觉。

// .eslintrc 片段,思路是限制 should 的导入路径 { "rules": { "no-restricted-imports": ["error", { "paths": [{ "name": "chai/register-should", "message": "新代码请使用 expect 风格" }] }] } }

这样做的理由很实际:expect 是三种里唯一既能覆盖全部断言能力、又不引入全局副作用的方案。should 的短是短,但代价在原型;assert 简单但表达力受限。把 expect 定为基准,另外两种作为局部选择,长期看维护成本最低。

5. 异步断言:Promise 进入断言链之后

5.1 chai-as-promised 补的到底是什么

原生的 Chai 是同步的,expect(promise).to.equal(1)比较的是一个 Promise 对象和数字 1,永远失败。异步代码测试要么手动await之后再断言,要么接一个插件把 await 能力做进链式语法里,chai-as-promised就是干这个的。

import { use, expect } from 'chai'; import chaiAsPromised from 'chai-as-promised'; use(chaiAsPromised); // 断言最终解析值 await expect(fetchUser('u1')).to.eventually.have.property('id'); // 断言拒绝,并检查错误类型和消息 await expect(doWork()).to.be.rejectedWith(ValidationError, /手机号格式/); // 断言拒绝,不关心具体内容 await expect(doWork()).to.be.rejected; // 断言正常完成 await expect(doWork()).to.be.fulfilled;

.eventually的作用是把后续整条断言链推迟到 Promise 落定之后执行,.rejected/.rejectedWith/.fulfilled则是直接对 Promise 状态做判断。这几个词本质上也是往 assertion 上打 flag,让最终的断言在执行前先await一次。

5.2 rejectedWith 的信息阅读方式

.rejectedWith的失败信息经常让人看不懂,因为它同时涉及三个维度:是否拒绝、错误类型是否匹配、消息是否匹配。实际报错长这样:

AssertionError: expected promise to be rejected with an error including '手机号格式' but got '请求超时'

这句话把三层信息压缩在一行里,读法是从后往前看——先看but got后面的实际值,确认到底有没有拒绝、拒绝的是什么;再看前面的期望条件,判断是哪一层没对上。如果实际值是undefined,说明 Promise 根本没拒绝,问题在业务逻辑不在断言;如果实际值是一个完整的错误消息,说明拒绝了但内容不符,这时候要看是不是包装了一层错误。

还有一个高频错误:把rejectedWith的参数写成错误实例。rejectedWith(new Error('x'))不会按预期工作,正确的是传构造函数加正则/字符串匹配。这个限制和同步版本to.throw的逻辑是一致的,理解了throw就理解了它。

5.3 超时、定时器和避免假异步

异步断言最容易出的问题是「假通过」——断言链根本没被 await。写了expect(p).to.eventually.equal(1)但没await也没return,测试函数直接结束,运行器认为通过,实际上断言可能压根没执行完。

这类问题排查起来很费劲,因为 CI 是绿的,直到某次业务变更导致 Promise 拒绝,你才发现断言从来没生效过。防护手段有两个:一是 ESLint 规则no-floating-promises,它能把未处理的 Promise 标出来;二是习惯上把异步断言写成return expect(...).to.eventually...,让测试函数直接返回那个 Promise,Mocha 会等它。

超时控制这块,chai-as-promised 本身不提供超时参数,超时由运行器管。Mocha 里用this.timeout(5000),注意不能用箭头函数,因为箭头函数没有自己的this。如果被测逻辑本身有定时器,测试里最好配合假时钟,把真实等待时间压缩到毫秒级,否则测试套件跑起来会非常慢。

6. 写一个插件:断言扩展的四种入口

6.1 插件骨架与注册时机

Chai 插件的本质是一个函数,签名是function (chai, utils) { ... },内部往chai.Assertion上挂方法。注册用chai.use(plugin),这一步会立即执行插件函数。最小骨架如下:

import * as chai from 'chai'; export default function myPlugin(chaiInstance, utils) { const { flag, getMessage } = utils; chaiInstance.Assertion.addMethod('isPositive', function () { const actual = flag(this, 'object'); this.assert( typeof actual === 'number' && actual > 0, 'expected #{this} to be a positive number', 'expected #{this} not to be a positive number' ); }); }

注册时机很关键。chai.use必须在使用断言之前执行,而且同一个插件函数被多次 use 会叠加效果——overwriteMethod每次 use 都会再包一层,消息越来越长,行为越来越怪。稳妥的做法是把所有use集中在一个test/setup.js里,通过运行器的--require或setupFiles配置全局加载一次,而不是每个测试文件各写一遍。

6.2 addMethod 与 addProperty 的分工

addMethod挂的是方法,调用时需要括号,适合带参数的断言;addProperty挂的是 getter,调用时不带括号,适合表达「某物具有某性质」。两者的选择标准很简单——如果需要传参就用 method,如果是「是不是」这种二元性质就用 property。

// property 版:expect(4).to.be.even chai.Assertion.addProperty('even', function () { const actual = flag(this, 'object'); this.assert( typeof actual === 'number' && actual % 2 === 0, 'expected #{this} to be an even number', 'expected #{this} not to be an even number' ); });

这里有个细节很多人没搞清:property 里用this.assert完全能跑,失败消息也最干净。但如果你希望属性后面还能继续挂链式且复用同一个 assertion 实例,比如expect(4).to.be.even.and.below(10),就需要在需要断链的位置包一层new chai.Assertion(...)。多数场景下直接this.assert就够了,别为了「看起来更规范」而无谓增加复杂度。

6.3 addChainableMethod 与 overwriteMethod 的使用场景

addChainableMethod是 method 和 property 的结合体,它同时注册一个可调用的方法和一个可读的属性,让同一个语义既能expect(str).to.longerThan(3)也能expect(str).to.be.longerThan。实现时第三个参数是链式行为函数,可以省略。

chai.Assertion.addChainableMethod('longerThan', function (n) { const actual = flag(this, 'object'); this.assert( typeof actual === 'string' && actual.length > n, 'expected #{this} to be longer than #{exp} characters', 'expected #{this} not to be longer than #{exp} characters', n, actual.length ); });

注意this.assert的参数顺序是(表达式, 正向消息, 反向消息, expected, actual),最后两个参数用来填充#{exp}和#{act}占位符。很多人把 expected 和 actual 写反,导致失败消息里数字对调,排查时会被误导很久。

overwriteMethod用于增强已有断言,而不是新增。它的回调收到一个_super参数,代表原实现,你可以选择性地调用它。

chai.Assertion.overwriteMethod('equal', function (_super) { return function (value) { const actual = flag(this, 'object'); if (typeof actual === 'string' && typeof value === 'string') { // 归一化空白后再走原比较逻辑 flag(this, 'object', actual.replace(/\s+/g, ' ').trim()); return _super.call(this, value.replace(/\s+/g, ' ').trim()); } return _super.apply(this, arguments); }; });

这段代码有个副作用必须提前知道:修改flag(this, 'object')会影响后续所有依赖该 flag 的断言和消息渲染,失败时打印出来的#{this}是归一化之后的值,跟原始值不同。如果这个差异会让排查变难,就别改 object,改成在插件内部新建一个 assertion 做比较。这个取舍我在两个项目里都试过,结论是「如果归一化规则简单且可预测,改了也就改了;规则复杂就老老实实新建实例」。

6.4 utils 三件套:flag / getMessage / transferFlags

flag前面讲得够多了,读写 assertion 状态用。getMessage用来渲染消息模板,支持#{this}(当前对象)、#{exp}(期望值)、#{act}(实际值)三个内置占位符,还能读自定义 flag。

// 自定义占位符示例 flag(this, 'expectedField', 'id'); const msg = getMessage(this, '#{this} 缺少字段 #{expectedField}');

transferFlags用于把一个 assertion 的状态转移到另一个上,典型场景是插件内部要新建 assertion 做嵌套比较,但希望保留deep、negate这些外部状态:

const nested = new chai.Assertion(someValue); utils.transferFlags(this, nested, false); nested.to.equal(other);

第三个参数控制是否转移全部 flag,传false表示排除object之外的部分按需处理。这块的实际用处不如前两个大,但写复杂插件时会用到。

6.5 完整示例:一个业务字段断言插件

把上面的东西攒起来,写一个实际能用的小插件——断言一个值是一个合法日期,并且距离参考时间在指定天数内。

import * as chai from 'chai'; const DAY = 24 * 60 * 60 * 1000; function toTimestamp(v) { if (v instanceof Date) return v.getTime(); if (typeof v === 'string' || typeof v === 'number') return new Date(v).getTime(); return NaN; } export default function dateRangePlugin(chaiInstance, utils) { const { flag } = utils; chaiInstance.Assertion.addMethod('withinDays', function (days, reference = Date.now()) { const actual = flag(this, 'object'); const ts = toTimestamp(actual); const ref = toTimestamp(reference); const ok = !Number.isNaN(ts) && !Number.isNaN(ref) && Math.abs(ts - ref) <= days * DAY; this.assert( ok, 'expected #{this} to be within #{exp} days of ' + new Date(ref).toISOString(), 'expected #{this} not to be within #{exp} days of ' + new Date(ref).toISOString(), days, actual ); }); chaiInstance.Assertion.addProperty('validDate', function () { const actual = flag(this, 'object'); this.assert( !Number.isNaN(toTimestamp(actual)), 'expected #{this} to be a valid date', 'expected #{this} not to be a valid date' ); }); }

用起来是这样的:

use(dateRangePlugin); await expect(createOrder()).to.eventually.satisfy((o) => { expect(o.createdAt).to.be.validDate; expect(o.createdAt).to.be.withinDays(1); return true; });

这里用.satisfy而不是直接链式,是因为eventually之后的链式只能对最终值做一次断言,要验多个字段就得拆成多次断言或者用satisfy包一层。这是 chai-as-promised 的一个常见组合套路,知道的人不多,但能省掉不少重复代码。

7. 排查链路:几种典型失败是怎么定位的

7.1 deep.equal 说对象不相等,但看起来一模一样

这类问题的排查顺序我固定成三步。第一步,把断言改成打印两边,用console.log(JSON.stringify(a), JSON.stringify(b)),如果序列化后完全一致,就基本确定是原型或者不可序列化的字段在作怪。第二步,检查原型,Object.getPrototypeOf(a) === Object.getPrototypeOf(b),最常见的场景是一边来自 ORM 返回的模型实例,一边是手写的字面量对象。第三步,检查特殊字段,Date、Map、Set、undefined值、NaN,这几个都是JSON.stringify会失真但深度比较会严格检查的类型。

定位到原因后,修法取决于你的意图。如果确实只关心数据内容,就用序列化后再比较,或者用chai-subset之类只比对子集。如果类型本身就有语义,那就该用instanceof单独断言一次,再对字段做深度比较,把「类型正确」和「内容正确」拆成两个断言,失败时一眼能看出是哪一层出问题。

7.2 should 碰到 null 直接炸

报错信息是TypeError: Cannot read properties of null (reading 'should'),堆栈指向断言那一行,看起来像是语法错误,实际就是空值访问原型失败。排查时先确认被测对象是不是可能为空,再决定是加判空前置断言还是整体换 expect。

// 反复出问题的写法 const res = await fetchSomething(); res.data.should.have.property('id'); // 改成显式判空 + expect expect(res.data).to.be.an('object'); expect(res.data).to.have.property('id');

这里有个更隐蔽的变种:对象存在但某个中间层是undefined,比如res.data.items.should.have.lengthOf(3),报错指向items的should,但根因是data.items没赋值。这类链式访问在 should 风格下特别容易掩盖真实的空值位置,建议在断言前先把中间值解构出来,断了再访问。

7.3 插件断言失败消息里冒出 undefined

自己写的插件,失败时消息是expected undefined to be ...,说明#{this}渲染出来的对象是空的。原因通常是flag(this, 'object')的调用时机不对,或者插件里用了箭头函数导致this不是 assertion 实例。

插件函数体里的方法必须用function () {},不能用箭头函数,因为this需要在调用时绑定到 assertion 上。这个错误特别隐蔽,因为插件注册时不会报错,只有断言执行时才出问题。检查方式很直接——在方法第一行打印this.constructor.name,正常应该看到Assertion,如果看到undefined或者别的,就是绑定问题。

还有一种情况是消息模板里用了不存在的占位符,比如写了#{expected}但实际应该是#{exp},Chai 会原样保留这串文本或者渲染成undefined,不会报错。写模板时对着文档核一遍占位符名称,能省掉不少调试时间。

7.4 重复 use 导致 overwrite 套娃

症状是断言失败的消息里出现了多段重复的期望描述,或者行为跟预期不符但代码看不出问题。根因是同一个插件被注册了多次,overwriteMethod每次都会在原有实现外面再包一层。

排查方式是打印已注册的插件数量,或者直接检查chai.use的调用点。修法是把注册集中到一个 setup 文件,并且给插件加一个幂等守卫:

export default function myPlugin(chaiInstance, utils) { if (chaiInstance.__myPluginLoaded) return; chaiInstance.__myPluginLoaded = true; // ... 注册逻辑 }

这种手写标记虽然不够优雅,但比依赖运行器保证「只加载一次」要可靠得多,尤其是测试文件并行加载、每个 worker 各自初始化的时候,运行器的保证经常不成立。

顺着这条排查链路走完,你会发现 Chai 的问题绝大多数不是它本身有 bug,而是状态隐式流转带来的理解偏差。flag 在哪一步被设置、在哪一步被读取、消息模板里的占位符对应哪个参数——把这三件事的先后顺序理清楚,八成的报错都能自己定位。

我个人在实际操作中的体会是,断言层的建设不要一步到位。先让 expect 成为团队默认,把异步断言的 await 写全,再考虑要不要写插件。插件一定是从「同一个断言写了三遍以上」的时候才开始抽象,过早封装成自定义方法,反而会让新人不认识断言在干什么,调试时多一层跳转。真到了要写插件那天,把this.assert的四个参数顺序背熟,比记住十个 API 名字都有用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询