news 2026/10/22 18:00:02

零依赖单测:用Node内置测试运行器替代Mocha、Chai与Sinon

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
零依赖单测:用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/strict
Mock 能力内置需 Sinont.mock 内置
覆盖率内置需 nyc/istanbul内置开关
组件/快照测试强一般不支持
适用场景前端、大型工程老项目习惯纯 Node 服务、公共库

公道话也得说一句:如果你的项目是前端组件库,重度依赖 Jest 的快照测试和 DOM 环境模拟,那继续用 Jest 依然是更省心的选择。这不是"谁比谁好"的争论,而是"这个项目到底需要什么"。node:test的目标不是取代 Jest,而是让那些本来只需要简单断言的 Node 项目,不再被迫背上一个重型框架。

2. 三分钟跑通第一个用例:从空目录到 node --test

2.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-reporter=tap # JUnit XML 格式,很多 CI 平台原生支持 node --test --test-reporter=junit

如果你既想本地看好看的输出,又想给流水线留一份 XML 存档,可以同时指定多个报告器并分开输出位置,把 JUnit 结果写到文件里:

node --test --test-reporter=spec --test-reporter-destination=stdout \ --test-reporter=junit --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-reporter=junit的输出路径设为固定文件名,让流水线归档测试报告。相比以前要专门写一个调用脚本来跑 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-concurrency=N参数,于是想通过并发把跑测时间压下去。

并发本身没问题,问题出在共享状态上。假如你有两个用例都操作同一个模块级变量,比如一个全局计数器,串行时一切正常,一旦打开并发,结果就变成"有时过、有时挂",还特别难复现。我的经验是:保持测试独立性永远优先于跑测速度,真想提速,应该先砍掉多余的 IO 等待,而不是盲目开并发。如果某个 describe 块里的用例确实互不干扰,再局部开启并发都来得及。

5.4 版本差异会让同一份代码表现不同

node:test是分版本逐步长全的,这一点必须心里有数:

能力大致出现版本
基础 test / assertNode 18.0
describe / 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这三板斧。三分钟上手这个说法不夸张,但把它用到顺手确实需要半天;我的建议是从纯函数模块开始换,边写边整理团队自己的用例风格,这套内置工具完全撑得住。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/21 20:54:27

PWM驱动蜂鸣器与ADC调光:MM32组合例程实战解析

1. 项目概述1.1 核心需求解析不少刚接触MM32系列开发板的开发者,在看完官方提供的LED闪烁、按键扫描这类基础例程之后,会陷入一个瓶颈期:点灯已经会了,但真正要做一个“稍微有点意思”的小项目,却不知道从哪下手。比如…

作者头像 李华
网站建设 2026/10/22 18:00:01

SpringBoot+Vue+MySQL家电销售展示平台:前后端分离管理系统实战

下载过项目源码的人,大多有过这种体验:页面上明明白白写着“可直接运行”,拉下来之后却连环境都凑不齐,跑起来更是到处报错。但这个家电销售展示平台信息管理系统,我在干净的Windows和Linux环境里各跑了一遍&#xff0…

作者头像 李华
网站建设 2026/10/22 15:46:29

高校社区生鲜配送系统实战:从压缩包到高并发订单状态机

简介:高校社区生鲜配送系统是一套面向大学生及周边社区居民的在线生鲜购物平台源码,适合计算机专业学生、Java Web开发者用于课程设计、毕业设计或企业级项目练手。系统围绕用户管理、商品管理、订单处理、库存控制、配送调度、支付接口、数据分析、客户…

作者头像 李华
网站建设 2026/10/22 18:00:00

Halcon联合C#机器视觉开发实战:从架构选型到避坑指南

干机器视觉这行干久了,手边最离不开的工具就是Halcon。尤其这几年,只要是工业现场的定位、检测、识别项目,十有八九是Halcon搭配C#搭出一套上位机程序:Halcon负责图像处理和算法,C#负责界面、通信和业务逻辑。我最早接…

作者头像 李华
网站建设 2026/10/20 6:57:50

Qt无边框窗口实战:从WinAPI到DWM的拖拽缩放阴影方案

简介:基于QT与WINAPI混合编程实现Win7特效无边框窗口的完整示例工程,面向有一定QT基础、希望在Windows下打造高定制化界面的桌面应用开发者。资源以纯C/QT源码为核心,完整演示了图标显示、窗口缩放、鼠标拖拽、AERO阴影与模糊背景&#xff0c…

作者头像 李华
网站建设 2026/10/22 14:17:45

排查域后门:DSRM 后门、异常组策略、异常 SPN、异常证书检测

🔒免责声明:本文仅用于企业 AD 域安全排查、红蓝对抗、应急响应学习,所有 PowerShell 命令请优先在测试环境验证;发生真实入侵事件,优先做好快照备份,禁止对未授权的域环境执行检测操作。 前言 当攻击者拿…

作者头像 李华