ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Chai.js断言库实战:expect风格、异步测试与对象比较指南

Chai.js断言库实战:expect风格、异步测试与对象比较指南 写测试写了几年最直观的感受是断言这东西真的能把一份测试代码的气质区分开。早期我在测试文件里堆满了原生if和throw后来切到 Chai.js才发现断言库不是锦上添花而是刚需。Chai.js 是目前 JavaScript 生态里最常见的断言库之一它配合测试框架一起用负责把期望某个值满足某个条件翻译成人话。你可以直接写出expect(user.name).to.equal(alice)读起来像一句英文而不再像一段防御性编程。这篇博文会围绕 Chai.js 的三种断言风格、链式语法、对象比较、异步场景和真实踩坑展开适合刚接触单元测试的前端或 Node 开发者也适合团队正在纠结要不要统一测试代码风格的朋友。1. 为什么说断言库是测试代码的分水岭1.1 没有断言库的时候测试代码长什么样如果你写过几年测试大概率见过这种代码if (typeof result ! string) { throw new Error(result should be a string); }这个写法不能算错但它离优雅两个字还差很远。问题不在于多写几行if而在于报错信息没有灵魂。当这条用例失败时你看到的是result should be a string但你根本不知道result实际是什么、和期望差了多少。如果你在一个特大型项目里同时加了十几个这样的校验排查起来基本靠大脑缓存。后来我改用 Node 自带的标准断言模块情况好了一些const assert require(assert); assert.strictEqual(result, expected);失败时会打出Expected: expected、Actual: ...至少能定位差异。但写多了会发现原生断言的能力边界很明显比较对象时只能走deepStrictEqual判断包含、正则、类型、抛错都需要记一堆 API而且整个断言过程像在写函数调用完全没有语言节奏。1.2 断言库解决的不只是少写 ifChai.js 这类断言库真正解决的是测试代码的表达力问题。同样一个断言如果用 Chai 的expect风格去写它是这样的expect(result).to.be.a(string).and.to.equal(expected);读起来完全是一句自然语言结果应该是一个字符串并且应该等于expected。这种可读性带来的收益在团队协作里会被放大很多倍。测试代码不是写给机器看的更多是写给接手的人看的。项目跑一两年之后真正解释业务规则文档的往往是测试文件本身。而且 Chai 在断言失败时会生成非常详细的 diff能直接看到期望值和实际值的差异。对象嵌套多深它都会尽量把不一致的那条路径标出来。这一点在实际排错时极有价值比if throw至少省一半时间。2. 安装、引入、三套 API 怎么选2.1 三步跑通第一个断言先用 npm 初始化一个项目并安装 Chai 和测试框架。我这里用 Mocha 作为示例实际你完全可以用 Jest、Vitest 或者别的框架Chai 只负责断言这一层npm init -y npm install --save-dev chai mocha然后创建一个测试文件我已经习惯把断言放在test目录下// test/demo.test.js const { expect } require(chai); describe(demo test, function () { it(should return expected string, function () { const result hello; expect(result).to.equal(hello); }); });在package.json里加一个脚本{ scripts: { test: mocha test/**/*.test.js } }跑一下npm test看到绿色通过第一个 Chai 断言就完成了。如果你的项目已经切到 ESM也可以这样引入import { expect } from chai;浏览器端使用也很简单直接引 Chai 的浏览器版本window.chai里就会挂上expect等对象。2.2 三种断言风格选型Chai 提供了三套 API 风格这是它的特色也会让新手纠结。我先快速对比一下风格基本写法读感适用场景assertassert.equal(a, a)偏传统像 Node 原生老项目迁移、习惯函数式写法expectexpect(a).to.equal(a)类似英文句子从对象展开大多数现代项目should(a).should.equal(a)非常口语化接近真人说话小团队、对可读性要求极高的场景三套风格功能基本是等价的都能覆盖相等、包含、类型、抛错等常见断言。差别只在写法形态和边界行为上。2.3 我为什么倾向于 expect 风格should风格写起来确实是最顺畅的比如hello.should.be.a(string);但有两个包袱第一必须提前执行chai.should()去扩展原生对象的原型第二它处理null和undefined时会很尴尬因为null.should直接会抛错。虽然这不是不能处理但在同一个大项目里很容易翻车。assert风格本身没什么问题只是它的读感太像调用 API链式表达能力偏弱写复杂断言时不如expect流畅。所以我通常会推荐expect作为团队默认风格。它对所有值类型都友好不需要修改原型不污染全局链式读感足够好也不会有should那种“某些值不能直接.should”的边界情况。3. expect 风格核心 API 拆解3.1 链式词表为什么断言可以像读英文Chai 的expect风格最迷人的地方是它提供了一堆链式连接词比如to、be、been、is、that、which、and、has、have、with、at、of、same、but、does、still、also。这些词在断言过程中不会触发实际判断单纯是为了让表达式更接近自然语言。比如expect(alice).to.be.a(string).and.to.include(lic);这里的to、be、a、and、to都是链式词真正执行判断的是include。你甚至可以省略所有连接词直接写expect(alice).a(string).include(lic);一样能跑通只是读感会变得生硬。我的经验是不要为了简洁把所有连接词都删掉也不要把每个词都堆上去。保证断言读起来是一句通顺的英文是团队可读性最好的状态。比如expect(obj).to.have.property(name, alice)比expect(obj).property(name, alice)更容易一眼看懂。3.2 高频断言方法分类记忆Chai 的 API 很多但高频使用的就那十几个。我按场景分了几类方便你建立记忆框架。相等性断言expect(2 2).to.equal(4); expect(result).not.to.equal(pending); expect({ a: 1 }).to.deep.equal({ a: 1 });not是反向断言放在链式词中间或者直接跟断言方法都行。注意对象比较一定要用deep.equal如果你用equal比较的是两个对象的引用地址哪怕内容完全一样也会失败。类型断言expect(hello).to.be.a(string); expect([1, 2]).to.be.an(array); expect(null).to.equal(null);a和an是同一个方法只是语法糖不同。它可以接收string、number、array、object、function、boolean、symbol等常见类型名。包含与成员断言expect([1, 2, 3]).to.include(2); expect(framework).to.include(work); expect({ name: alice }).to.include({ name: alice }); expect([1, 2, 3]).to.have.members([3, 1, 2]); expect([{ id: 1 }, { id: 2 }]).to.deep.include({ id: 2 });对数组用include(member)是判断数组中包含某个元素对字符串是判断子串对对象是判断包含某个属性且值相等。这里是新手最容易记混的地方我的记忆口诀是按“容器类型”选择判断方式。members用于比较数组的成员集合它不在乎顺序只要求两个数组的元素一一对应。如果是对象数组需要配合deep.members。正则与条件断言expect(chai.js).to.match(/chai/i); expect(8).to.be.greaterThan(5); expect(3).to.be.lessThan(4);match接收正则表达式适合校验邮箱、手机号、版本号等格式。数值比较用above、below、within也可以实现。长度、属性、存在性expect(hello).to.have.lengthOf(5); expect([1, 2, 3]).to.have.lengthOf(3); expect(user).to.have.property(name); expect(user).to.have.property(name, alice); expect(result).to.exist; expect(arr).to.be.empty;property的第二个参数是可选的值校验也就是说它同时帮你做了存在和相等两件事。异常断言expect(() JSON.parse(bad json)).to.throw(SyntaxError); expect(() someCall()).not.to.throw();这里有个关键点throw接收的是一个函数不是函数的执行结果。新手很容易写成expect(JSON.parse(bad json)).to.throw()这会在断言执行前就抛错永远测不到你想测的分支。多选一断言expect(status).to.be.oneOf([success, failure, pending]);这个断言看起来很冷门但适合校验枚举值、状态机字段能省掉很多||判断。3.3 对象和数组怎么断言才精准对象比较是断言里最容易出错的地方。很多人的第一反应是expect(apiResponse).to.deep.equal(expectedData);没问题但用的时候要理解deep.equal是完整深比较两个对象的结构、字段、嵌套内容必须完全一致。只要接口多返回了一个字段测试就会失败。如果你只想校验接口返回中的某一部分更好的做法是expect(apiResponse).to.have.property(data); expect(apiResponse.data).to.deep.equal({ name: alice, age: 18 });这样即使接口未来新增字段也不会影响你针对核心数据的断言。还有一个小技巧用expect(Object.keys(apiResponse)).to.have.lengthOf(3)可以限制返回字段数量避免接口悄悄多塞数据你后知后觉。对于对象数组比如接口返回一组用户列表你想断言其中包含某个用户const users [ { name: alice, age: 18 }, { name: bob, age: 20 }, ]; expect(users).to.deep.include({ name: alice, age: 18 });deep.include对对象数组很有用它会在数组中查找“深度相等”的那个元素。但如果数组顺序很关键就用deep.equal整体比较如果只关心一组特定成员是否存在就用deep.members。4. should 风格与其边界4.1 should 风格的写法和触发方式如果你喜欢读感最自然的写法should风格确实很吸引人const chai require(chai); chai.should(); describe(should style, function () { it(should support chaining, function () { const answer 42; answer.should.equal(42); answer.should.be.a(number); }); });它读起来是最接近人的语言习惯的甚至比expect更口语化。由于它是在原型上挂了should属性所以所有值都能直接调用.should。4.2 should 会踩的那些坑should第一次用会很爽直到你遇到null和undefinedlet data null; data.should.equal(null); // 这里直接报错因为 null 没有 should 属性这其实是设计上的取舍should风格没法覆盖空值场景。在真实项目中接口返回null是很容易出现的情况为了兼容空值你最终还是得改用expect去判断。这就造成了“一个测试文件里混用两套风格”的尴尬。另外should会修改Object.prototype等于给所有对象挂了一个属性。在一些底层库互相侵入的项目里这可能引发莫名其妙的属性冲突。所以我最终在团队里只保留了expect风格不是should不好而是它解决的可读性问题expect也能解决却没有那些额外的边界担忧。5. 异步代码、自定义消息与可读性增强5.1 异步断言怎么写才可靠前端测试和 Node 测试里异步场景几乎绕不开。用 Chai 配合 Mocha 时常见的有三种写法。返回 Promise 的方式推荐it(should return user data, async () { const data await fetchUser(1); expect(data).to.have.property(name); });这里的关键是it的回调函数要返回 Promise测试框架才能知道什么时候结束。done 回调的方式it(should work with done, (done) { fetchUser(1).then((data) { expect(data).to.be.an(object); done(); }).catch(done); });注意catch(done)不能省否则断言失败时抛出异常会被 Promise 吞掉测试永远处于挂起状态最后超时。用 async/await 捕获异常it(should catch async error, async () { let error; try { await shouldReject(); } catch (e) { error e; } expect(error).to.be.an.instanceof(Error); });try/catch包一层再断言比.to.throw()更适合检查异步函数抛错。5.2 给断言加自定义消息Chai 的expect可以接收第二个参数作为自定义错误说明expect(actualValue, 用户状态应当为 active).to.equal(active);当断言失败时你会在控制台看到这行说明。我用它来补充一些上下文信息比如这是登录流程的第二个请求返回。自定义消息特别适合在长测试中快速定位是哪一个业务点出了问题。5.3 善用 not 和半结构化断言not不只是简单的取反它还能帮助你做“白名单式”校验。比如某个接口返回的数据里你不确定具体有几个字段但可以肯定不能包含某种敏感结构expect(user).to.not.have.property(password); expect(response.headers).to.not.include({ authorization: Bearer xxx });这种断言的表达力很强读测试的人一眼就知道“这里不允许出现什么”。我习惯在断言约束比较多的场景里把正向断言和反向断言组合起来让测试看起来更像一份完整的验收标准。6. 常见问题与排查技巧实录6.1 断言没有执行测试却一直通过最典型的情况是忘了调用断言方法expect(result).to.equal; // 这里缺少 ()表面看没有报错但测试永远通过因为这个表达式只是把函数引用挂到了链上根本不会执行真实判断。我的排查经验是如果一个用例怎么改都不红先怀疑断言根本没跑。检查所有断言方法后面有没有括号。还有一种情况是异步回调里的断言回调没被执行。比如fetchUser().then(...)里写了断言但Promise没有被返回也没有用done测试框架不知道异步逻辑还没跑完于是提前变绿。6.2 对象比较总失败引用比较的误解很多新手写对象断言时用的是expect(actual).to.equal(expected);两个对象内容完全一样但测试就是红。原因很简单JS 对象之间的equal比较的是引用不是结构。两个独立创建的对象即使属性一模一样引用也不同。遇到这种情况不要怀疑 Chai 坏了改成expect(actual).to.deep.equal(expected);deep.equal才会递归比较对象和数组的内容。6.3 异步抛错没被抓到有人会在 async 测试里直接写it(test, async () { expect(asyncFn()).to.throw(); });这拿不到异步异常因为asyncFn()返回的是一个 Promise而to.throw()需要接收一个会同步抛错的函数。异步的异常应该用chai-as-promised插件或者老老实实用await包一层再断言。6.4 断言报错信息太长看得眼花当对象层次很深时Chai 默认会展示一大段期望和实际值的 diff。控制台刷得密密麻麻反而看不清差异。我常用的办法是先把断言拆小。比如一个用户对象有几十个字段我很少直接用deep.equal一把梭而是先断言关键属性expect(data).to.have.property(status, ok); expect(data.user).to.have.property(name, alice); expect(data.user.profile).to.have.property(age, 18);这样测试分裂成多个独立步骤失败时一眼就能定位到具体字段。等关键字段都稳定了再考虑加一层整体deep.equal做完整性校验。6.5 混用断言风格导致的维护问题同一个测试文件里既写expect又写should比想象中更容易发生。有的老项目从assert迁移到expect时会保留一部分旧用例。表面上功能正常但团队其他人读代码时心智负担很重。我的建议是一旦定了项目规范就严格统一风格。你说不清哪一天会有个维护者把should用错然后排查一小时。我在实际项目中踩过最深的坑就是对象比较没有用deep.equal导致断言把引用当成了内容测试通过了却根本没校验到数据。后来我给自己定了一条规矩对象和数组一律深比较包含关系一律显示声明异步断言必须保证测试框架能等到结果。这三点做到测试代码基本不会出现那种“绿得很心虚”的情况。最后再分享一个小技巧如果团队统一用expect风格最好在代码规范里明确要求不允许在同一个测试文件里混用expect和assert。一旦混用维护的时候你会知道什么叫头疼。Chai 这个库本身很灵活但真正的优雅往往来自团队用同一个姿势写它。
RELATED READING

延伸阅读

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