老柯修机铺

通讯行老师傅的手机软件笔记——每款 App 装过、用过,才敢写下来

Chai 断言链拆解:一个测试库如何把 DSL 做到丝滑

项目内容
软件Chai
版本0.4.339
平台Android APK
体积77.11M
分类通讯社交
包名com.Beauchamp.Messenger.external
官网Chai 官网
下载一Chai 下载,Chai 下载
下载二Chai 下载,Chai 下载
文档Chai 文档
介绍Chai 作为 Node.js 生态最流行的断言库,靠一条可组合的语言链站稳脚跟。本文从链式 getter 到插件扩展,拆解其设计原理与取舍。

Chai 是什么,它解决了什么问题

我是个常年和 Node.js 测试打交道的开发者。在写单元测试时,断言语句是逃不开的骨架——你总得说清楚「结果应该等于什么」「应该抛什么异常」「数组里应该有什么元素」。

Chai 就是干这个的。它不是测试运行器(那是 Mocha、Jest、Vitest 的活),也不是 mock 框架(Sinon 的地盘)——它是纯粹的断言库:给你一套方法,让你以接近自然语言的方式表达对值的期望。比如你写 expect([1,2,3]).to.have.members([3,2,1]),意思就是「这个数组应该包含这三个元素,顺序无所谓」。

Chai 聊天主界面

听起来简单。但仔细想一下:为什么一个断言库能从 2012 年火到现在?Node.js 生态换了一茬又一茬测试工具,Chai 却始终没被淘汰——它的设计里藏着什么不为人知的巧思?

让我从一个问题开始:同样是断言,为什么不直接写 assert.equal(a, b),非要搞一条链式调用?

链式语言链:用 getter 属性拼出 DSL

Chai 的核心设计叫「语言链」(Language Chain)。看这句:

expect([1, 2, 3]).to.have.deep.members([3, 2, 1]);

.to、.have、.deep 这些词在 JavaScript 里本来什么都不做——但在 Chai 里,它们是有副作用的 getter 属性。每访问一个属性,Chai 内部就给当前断言叠加一层修饰状态,最后等你真正调用 .members() 这个断言方法时,所有修饰词一次性生效。

这就是 Chai 的秘密:它利用了 JavaScript 对象属性访问是同步的这一特性,把每一次属性读取变成了一次状态标记。.deep 标记接下来的比较要用深度相等,.not 标记接下来的断言要取反,.ordered 标记数组成员匹配时要检查顺序。修饰词可以自由组合,顺序不敏感——.to.deep.equal(x) 和 .deep.to.equal(x) 完全等价。

我喜欢把这个机制比作咖啡点单系统。你走进咖啡店说「一杯大杯、去冰、燕麦奶的拿铁」——大杯、去冰、燕麦奶是修饰词,拿铁是最终的饮品。Chai 的语言链就是这个逻辑:修饰词随便加,最后必须落到一个真正的断言方法上。而且修饰词可以叠加——.not.deep.include() 就是「不深度包含」,两层修饰同时生效。

这套设计的好处是可扩展性极强。官方内置了 .deep、.not、.any、.all、.ordered 这些修饰词,但第三方插件也能往链上挂自己的词。比如 chai-as-promised 插件加了 .eventually——意思是「等这个 Promise resolve 之后再做断言」,于是你可以写 await expect(asyncOp()).to.eventually.equal(42),自然得像说人话。

三种入口,一个内核

Chai 提供了三种断言风格:expect、should、assert。它们的底层完全不同,但共享同一套断言引擎。

expect 是函数调用风格——你把值传进去,然后链式调用。这是最主流的写法,也是 chai-http、chai-as-promised 等插件的默认搭配。它的好处是可以断言任何值,包括 null 和 undefined,而且不会污染任何原型。

should 风格更激进——它直接往 Object.prototype 上挂了一个 should getter。一旦你执行了 require('chai').should()(注意那个执行括号),所有对象都多了个 .should 属性。于是你可以写 foo.should.have.property('name'),主语在前,读起来更像英语。

但 should 风格有个硬伤:null 和 undefined 没有原型链,所以 null.should.be.null 会直接 TypeError。这不是 bug,是 JavaScript 语言本身的限制。Chai 的解决方案是提供 should.be.null(null) 这种助手函数,遇到 null/undefined 时手动调用。另外,对象字面量用 should 风格时必须加括号——({ name: 'Chai' }).should.have.property('name')——因为 JS 把 { 开头的表达式当成了代码块。

assert 风格最传统,就是一系列函数调用:assert.equal(a, b)、assert.isArray(arr)。它没有语言链,也不做 DSL,就是老老实实的函数。适合从 Node.js 内置 assert 模块迁移过来的老项目。

三种风格看起来是三套 API,实际共享同一套核心断言实现。.deep、.members、.throw 这些能力在三种风格下都能用,只是入口不同。Chai 的开发者故意把内核和入口解耦了——这让维护成本降了一半。

插件机制:扩展点设计的教科书

Chai 本身只提供最基础的断言能力。HTTP 接口测试、异步 Promise 断言、JSON Schema 校验——这些全是第三方插件做的。官方最常用的几个插件是 chai-http(HTTP 测试)和 chai-as-promised(异步断言)。

插件的接入方式极简:chai.use(plugin) 一行搞定。内部实现上,Chai 给每个插件开放了三个扩展点:往语言链上挂新的 getter、注册新的断言方法、覆盖已有的断言逻辑。

chai-http 是个好例子。它在语言链上加了 .request() 方法,同时注册了 .status()、.header()、.cookie() 这些针对 HTTP 响应的断言。更巧的是它能自动管理 Express/Koa 应用的生命周期——你直接 chai.request(app).get('/health'),它会自动找空闲端口启动应用、发请求、测完自动关掉服务器,整个过程对测试透明。如果多个请求要打到同一个服务器(比如先登录再访问资料),用 .keepOpen() 保持服务器存活,测完手动 .close() 清理。

这种扩展点设计让 Chai 的生态活得很好。核心库保持轻量(零依赖),功能由社区按需补上——你只写纯 Node.js 逻辑就不要装 chai-http,只测同步函数就不要装 chai-as-promised。

Chai AI 角色对话界面

异步:Chai 不管,运行器管

这是 Chai 设计中最容易被忽视、但最能体现设计哲学的一点:Chai 本身不处理异步等待。

所有 Chai 断言都是同步执行的。它不会自动等 Promise resolve,也不会自动等回调触发。异步等待的职责完全交给测试运行器——Mocha、Jest、Vitest 这些。

三种异步模式:回调 done、return Promise、async/await。核心逻辑都是一样的——测试运行器需要知道「这个测试还没完,等它」。function (done) 有参数时 Mocha 判定为异步、return Promise 时 Mocha 自动等待、async function 本身返回 Promise。Chai 只负责在「已经有结果」的前提下做断言。

chai-as-promised 插件在此基础上加了 .eventually 修饰词——它返回一个 Promise,断言的执行时机被推迟到原始 Promise resolve 之后。但即便如此,你仍然需要 await 或 return 这个断言,否则测试运行器不知道要等。忘记 await/return 是 chai-as-promised 最常见的坑——断言静默通过了,因为它根本没执行。

这是一个典型的职责分离设计。Chai 专注做断言表达,不碰异步调度。好处是核心库极简、逻辑清晰;代价是用户必须同时理解两套机制——Chai 的语言链和运行器的异步模式。

取舍:这套设计的代价

没有完美的设计。Chai 的语言链机制也有明显的短板。

最常见的踩坑是忘记 .deep。Chai 默认用 === 严格相等,对象比较的是引用而非值。expect({ a: 1 }).to.equal({ a: 1 }) 会直接失败——两个字面量是不同的对象。必须加 .deep 或用 .eql() 简写。这不是 bug,是 JavaScript 的特性,但几乎所有 Chai 用户都踩过一次。

should 风格的原型污染风险也一直存在。它往 Object.prototype 上挂 getter,意味着全局所有对象都多了个 .should 属性。如果你的代码里有遍历对象键值的逻辑(for...in、Object.keys),可能会意外扫到这个属性。虽然可以用 Object.defineProperty 的 enumerable: false 规避,但某些旧环境下还是会出问题。

链式调用过长时,可读性和 lint 都会出问题。expect(obj).to.have.deep.property('user.profile.settings.theme').that.equals('dark') 这一行,ESLint 的 max-len 大概率会报红,而且出了错很难定位到底是哪一级修饰词的问题。官方建议是拆成多句断言——虽然啰嗦一点,但每句的意图清晰得多。

.not 的过度使用也是个反模式。expect(res.status).to.not.equal(404) 会让 500、302 全部通过——你以为在测「不是 404」,实际测的是「任何不是 404 的状态码」。正确的写法是精确断言 expect(res.status).to.equal(200)——否定断言太宽泛,容易掩盖真正的 bug。

怎么判断该用不该用

如果你在写 Node.js 后端、CLI 工具、任何有单元测试的 JavaScript/TypeScript 项目,Chai 大概率值得一试。它的语言链设计让断言读起来像自然语言,插件生态覆盖了 HTTP、异步、JSON Schema、DOM 几乎所有常见场景。

但有几个场景不太适合。纯前端项目里,Jest 和 Vitest 自带的 expect 已经够用了,没必要再引入一套断言库。如果你需要强类型提示,Chai 的类型定义不如 Vitest 的原生 expect 完善——链式调用在 TypeScript 里有时会出现类型推断不准的问题。另外,should 风格在 ESM 环境下有过一些兼容性问题,推荐用 expect 风格作为主力。

回到我开头的问题:为什么 Chai 能活十几年?因为它在最核心的事情上做得足够好——把断言这件事从「调用函数传参数」变成了「用自然语言表达期望」。语言链机制是这个转变的关键,插件机制让它不至于僵化,职责分离的设计让核心库保持了极简。它不是最炫的测试工具,但它是最稳的那个。

本文为个人基于网络公开资料整理编写,非官方内容,仅供学习交流参考;软件版权归原作者所有,请支持正版;使用过程中遇到问题请查阅官方渠道,本文不承担任何责任。

同类文章

Moodly 角色自訂與對話訓練完整實戰

這篇教程帶你從零開始在 Moodly 裡建立一個真正懂你說話節奏的 AI 搭子,涵蓋角色挑選、說話風格微調到記憶積累的全流程。