零依赖单测:用Node内置测试运行器替代Mocha、Chai与Sinon
我最近接手一个老服务第一件事不是看业务代码而是先摸一遍它的测试链路Mocha 负责跑Chai 管断言Sinon 做 mock再加一个 nyc 统计覆盖率。功能倒是都能满足但每次新同事 clone 项目光装依赖就要等两分钟锁文件里躺着一百多个跟业务无关的包。后来我给另一个新模块写单测时换成了 Node.js 内置测试运行器node:test一条node --test命令全搞定依赖数直接归零。这篇文章我想用三分钟带你从零写一个能跑的单元测试再把 describe/it、钩子、mock、覆盖率和 CI 接入这些真实项目里绕不开的能力全部过一遍最后把我在换框架过程中踩过的坑也摊开讲。适合还没上测试的老项目快速补课也适合新项目想少装一个测试框架的团队参考。1. 为什么新项目我第一个想装的是它内置测试运行器的真实价值1.1 零依赖背后省的其实是一整条维护链路先说个不算冷的知识node:test从 Node 18 起就进标准库了20 之后整体体验已经很顺到了 22 我基本敢在正式项目里全量使用。也就是说从某个版本开始写 Node 单元测试这件事本身不需要再装任何第三方测试框架。很多人对内置的第一反应是功能肯定很简陋我当初也这么想直到我把一个小服务的测试从 Mocha Chai Sinon 三件套换成node:test之后才发现真正值钱的不是省了几个包而是省掉了一整条维护链路不用再维护mocharc、jest.config这类配置文件不用再纠结团队里谁用 Jest 25、谁用 Jest 29锁文件升级引发的兼容性问题直接消失不用再记断言用 Chai 的expect还是should这种风格之争删掉依赖之后npm install的速度肉眼可见地变快CI 里拉依赖的时间也跟着缩短。这套东西对个人项目和中小型后端服务尤其划算。你真正需要的往往就三样能跑用例、能比断言、能 mock 掉外部依赖。而这三点内置运行器现在都占了。1.2 一张表说清它的能力和边界先把内置运行器和我们常用的方案放在一起对比一下方便你判断要不要换对比项JestMocha Chai Sinonnode:test额外依赖一大堆至少 3 个主包0配置文件jest.config.mocharc 等无断言能力内置需 Chainode:assert / assert/strictMock 能力内置需 Sinont.mock 内置覆盖率内置需 nyc/istanbul内置开关组件/快照测试强一般不支持适用场景前端、大型工程老项目习惯纯 Node 服务、公共库公道话也得说一句如果你的项目是前端组件库重度依赖 Jest 的快照测试和 DOM 环境模拟那继续用 Jest 依然是更省心的选择。这不是谁比谁好的争论而是这个项目到底需要什么。node:test的目标不是取代 Jest而是让那些本来只需要简单断言的 Node 项目不再被迫背上一个重型框架。2. 三分钟跑通第一个用例从空目录到 node --test2.1 一个最小但完整的 Demo直接实操。先建一个空目录初始化一下mkdir test-demo cd test-demo npm init -y node -v我这里假设你的 Node 版本在 20 以上。然后新建一个名为math.test.js的文件const { test } require(node:test) const assert require(node:assert/strict) function add(a, b) { return a b } test(add 函数能正确做加法, () { assert.equal(add(1, 2), 3) })然后运行node --test你会看到类似这样的输出✔ add 函数能正确做加法 (1.1ms) ℹ tests 1 ℹ pass 1 ℹ fail 0 ℹ cancelled 0 ℹ duration 20.3ms到这里你的第一个单元测试就已经跑起来了。整个过程没有任何一行配置文件没有安装任何依赖这就是内置运行器最直观的体验。你也可以用node --test math.test.js指定只跑某一个文件平时调试单个用例的时候这样更快。2.2 文件发现规则知道这些才能不被漏跑node --test不是随便扫描整个项目它有自己的一套文件匹配规则我列一下关键点文件名以.test.js、.test.mjs、.test.cjs结尾的文件文件名以-test.js、_test.js结尾的文件文件名叫test.js或者以test-开头的文件任何位于test目录下的.js、.mjs、.cjs文件。默认情况下它会跳过node_modules这点放心。我建议团队统一采用xxx.test.js这种命名放在被测模块旁边或者统一放test目录都可以关键是规则要对齐不然就会出现我明明写了测试CI 里却没跑这种尴尬。2.3 断言到底该用 node:assert 还是 node:assert/strict这是新手最容易忽视的一个选择。node:assert是宽松模式node:assert/strict是严格模式。光说概念不够直观看代码const assert require(node:assert) const strict require(node:assert/strict) assert.deepEqual({ a: 1 }, { a: 1 }) // 通过宽松比较 strict.deepEqual({ a: 1 }, { a: 1 }) // 抛异常因为类型不同宽松模式下1和1会被认为是相等的这在单元测试里非常危险——你可能以为校验了数据结构实际上脏数据悄悄溜过去了。所以我的建议很简单一律从node:assert/strict引入断言让和、浅比较和深比较这些语义都是严格的。如果你有团队规范直接写死这一条。3. 写真实业务测试时真正会用到的能力3.1 describe / it 钩子把测试按模块组织起来单一test()函数写多了之后最需要的是分组能力。内置运行器提供了describe和it用起来就是熟悉的味道const { describe, it, beforeEach, afterEach } require(node:test) const assert require(node:assert/strict) const users [] function addUser(name) { users.push({ name, createdAt: Date.now() }) } describe(用户模块, () { beforeEach(() { users.length 0 }) it(添加用户后列表长度增加, () { addUser(A同学) assert.equal(users.length, 1) }) it(添加的用户包含 name 字段, () { addUser(B同学) assert.equal(users[0].name, B同学) }) })钩子的执行顺序是before在整个 describe 块开始前执行一次beforeEach在每个用例前执行afterEach在每个用例后执行after在整个块结束后执行。顶层声明的钩子作用于整个文件里的所有用例describe内部的钩子只作用于当前块。这个作用域机制和大多数框架一致老手几乎零学习成本。3.2 异步测试await 和 assert.rejects 的正确姿势现在的业务代码十个里有九个是异步的所以异步测试是重中之重。node:test对 async 函数的处理很直接测试函数返回 Promise运行器会等它完成。test(异步获取用户列表, async () { const data await fetchUserList() assert.equal(data.length, 2) })但这只是第一层。真正容易写错的是断言一个 Promise 会失败的场景。很多人下意识用assert.throws这是错的——assert.throws只适合同步抛错异步的 Promise 要用assert.rejects// 正确写法断言 Promise 被 reject并校验错误信息 await assert.rejects( getUserById(-1), /用户不存在/ )如果你拿assert.throws去测 async 函数测试往往会生效但不彻底有时候能抓到错误有时候抓不到非常不稳定。我后面第五节还会展开讲这个连带问题。3.3 参数化测试、skip 与 todo让用例数量有意义的三个小工具单元测试里有一类很常见的工作同一个纯函数喂不同输入验证不同输出。node:test没有内置参数化语法但直接用循环生成用例就行反而更灵活const cases [ [1, 2, 3], [2, 3, 5], [0, 0, 0], [-1, 1, 0], ] for (const [a, b, expected] of cases) { test(add(${a}, ${b}) 应该等于 ${expected}, () { assert.equal(add(a, b), expected) }) }这么写之后每次运行都能清楚看到哪组边界数据挂了比在一个用例里写一堆assert.equal然后只报一个笼统的失败信息要直观得多。另外两个实用标记test.skip和test.todo。前者用于临时代码里暂时不跑的用例后者用于标记还没实现但应该补的用例。它们会让你的测试输出里明确出现 skip 或 todo 的统计而不是默默删掉一行代码。做代码审查的时候这类标记也能提醒后来人这里还有账没还清。3.4 Mock用 t.mock.method 管住外部依赖单元测试的核心是隔离你要测的是自己的逻辑不是数据库、不是 HTTP 接口所以 mock 能力是否好用直接决定了这套框架能不能落地。node:test的 mock 在测试上下文里取const { test } require(node:test) const assert require(node:assert/strict) async function saveUser(db, user) { await db.insert(user) return { saved: true } } test(saveUser 会调用 db.insert, async (t) { const db { insert: async (user) ({ id: user.id }) } const spy t.mock.method(db, insert) const result await saveUser(db, { id: 1, name: A同学 }) assert.equal(result.saved, true) assert.equal(spy.mock.callCount(), 1) assert.equal(spy.mock.calls[0].arguments[0].name, A同学) })这里有个很贴心的设计通过t.mock.method创建的 mock 会在该测试用例结束时自动恢复原方法不需要你手动清理。这一点非常关键——如果你在测试之间忘了恢复 mock很容易污染后续用例制造一堆改了这一处、挂了那一处的灵异问题。如果你用模块级mock而不是测试上下文里的t.mock那就要记得在afterEach里手动调用mock.restoreAll()。我建议能用t.mock就用t.mock少一件需要操心的事。4. 覆盖率、报告器与 CI 接入从能跑到能上线4.1 先给项目开一份覆盖率账单测试写完下一个绕不开的问题就是我到底测了多少。内置运行器提供覆盖率统计不需要额外装 nyc 这种工具。在 Node 20.3 以上版本直接加开关node --test --experimental-test-coverage运行结束后终端会输出每个文件的行覆盖率、分支覆盖率、函数覆盖率、语句覆盖率这几项数据。较新版本的 Node 可能已经去掉了experimental前缀具体以你本机node --help输出的参数为准。覆盖率这个东西我个人的态度是它是一面镜子不是一根鞭子。别盲目追求 100%那只会催生为了覆盖率而写的假测试。更好的做法是盯住核心业务模块的覆盖率比如订单计算、权限判断这类出错代价高的代码把行覆盖和分支覆盖保证在 80% 以上其他工具类代码宽松一些也问题不大。4.2 报告器怎么选node --test默认输出的是 spec 风格人眼看着很舒服。但到了 CI 和自动化平台系统需要的是机器可读的格式。内置报告器我给三个常用选项# 默认的 spec 格式适合本地开发 node --test # TAP 格式几乎所有测试解析工具都认 node --test --test-reportertap # JUnit XML 格式很多 CI 平台原生支持 node --test --test-reporterjunit如果你既想本地看好看的输出又想给流水线留一份 XML 存档可以同时指定多个报告器并分开输出位置把 JUnit 结果写到文件里node --test --test-reporterspec --test-reporter-destinationstdout \ --test-reporterjunit --test-reporter-destination./test-results.xml这些参数不复杂但每次新接一个 CI 平台都要重新查一遍很烦建议直接写进 package.json 脚本里团队共享。4.3 流水线里最小接入CI 接入其实比想象中简单因为node --test天然遵守退出码非 0 即失败的约定。你只需要在 package.json 里加一行{ scripts: { test: node --test } }然后在流水线里做三件事装好 Node 20 或更高版本、拉依赖、跑测试。一个最基础的流程大概是steps: - 安装 Node.js版本 20 - 执行依赖安装 - 执行 npm test整个过程不需要任何额外插件。你甚至可以顺手把--test-reporterjunit的输出路径设为固定文件名让流水线归档测试报告。相比以前要专门写一个调用脚本来跑 mocha 参数这里真的就是一行命令的事。5. 用了一段时间后我劝你注意这几个坑5.1 异步测试忘了 await绿灯比红灯更可怕我先给一段典型问题代码你可以想想自己有没有写过类似的test(getUserById 返回用户, () { getUserById(1).then((user) { assert.ok(user) }) })这个用例大概率是绿的但它什么也没测到——测试函数在getUserById返回的 Promise 落定之前就结束了断言逻辑被丢到了事件循环后面。运气好时断言跑完一切正常运气差时断言里的失败会以 unhandled rejection 的形式出现测试结果忽绿忽红甚至可能变成明明代码有问题CI 却通过了。修正方法就是我在 3.2 节强调的异步测试函数写成async对每个异步调用都await。这个坑我见过太多次也是新手从会写测试到测对东西之间最典型的一道坎。5.2 test 目录下的工具文件会被当成测试用例加载这个坑来自文件发现规则node --test会把test目录下所有 js 文件都当作测试文件加载。我一度在test/helpers.js里放了个创建数据库连接的工具函数里面有一句初始化日志打印。结果每次跑测试那行日志都会出现好几次看起来没什么大危害但你要是把一个启动时只执行一次的副作用代码放进去问题就会被无限放大。我的建议是公共测试工具不要放在test目录里统一放项目根目录下的test-utils.js或src/test-utils.js因为它的文件名不匹配发现规则不会被当作测试文件加载。5.3 concurrency 和共享状态打架node:test默认在一个文件内串行执行用例这个设计其实很稳。但你可能看到文档里有{ concurrency: true }这个选项或者--test-concurrencyN参数于是想通过并发把跑测时间压下去。并发本身没问题问题出在共享状态上。假如你有两个用例都操作同一个模块级变量比如一个全局计数器串行时一切正常一旦打开并发结果就变成有时过、有时挂还特别难复现。我的经验是保持测试独立性永远优先于跑测速度真想提速应该先砍掉多余的 IO 等待而不是盲目开并发。如果某个 describe 块里的用例确实互不干扰再局部开启并发都来得及。5.4 版本差异会让同一份代码表现不同node:test是分版本逐步长全的这一点必须心里有数能力大致出现版本基础 test / assertNode 18.0describe / it / 钩子Node 18.7 之后t.mock.methodNode 19.x 之后覆盖率开关Node 20.3 之后mock.fn、新增实验特性更新版本也就是说同一份测试代码在 Node 18 和 Node 20 上的行为可能不同——不是语法报错而是某些 API 压根不存在。我踩过一次同事本地 Node 18跑一个用到mock.fn()的用例直接抛TypeError还以为是代码问题查了半天才发现是版本问题。建议很简单在 package.json 里声明engines: { node: 20 }开发和 CI 统一用双数版本至少保证大版本一致。这样能避开绝大多数版本差异带来的坑。最后再分享一个我实际操作的体会。我把一个中等规模服务的测试从 Mocha Chai Sinon 换到node:test之后最直观的变化是删掉了一整个配置文件、十来个间接依赖npm install速度快了接近一半。刚开始我也觉得没快照测试、没一堆现成插件不太习惯但真正写了一个月之后发现后端单元测试反复用到的就是assert.strictEqual、assert.rejects和t.mock.method这三板斧。三分钟上手这个说法不夸张但把它用到顺手确实需要半天我的建议是从纯函数模块开始换边写边整理团队自己的用例风格这套内置工具完全撑得住。