资讯动态

Chai.js断言库实战指南:从expect到deep.equal的测试精讲

发布时间:2026/10/13 18:11:04 来源:尧图企业网站定制
1. 为什么断言库是测试代码的灵魂从一次排查恐惧说起我始终记得刚转行做前端时的一次经历。当时一个模块的单元测试全部爆红十几条失败用例堆在一起控制台里满屏的AssertionError [ERR_ASSERTION]: false true我只能挨个打开业务代码对着if条件反复推敲最后才发现是某个工具函数里一个边界值没处理。那次排查花费的时间比写业务代码还长。事后复盘时带我的前辈说了一句话你会在测试里写if (a ! b) throw new Error(not equal)为什么不用断言库 从那天起我意识到断言库才是测试代码真正的中枢神经。什么是断言一句话概括断言就是在测试代码里声明我认为某个值应该满足某种条件的语句。如果条件不满足断言库会抛出一个可读性极强的错误告诉你在哪个文件、哪一行、具体哪个值出了问题。很多人觉得测试框架本身就带断言比如 Node 内置的assert模块、Jest 自带的expect为什么还要单独引入 Chai.js这个问题我稍后展开但先给一个结论Chai.js 的价值不在于能不能断言而在于它的断言语句读起来接近自然语言报错信息也能直接定位到断言表达式本身这让整套测试代码的维护成本显著下降。Chai.js 是一个 BDD / TDD 双风格断言库可以单独使用也能与 Mocha、Jasmine 等测试运行器搭配。它不关心你怎么组织用例只负责一件事——把你的断言写得干净、优雅、一眼能看懂。这篇文章我会从三个核心话题展开三种断言风格的取舍、常用断言方法背后的判断逻辑、以及与主流测试框架的集成方式最后附上我在真实项目中踩过的坑和经验。如果你是刚开始接触前端测试的小白这篇文章足够让你把 Chai.js 用起来如果你已经写过不少测试我更希望你能从为什么断言发出的是这种错误信息的角度重新理解它。2. 环境安装与三种断言风格expect、should、assert 到底怎么选2.1 安装 Chai.js 的两种方式Chai.js 的安装没有太多神秘感。在 Node 项目里直接用 npm 或 yarn 安装即可npm install chai --save-dev如果你在浏览器环境里使用可以通过 CDN 引入构建好的脚本文件。以 4.x 版本为例script srchttps://cdn.jsdelivr.net/npm/chai4.3.6/chai.js/script引入后全局会挂载一个chai对象接下来就能在页面里使用断言了。不过在实际的前端工程里更常见的组合是测试运行器 Mocha 负责组织和执行用例Chai.js 负责断言。2.2 expect 风格最推荐的入门选择Chai.js 提供了三种断言接口第一种是expect它的用法是用expect(被断言的值)开启一个断言链然后通过.to、.be、.equal这类语言链和断言方法组合出完整的断言语句。const { expect } require(chai); describe(一个简单的加法函数, () { it(应该返回两个数字的和, () { const result add(1, 2); expect(result).to.equal(3); }); });如果你是第一次看到expect(result).to.equal(3)这样的写法可能会觉得语法有些啰嗦。但恰恰是这种啰嗦保证了断言的语义足够清晰。当你读测试的时候几乎不需要在脑海里翻译这段代码直接按英文理解就行期望这个结果等于 3。 这种表达方式在团队协作中的好处特别明显——新人接手测试代码时理解成本极低。2.3 should 风格把断言直接挂在对象上should风格的写法比expect更激进它打破了你平常对 JavaScript API 的认知给原始类型和对象直接挂上断言的属性方法。const should require(chai).should(); describe(用 should 风格写断言, () { it(应该判断字符串包含子串, () { const name Hello Chai; name.should.include(Chai); }); });是不是觉得语法更像是这台显示器应该开机这种拟人化表达这就是 BDD 风格名称的由来——行为驱动开发把测试语言化、行为化。使用should风格有一个前提必须先执行一次chai.should()来扩展原型这意味着它对全局对象有侵入性。如果你的测试环境对原型污染有严格限制或者团队规范不允许扩展内置原型就尽量不要用should。2.4 assert 风格古典派的选择assert风格并非 Chai.js 独有Node.js 内置的assert模块同样是这套思路它们的语法一致区别在于 Chai 提供了更丰富的断言方法和错误信息。const assert require(chai).assert; describe(用 assert 风格写断言, () { it(应该断言数组包含某个值, () { const list [1, 2, 3]; assert.include(list, 2, 数组必须包含 2); }); });assert风格属于 TDD 的产物它的特点是命令式、无链式调用更像在写一系列的检查清单。使用assert风格的代码断言的强度完全依赖你选了哪些方法——它不像expect和should那样能通过追加修饰词来增强断言逻辑。这三种风格的差异用一个表格总结风格语法形态适用场景上手难度expect链式调用语言化明显大多数项目团队规范首选低should对象直接挂载方法最接近自然语言小项目、个人项目、原型扩展无顾虑时低assert命令式函数调用TDD 风格传统项目、Node 内置环境的迁移中我在实际项目里几乎只使用expect。原因很简单它的可读性和扩展性平衡得最好再加上should的原型扩展在某些严格模式环境里会引发 ESLint 报错团队协作时很难推动。但如果你是个人项目想体验一下让代码读起来像英文句子的感觉用should也未尝不可。3. 从相等到包含核心断言方法的心智模型3.1 相等断言equal、strictEqual、deep.equal 的本质区别所有断言库都绕不开相等这道坎。理解相等断言本质上是理解 JavaScript 的相等语义在断言场景下的三种状态。先看原始类型expect(1).to.equal(1); // 通过 expect(abc).to.equal(abc); // 通过equal在 Chai 4.x 中相当于 JavaScript 的严格相等所以 1 和1不会通过断言expect(1).to.not.equal(1); // 通过因为 1 ! 1如果你需要宽松相等Chai 并没有直接暴露一个方法给你你必须用equal配合类型转换但我不推荐这么做——测试代码应该精确不应该依赖隐式转换。再看对象。对象比较是断言库最容易让人困惑的地方。如果写expect({ a: 1 }).to.equal({ a: 1 })我会直接告诉你这个断言会失败。因为在 JavaScript 眼里两个字面量创建的对象指向不同的内存地址比的是引用不是值。Chai.js 为此提供了deep修饰词。deep.equal会把两边的对象变成 JSON 结构比对而不是比对引用地址expect({ a: 1 }).to.deep.equal({ a: 1 }); // 通过 expect({ a: 1, b: { c: 2 } }).to.deep.equal({ a: 1, b: { c: 2 } }); // 通过deep.equal内部对数组同样适用——数组的元素、嵌套结构、顺序都会被逐一比对。只要你能把数据转成 JSONdeep.equal就能做完全的值比较。那strictEqual又是干什么的在 Chai 4.x 里它有独立的行为当用于原始类型时strictEqual等于当用于对象时它比的是引用同一性。也就是说expect({ a: 1 }).to.strictEqual({ a: 1 })仍然失败。为了不让你混淆我给出一个总结性对比表断言写法原始类型对象/数组.equal(value)比较引用地址比较.strictEqual(value)比较引用地址比较.deep.equal(value)比较递归值比较关键结论凡是涉及对象内容比较一律用deep.equal。这是新手最容易踩的坑没有之一。3.2 包含断言include、contain 与成员关系包含断言的作用是判断一个复合结构是否含有某个元素或子串它是后端接口测试和前端业务逻辑测试中使用频率最高的断言类型之一。expect(Hello World).to.include(World); // 字符串包含 expect([1, 2, 3]).to.include(2); // 数组包含元素 expect({ name: Tom, age: 18 }).to.include({ age: 18 }); // 对象包含键值对对于对象include的本质是部分匹配它检查目标对象是否拥有指定键并且对应值相同但不会要求两个对象完全一致。就接口测试场景而言这非常实用——我们只关心返回体里的关键字段而不是全量比对。同样地如果你要否定包含关系使用.not.includeexpect(Hello World).to.not.include(Goodbye);数组还有一个值得记忆的成员断言members它比较数组的元素集合expect([1, 2, 3]).to.have.members([3, 1, 2]); // 通过不考虑顺序 expect([1, 2, 3]).to.have.members([1, 2, 3]); // 通过顺序一致也行默认情况下members不要求顺序一致属于集合层面的比较。如果真的想强制顺序一致需要配合ordered修饰词expect([1, 2, 3]).to.have.ordered.members([1, 2, 3]); // 通过 expect([1, 2, 3]).to.have.ordered.members([3, 2, 1]); // 失败3.3 null、undefined、布尔值、类型与真假值的断言测试中经常碰到对是否存在的判断。Chai.js 为此封装了专门的方法expect(value).to.be.null; // 值为 null expect(value).to.be.undefined; // 值为 undefined expect(value).to.be.true; // 严格等同 true expect(value).to.be.false; // 严格等同 false expect(value).to.be.ok; // 真值truthy expect(value).to.not.be.ok; // 假值falsy expect(value).to.be.NaN; // 是否为 NaNok和true的区别值得展开。.true要求value true布尔 AND 运算返回true才通过.ok只要值是 truthy 就通过——例如数字1、非空字符串a、空数组[]都会让.ok通过但都不会让.true通过。业务代码里建议优先用.true和.false把真假值判断写成明确的状态避免隐藏 bug。3.4 类型、长度与属性断言类型断言用于确认值的类型是否符合预期它在接口兼容性测试中特别有用expect(Hello).to.be.a(string); expect(123).to.be.a(number); expect([]).to.be.an(array); expect({}).to.be.an(object);Chai 的类型判断底层用的是Object.prototype.toString或者 constructor所以对于null它会被判断成object——这是 JavaScript 的历史遗留。想判断空值直接用.null不要用类型断言。长度断言是一类高频操作expect([1, 2, 3]).to.have.lengthOf(3); expect(Hello).to.have.lengthOf(5);对象属性的存在性与值断言const user { name: Alice, age: 25 }; expect(user).to.have.property(name); expect(user).to.have.property(name, Alice); expect(user).to.have.nested.property(address.city, Beijing);property方法可以接收第二个参数表示期望该属性的值nested.property支持点路径访问嵌套属性。有一次我在排查接口返回体时发现某个字段可能在data或meta里我的断言写法就是expect(response.body).to.have.nested.property(data.list, []);这个写法现在已经成为我检查嵌套结构的标准姿势了——省掉了连串的判空逻辑。3.5 异常断言throw测试的错误处理路径如果说前面所有断言都是在测正常情况throw就是专门测异常情况的武器。它的语法乍一看有点怪expect(() { JSON.parse(not-json); }).to.throw();对expect接收一个函数Chai 会替你调用它然后捕获异常。如果你不包一层函数而直接expect(JSON.parse(not-json))这个断言在调用expect之前异常就已经抛出了根本没机会断言。还可以匹配异常的类型和信息expect(() { throw new TypeError(年龄不合法); }).to.throw(TypeError); // 匹配构造函数 expect(() { throw new TypeError(年龄不合法); }).to.throw(年龄不合法); // 匹配错误信息子串 expect(() { throw new TypeError(年龄不合法); }).to.throw(TypeError, 年龄); // 同时匹配类型和消息这里有一个我亲测过的细节匹配错误信息用的不是严格相等而是子串包含。如果你想做正则匹配也可以直接传正则表达式expect(() { throw new Error(订单号缺失无法创建); }).to.throw(/订单号.*无法/);4. 语言链的真正作用为什么你的断言看起来像英文句子4.1 装饰性语言链 vs 功能性修饰词你肯定会好奇to、be、been、is、that、which、and、has、have、with、at、of、same这些词在断言里扮演什么角色。答案可能出乎你意料——它们大部分是纯装饰Chai 会把它们直接忽略。也就是说下面这三种写法的行为完全一致expect(1).to.equal(1); expect(1).be.equal(1); expect(1).to.be.equal(1);既然有没有这些词行为都一样为什么还要保留它们为了读代码时的自然语感。你可以把 Chai 的链式断言当作一门微型的英语描述语言主语是expect(值)谓语是断言方法而to、be、should这些语言链就是让语句通顺的连接词不需要承载任何逻辑。真正影响断言行为的修饰词是下面这几个deep深层比较、深度包含not取反nested嵌套属性访问ordered数组顺序比较any/all数组成员匹配范围一个经典的链式断言可能是这样的expect(users) .to.be.an(array) .that.includes(Alice) .and.has.lengthOf(2);拆开看to.be.an(array)断言类型.that是装饰让句子通顺.includes(Alice)断言包含元素.and再次装饰.has.lengthOf(2)断言长度。整句话读下来几乎就是一个自然语言陈述句。4.2 not 取反与嵌套使用的逻辑陷阱not的逻辑看起来直觉但组合使用时会隐藏一些惊喜。一个常见的误用是expect(对象).to.not.deep.equal({ 预期值 })——这个没问题它表示对象内容不等于预期值。但如果是expect(对象).to.not.have.property(name, Alice)含义就很微妙了这个断言要求要么没有name属性要么name属性的值不等于Alice。很多人会把它理解成对象没有name属性这是完全错误的预期。我在代码评审时看到过不止一次这种错误。当断言失败时报错逻辑往往让你一头雾水。解决这类语义最好的办法是让取反只作用在单一意图上不要给太复杂的表达式加not。例如想表达没有 name 属性就应该直接写expect(user).to.not.have.property(name);想表达name 属性值不是 Alice就写expect(user).to.have.property(name).that.not.equal(Alice);4.3 链式断言的执行顺序与中断每调用一个断言方法这次断言的使命就完成了后面仍然可以继续追加新的断言方法——Chai 会生成新的断言上下文继续基于最初的值进行判断。expect(Hello Chai) .to.be.a(string) .and.have.lengthOf(10) .and.match(/^Hello/);这种写法适合在同一对象上做多项检查但我不建议写超过三层的链式调用。一旦中间某个断言失败报错信息定位到的行号是整条链的起始位置排查时你还要手动拆解是哪一段断了得不偿失。我通常的做法是const str Hello Chai; expect(str).to.be.a(string); expect(str).to.have.lengthOf(10); expect(str).to.match(/^Hello/);多条独立的expect显然更友好——失败了可以直接看到具体是哪一行。5. 与 Mocha、Jest 的集成把断言从测试框架中解放出来5.1 Mocha Chai 的经典组合前端单元测试最经典的一套组合拳就是 Mocha 和 ChaiMocha 管用例的收集、执行、钩子函数、超时控制Chai 管断言。两者职责边界清晰互不侵入。安装完成后写上这样一个测试文件const { expect } require(chai); function add(a, b) { return a b; } describe(add 函数, () { it(1 2 等于 3, () { expect(add(1, 2)).to.equal(3); }); });运行npx mocha即可。Mocha 默认会寻找test/目录下的测试文件你可以通过.mocharc.yml调整匹配规则spec: - test/**/*.spec.js这种组合的好处在于灵活——你没有绑定一套全家桶测试运行器和断言库可以分别独立升级也可以按项目需求替换其中一件。比如在某个大型遗留项目里团队把自带的默认断言全部换成 Chai并不需要修改任何describe/it结构只需要替换断言那一行。5.2 在 Jest 里面用 Chai绕开内置 expect 的收益Jest 自带expect而且功能也不算弱但依然有团队选择在 Jest 里引入 Chai——原因通常是项目存在多套测试框架并存为了统一断言风格减少认知切换。在 Jest 里使用 Chai只需在测试文件头部直接 import/require 即可不需要改变 Jest 的用例组织方式const { expect } require(chai); test(使用 Chai 断言, () { expect({ a: 1 }).to.deep.equal({ a: 1 }); });注意在 Jest 环境里expect这个名字会和 Jest 自带的全局expect产生命名冲突。建议给 Chai 的expect起个别名比如chaiExpect或者直接在.eslintrc里禁用 Jest 的expect全局变量。我倾向于别名方案const { expect: chaiExpect } require(chai); chaiExpect(1).to.equal(1);5.3 浏览器端测试场景当你在浏览器环境做测试时比如用 Karma 或直接写 HTML 测试页Chai 的引入方式略有不同。CDN 引入之后chai是全局变量用法不变!DOCTYPE html html langzh-CN head meta charsetUTF-8 titleChai 浏览器测试/title script srchttps://cdn.jsdelivr.net/npm/chai4.3.6/chai.js/script /head body script const { expect } chai; expect(1 1).to.equal(2); console.log(测试通过); /script /body /html浏览器环境下缺少模块体系因此更推荐直接在 CDN 顶部引入构建版文件。如果你的构建工具是 webpack 或 Vite那和 Node 环境的用法完全一致。6. 前端常见场景的实战断言模板6.1 异步接口返回体的验证前端测试绕不开异步Chai 本身不处理异步流程它只是断言工具异步控制权在 Mocha 手里。Mocha 支持在it里返回 Promiseit(获取用户列表接口返回正确结构, async () { const response await fetch(/api/users); const body await response.json(); expect(response.status).to.equal(200); expect(body).to.have.property(code, 0); expect(body).to.have.nested.property(data.users).that.is.an(array); expect(body.data.users[0]).to.have.all.keys(id, name, avatar); });这里的have.all.keys是一个我特别喜欢的断言——它检查对象只包含指定的键集合不多不少。接口返回结构如果有额外字段这个断言会失败适合做接口契约测试。6.2 函数抛错与边界测试测试工具函数时异常路径往往是最容易被遗漏的。一个完整的分支覆盖应该像这样describe(parseUserId, () { it(非法输入应该抛出类型错误, () { expect(() parseUserId(abc)).to.throw(TypeError, userId 必须为数字); }); it(空值应该抛出参数缺失错误, () { expect(() parseUserId()).to.throw(/userId 不能为空/); }); it(正常的数字字符串可以正确解析, () { expect(parseUserId( 42 )).to.equal(42); }); });6.3 数组排序与过滤逻辑的断言业务代码里数组操作变更频率高用 Chai 写这类断言时我一般遵循三个原则先断言类型再断言长度最后断言元素内容。function filterAdults(users) { return users.filter(u u.age 18); } it(过滤器只保留成年人, () { const input [ { name: A, age: 17 }, { name: B, age: 18 }, { name: C, age: 24 } ]; const result filterAdults(input); expect(result).to.be.an(array); expect(result).to.have.lengthOf(2); expect(result.map(u u.name)).to.have.members([B, C]); });这里没有用deep.equal是因为我想把用户对象内容和用户姓名的集合分开验证。过度依赖deep.equal会让断言变得脆接口字段顺序调整是小事新增一个默认字段也能让全量比较失败。6.4 DOM 节点状态测试的简化写法配合 jsdom 或浏览器环境Chai 的 DOM 断言常与插件chai-dom搭配但如果你不想引入插件直接用原生断言也够用it(按钮初始状态为禁用, () { const button document.querySelector(#submit-btn); expect(button).to.not.be.null; expect(button.disabled).to.equal(true); expect(button.textContent.trim()).to.equal(提交); });chai-dom的to.have.trimmed.text这类断言会更语义化但这种原生的写法在熟练后用起来同样高效还能少一层依赖。7. 我在真实项目中总结出的 Chai.js 使用经验与避坑笔记7.1 断言失败时的报错信息如何读、怎么查Chai 的报错信息一直是它的亮点之一。当断言失败时它会给出几类信息断言所在的断言表达式源码、期望值和实际值、以及在测试用例中的位置。我见过很多新手在报错后先去看业务代码其实倒过来才是对的先读断言表达式——它精确地告诉你是哪一条不符再去定位是哪一步产生了这个值。实际开发中我通常会在断言里加第三个参数打印自定义的上下文提示expect(user.age, 用户年龄字段异常).to.be.a(number);第三个参数会出现在错误信息的前列能省掉大量被误判业务逻辑的时间。7.2 团队统一断言风格的落地经验代码评审中我对团队一致性的要求高于风格好坏的争论。实际落地时建议把以下三条写进团队约定里统一使用expect风格不用should避免原型污染对象和数组的内容比较一律使用deep.equal禁止裸equal布尔状态断言用.true/.false不用.ok/.not.ok这类约定最好直接写进 ESLint 的chai-expect插件规则里或者配置eslint-plugin-chai-friendly。我见过太多团队在风格上争论几轮后不了了之最后测试代码风格四分五裂——风格统一本身就是降低维护成本的手段。7.3 Chai 4 与 Chai 5 的互补与迁移目前我用的还是 Chai 4.x因为它是当前生态里最稳定的版本和 Mocha 10 配合没有任何问题。Chai 5 已经发布主要变化是全面 ESM 化同时移除了一些旧浏览器支持。如果你的项目已经切换到 ESM 模块体系用 Chai 5 的体验会更流畅如果是 CommonJS 的存量项目继续锁 4.x 是更稳妥的选择。迁移体验上的一个注意点是Chai 4 时代const { expect } require(chai)这套写法在 Chai 5 里不能用了必须改成import { expect } from chai。如果你正在做混合模块体系的项目建议别急着升级断言库等构建链路全部切完 ESM 再动。7.4 插件生态dirty-chai 与 chai-as-promisedChai 的插件机制让我们可以按需扩展断言能力。其中chai-as-promised是我在异步测试里不可缺少的插件之一它可以让断言直接作用在 Promise 上npm install chai-as-promised --save-devconst chai require(chai); const chaiAsPromised require(chai-as-promised); chai.use(chaiAsPromised); const { expect } chai; it(异步请求应该成功返回, async () { await expect(fetchData()).to.eventually.deep.equal({ code: 0 }); }); it(异步请求应该失败, async () { await expect(fetchData()).to.be.rejectedWith(Error, 请求超时); });插件扩展后eventually和rejectedWith会进入链式调用让 Promise 断言写起来非常舒服。dirty-chai则解决一个很实际的问题默认expect(1).to.equal(1)返回的是断言对象而非布尔值有些 ESLint 规则对表达式语句不返回值的写法很敏感。dirty-chai让每个断言方法都返回true或false并且强制你在末尾补一个()const dirtyChai require(dirty-chai); chai.use(dirtyChai); expect(1).to.equal(1); // 这行会报错因为方法需要被调用 expect(1).to.equal(1)(); // 通过额外的一个括号这个插件适合对代码风格要求严苛的团队。不过它对新手不太友好会让断言看起来多一重仪式感所以我只在特定项目里启用。7.5 我的一个高频踩坑include 和 nested.property 的混用最后分享一个我印象特别深刻的坑。有段时间我一直用expect(obj).to.include({ nested: { key: value } })去验证嵌套对象结果发现断言无论如何都失败。后来查阅文档才明白include在对象上是浅比较它只看一层的键值对嵌套对象会被当整体比较。如果确实要检查嵌套属性正确写法是expect(obj).to.have.nested.property(nested.key, value); // 或者 expect(obj).to.nested.include({ nested.key: value });这两种写法都能通过但核心思路是嵌套结构要用nested修饰词别让include替你自动递归。Chai 给深度比较和嵌套路径访问划了一条清晰的边界——deep负责值递归nested负责路径点分。理解了这条边界你就能处理好 90% 的对象断言需求。测试代码的优雅程度其实衡量的是你对断言语义的理解深度。Chai.js 提供的每个修饰词和方法背后都对应 JavaScript 本身的比较规则把这些规则吃透了你写的就不只是能跑的测试而是能精准表达意图的测试。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑