测试通过,说明已有断言没有发现问题;覆盖率报告则告诉我们,测试执行时经过了哪些代码。两种证据各有用途。一个函数从未被调用,是值得调查的空白;一个函数被调用过,却没有检查关键返回值或副作用,也可能掩盖缺陷。把运行器、环境隔离、mock 和覆盖率放在一起设计,才能得到可解释的回归测试。
本文合并翻译整理 Node.js 官方的 覆盖率、测试运行器使用、mock 和 Mocha 迁移四篇指南,补入逐项静态核对发现的修订。作者归属为 Node.js 文档贡献者;保留 OpenJS Foundation、Node.js contributors 及 Node.js Website WG contributors 版权声明。网站仓库采用 MIT 许可,完整文本见文末。
版本与证据范围:本文核对于 2026-10-05。示例采用支持相关接口的 Node.js 版本;所读官方 API 页面分别标为 v24.21.0 与 v26.10.0,不能据此假定任意 Node.js 22 或更早版本都支持相同参数。本文没有运行测试、安装依赖、执行 codemod 或更新快照;下述修订均为静态对照,原文演示输出不会冒充本次测试结果。

从一个没有测到的函数开始
原覆盖率指南使用 CommonJS。为避免项目的 package.json 设置 type: module 后产生歧义,下面把文件名明确写成 .cjs;这是相对原文 .js 的编辑修订。
main.cjs 提供三个简单函数:
function add(a, b) {
return a + b;
}
function isEven(num) {
return num % 2 === 0;
}
function multiply(a, b) {
return a * b;
}
module.exports = { add, isEven, multiply };
main.test.cjs 只测试加法和偶数判断:
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { add, isEven } = require('./main.cjs');
test('add 将两个数相加', () => {
assert.strictEqual(add(1, 2), 3);
});
test('isEven 判断零为偶数', () => {
assert.ok(isEven(0));
});
原例使用测试上下文的 t.assert;此处改为显式导入 node:assert/strict,使断言来自哪里更清楚。运行并收集覆盖率的命令是:
node --experimental-test-coverage --test main.test.cjs
覆盖率参数虽然带有 experimental,仍要按实际 Node 版本的文档判断支持程度。覆盖率表包含行、分支、函数三个百分比和未覆盖行号:行覆盖表示执行过哪些代码行;分支覆盖表示执行过哪些控制流分支;函数覆盖表示调用过哪些函数。
原文在原始文件排版下演示:main.js 行覆盖率 76.92%、函数覆盖率 66.67%,乘法函数对应第 9—11 行未覆盖。测试文件也进入了那份报告,使合计值与业务文件值不同。这些是源文示例数字,改动代码排版、文件范围或运行器版本后不应要求它们逐字相同。
即使偶数判断函数的代码行全部执行,也不代表已经检查奇数、负数或输入类型等行为。完善测试应从函数契约选择输入和断言,不能只追求点亮每一行。这个演示也未定义字符串、浮点数或非法输入应如何处理,不应把简单算术函数视为完整业务接口。
明确覆盖率的分母
Node.js 支持用注释忽略代码,例如:
/* node:coverage ignore next 3 */
function multiply(a, b) {
return a * b;
}
ignore next 不带数字时忽略下一行;也可用 /* node:coverage disable */ 与 /* node:coverage enable */ 包住一段代码。原文演示忽略未测乘法函数后,报告变成 100%。这改变的是统计范围,没有增加一次乘法断言。忽略项应有可审阅的原因,不能用来掩盖本该补测的功能。
文件级范围可用 --test-coverage-include 与 --test-coverage-exclude。两者可以重复提供;同时使用时,文件既要匹配纳入条件,又不能匹配排除条件。默认排除 node_modules,显式纳入规则可以改变这一点。命令中的 glob 应引用,避免 shell 抢先展开:
node --experimental-test-coverage --test-coverage-include="src/*.js" --test main.test.cjs
node --experimental-test-coverage --test-coverage-exclude="src/age.js" --test main.test.cjs
这里的 src/*.js 只表示一个示例目录范围,不能误认为自动递归覆盖全部层级。原文把 src/age.js 排除后同样得到更好看的百分比,但未测试的逻辑仍然存在。
API 名称修订:原指南正文写 coverageIncludeGlobs/coverageExcludeGlobs,代码却写成 coverageInclude/coverageExclude。所核对的 Node.js v24 API与当前 API 都使用带 Globs 的名称。run() 的配置应据此写为:
const options = {
files: ['main.test.cjs'],
coverage: true,
coverageIncludeGlobs: ['src/*.js'],
coverageExcludeGlobs: ['src/generated/*.js'],
lineCoverage: 90,
branchCoverage: 80,
functionCoverage: 90,
};
这是传给 run(options) 的配置片段,程序化运行还需按 API 处理返回的测试事件流、报告器和失败退出状态。CLI 更适合直接演示。三个最低阈值分别由 --test-coverage-lines、--test-coverage-branches、--test-coverage-functions 设置;覆盖率没有达到阈值时退出码为 1,即使行为测试全部通过也会失败。例如:
node --experimental-test-coverage --test-coverage-lines=90 --test main.test.cjs
还要留意“从未被加载的文件”。所读当前 v26 API 出现 coverageIncludeAll,可把未加载源文件按零覆盖计入,默认仍为 false;所读 v24 API 的选项列表没有同项。旧版本报告不能当然代表整个源码目录。无论用哪版,都应把报告中的文件清单与预期源码清单对照,避免只看一个总百分比。
按运行环境拆开 setup
测试运行器指南将单元测试、Service Worker 测试、UI 测试分别组织。原因很实际:DOM、IndexedDB 或 Service Worker 全局对象初始化可能昂贵,也可能污染不需要它们的用例。基础 setup 负责所有组共同需要的配置,每组 setup 再按需导入基础配置。
原文的 npm 脚本用 concurrently 并行启动三个独立命令,并以 --kill-others-on-fail 在一组失败时停止其他组。下面保留分组命令的核心:
node --import ./test/setup.sw.mjs --test "./src/sw/**/*.spec.*"
node --import ./test/setup.units.mjs --test "./src/app/**/*.spec.*"
node --import ./test/setup.ui.mjs --test "./src/app/**/*.test.*"
原文说明 glob 用法要求 Node v21 及之后,且模式要引用;实际支持仍需对版确认。其目录树列了 setup.mjs、setup.units.mjs、setup.ui.mjs,执行脚本还用到 setup.sw.mjs,组织项目时别漏掉后者。register('some-typescript-loader')、some-plaintext-loader、some-css-modules-loader 都是说明性名称,原页并没有提供一套安装后即可运行的项目。
原文用 node:module 的加载器注册机制支持 TypeScript、纯文本或 CSS 模块,并强调 setup 文件自身仍要是能被当前启动链加载的 JavaScript。某些 Node 版本已有原生 TypeScript 能力,但它并不自动等价于任意 JSX、CSS 或项目自定义转换链。应为真实项目选用并固定实际依赖,而不是安装字面上的示例包名。
动态生成子测试时,等待真实的 Promise
对一组 user-agent 或多个工作区的 package.json 做相同检查时,可在父 test 内创建 t.test 子测试。不要在异步父流程中把顶层 test 当成同义替代。
原指南按 23.8.0 前后展示不同等待方式。但旧版“简单例子”的 map 回调用了花括号却没有 return t.test(...),得到的是一组 undefined,后面的 Promise.allSettled 并未等待子测试。下面改成最容易核对的顺序等待形式,避开这项遗漏和版本假设:
import assert from 'node:assert/strict';
import { test } from 'node:test';
test('简单加法表', async t => {
for (const [a, b, expected] of [[1, 2, 3], [-1, 1, 0]]) {
await t.test(`${a} + ${b}`, () => {
assert.equal(a + b, expected);
});
}
});
如果需要并发,必须正确返回子测试 Promise,并确认用例之间没有共享可变资源。原高级例子用 globSync 找工作区文件,再按 JSON 模块导入检查关键字;它提醒 glob 接口需要文件路径,而不是 file: URL 字符串。跨平台时还要正确区分 glob 所需路径与动态 import() 所需模块 URL,并读取 JSON 模块的 default 导出。partialDeepStrictEqual 等较新断言也需确认版本。这里保留检查思路,没有把原辅助函数当作已验证的通用工作区扫描器。
Service Worker、单元和 UI 测试的边界
Service Worker 具有普通 Node 环境没有的全局 API,甚至名称相同的 fetch 也可能处于不同使用语境。原 setup 在每个测试前把 globalThis.self 换成模拟 ServiceWorkerGlobalScope,再在激活事件测试中检查 clients.claim 与 clients.matchAll 的调用次数。应用应在清理钩子恢复原来的全局对象。原激活测试用了 before 和 after 却没有导入,使用时须补齐。
纯单元测试通常只需构造输入并断言行为。原例用“猫可以吃鱼但不能吃塑料”,分别演示 assert.doesNotThrow() 和 assert.throws():正向用例和禁止行为都要检查。不要因为绝大多数用例都属于单元测试,就让所有用例承担 UI 初始化成本。
UI setup 通常需要 DOM。原文使用 global-jsdom 并给出测试 URL,提醒不要重复创建多套 JSDOM;IndexedDB 等模拟只应放在实际需要它的作用域。原片段还漏了 beforeEach 导入,且使用 indexedDb 这一自定义拼写,接到浏览器真实接口 indexedDB 时要核对。其 history.pushState 包装调用 location.assign 也不能被理解为 JSDOM 已拥有完整浏览器导航。需要真实导航行为时,应另设浏览器端端到端测试。
UI 测试可以只检查组件与模拟依赖,也可以沿更多真实内部调用链测试;它们回答的问题不同。原指南把真实浏览器用户路径留给 Playwright 或 Puppeteer 一类工具。这里不把 DOM 模拟环境下通过的断言宣传成完整浏览器兼容性验证。
快照检查结构,行为断言检查含义
原指南介绍 Node v22.3.0 起的快照能力及其当时的 --experimental-test-snapshots 开关。开关要求随版本变化,使用时应查本地版本,而不是永久照抄旧命令。快照可用于组件输出或基础设施配置;可通过 snapshot.setResolveSnapshotPath() 自定义保存位置,例如把默认的 .js.snapshot 改成更容易识别为 CommonJS 的 .snap.cjs。
快照断言来自测试上下文:箭头函数用 t.assert.snapshot(value),普通函数可按该 API 使用上下文 this.assert.snapshot(value);它不是 node:assert 上的同名方法。原 UI 示例先用 Testing Library 渲染组件,再把 prettyDOM 格式化结果交给快照。更新快照时要逐项审阅差异,不能把“重新生成基准”当作修复测试。
对于“失败时应显示错误提示”这种行为,直接查找必要文字或可访问性语义往往比大块快照更稳妥。否则一处无关样式或文案调整就会造成大量噪声,真正行为变化反而难以识别。
决定该模拟什么
mock 的目标是减少不相关变量,让同一测试在不同顺序和次数下仍给出可解释结果。原文把 test double 中的 stub 与 mock 简化为统一称呼;实际工具的术语可能更细,本文沿用其方便讨论的用法。
| 层级 | 关注对象 | 典型取舍 |
|---|---|---|
| 单元 | 最小可隔离代码 | 可模拟自有依赖、第三方包及外部系统,检查该单元的决策 |
| 组件 | 单元加其依赖 | 保留内部连接,按目标决定哪些外部代码需模拟 |
| 集成 | 多个组件配合 | 检查连接契约,使用隔离实例或受控外部替身 |
| 端到端 | 用户实际经过的完整系统 | 原文主张保留被验证路径的真实外部系统;应使用专用测试环境 |
模拟自有函数可以让失败定位更集中,但简单、稳定且已充分测试的函数也可能保留真实调用更合算。第三方依赖不受项目控制,单元测试通常围绕项目调用它的方式来模拟;却很少值得把整个 React 或 Angular 都重做一遍。外部数据库、文件系统和浏览器环境如果能为每个用例提供隔离副本,也可以真实测试。
原文的并发存储例子说明了反面:两个用例同时写同一个数据库,其中一个插入的记录可能使另一个的数量断言失败。这不是业务代码必然有错,而是夹具共享引入竞争。应为用例分配独立数据、独立存储或可恢复的替身;不能用随机重试掩盖污染。
模块 mock 的顺序和接口
模块替换必须在被测模块导入前安装,所以消费者需要动态 import()。已经持有原模块引用的代码不会自动变成替身。模块 mock 还要求相应版本启用 --experimental-test-module-mocks。
原 UI 示例把 mock.module() 返回值当作函数,然后调用 mockImplementation(),同时把命名导出直接放在选项顶层。这混淆了模块上下文与 mock 函数。下面是按 v24 文档接口修订的结构片段;假定项目确有对应两个模块:
import { test } from 'node:test';
test('组件能处理计算失败', async t => {
const calc = t.mock.fn(() => null);
t.mock.module('./calcSomeValue.js', {
namedExports: { calcSomeValue: calc },
});
const { SomeOtherComponent } = await import('./SomeOtherComponent.js');
// 在真实项目中渲染组件并断言应出现的错误提示。
// 修改函数实现时使用 calc.mock.mockImplementation(...)。
});
这里是接口使用片段,不是一个带空断言的完成测试。所读 v24 与当前 v26 API 文档均已提供 options.exports,并将 namedExports/defaultExport 标为弃用;上面的旧接口示例在这些版本仍有文档支持,新代码宜按所用版本采用 options.exports。两种写法不能混用。原 mock 指南中保留其他命名导出、仅替换默认导出的做法仍体现同一原则,但“先导入原模块拿导出”本身会执行原模块初始化代码,要确认它没有不想触发的副作用。
使用 t.mock 创建的模拟由测试上下文负责结束清理。若大量使用全局 mock,不能笼统相信所有共享状态都会自动重置;应显式恢复或重置并避免并发交叉改写。调用历史和实现是否重置,也应分别核对。
模拟 HTTP 请求时,阻止遗漏的真实网络访问
Node 的 fetch 实现基于 Undici,但要使用 MockAgent 等接口,原文要求把 undici 作为项目依赖安装。原例在并发套件的 beforeEach 中替换全局 dispatcher,却没有完整恢复,容易相互干扰。下面增加禁用未匹配网络、保存恢复原 dispatcher 和关闭代理;使用这类全局状态的用例仍须放在独立进程或确保全部串行。
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { MockAgent, getGlobalDispatcher, setGlobalDispatcher } from 'undici';
test('读取一个受控 HTTP 响应', { concurrency: false }, async t => {
const previous = getGlobalDispatcher();
const agent = new MockAgent();
agent.disableNetConnect();
setGlobalDispatcher(agent);
t.after(async () => {
setGlobalDispatcher(previous);
await agent.close();
});
agent.get('https://example.com')
.intercept({ path: '/foo', method: 'GET' })
.reply(200, { key: 'good', val: 'item' });
const response = await fetch('https://example.com/foo');
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), { key: 'good', val: 'item' });
agent.assertNoPendingInterceptors();
});
这是相对原文的安全整理,未实际运行。concurrency: false 不会神奇隔离同进程其他地方的全局 dispatcher;若别的测试同时修改它,仍须调整测试文件或进程组织。按 Undici 官方 MockAgent 文档,全局 dispatcher 也不替换独立创建的 Pool/Client,更不覆盖其他 HTTP 库。对 GET、PUT 等不同请求应分别匹配 method、path 和必要请求体,避免宽泛 mock 让错误调用也通过。
模拟时间时,先核对时间差
mock.timers 能推进计时器或替换日期,避免为一个超时等待真实几分钟。时间戳应明确时区,原文使用 Z 指定 UTC。不过原例的“现在”是 2000-01-01T00:02:02Z,输入却是 1999-12-01T23:59:59Z,两者相差约一个月,不是“两分钟前”。
下例将输入修为 12 月 31 日,并直接断言 123000 毫秒差,避免依赖原文未提供的 ago():
import assert from 'node:assert/strict';
import { test } from 'node:test';
test('两分钟加三秒的时间差', t => {
const earlier = Date.parse('1999-12-31T23:59:59Z');
t.mock.timers.enable({
apis: ['Date'],
now: new Date('2000-01-01T00:02:02Z'),
});
assert.equal(Date.now() - earlier, 123000);
});
这是静态推导的预期差值,没有执行测试。日期和计时器模拟也涉及进程内共享行为,应避免并发用例互相推进时间;采用测试上下文的模拟并检查清理生命周期。若要断言自然语言的“两分钟前”,还必须定义舍入规则及本地化文案。
从 Mocha 迁移时,检查语义而不只看语法
官方迁移页针对 Mocha 8.x 测试套件,提供 npx codemod @nodejs/mocha-to-node-test-runner。它会添加文件实际使用的 describe、it 和各类钩子的 node:test 导入,支持 CommonJS 与 ESM,并保留普通函数或箭头函数风格。该命令会运行外部工具并修改项目,本文没有执行;实际使用应在可回退工作副本中审查版本、变更和测试结果。
| Mocha 行为 | 迁移后应检查 |
|---|---|
| 全局 describe、it、钩子 | 从 node:test 显式导入实际用到的函数;describe.skip 等修饰符仍需按运行器语义检查 |
function(done) |
变成 function(t, done),测试上下文占第一参数;不要与 Promise 完成方式混用 |
this.timeout(N) |
转换成测试或套件的 { timeout: N } 选项;超时不等于强制停止任意同步副作用 |
this.skip() |
转换为 t.skip(),但仍须显式退出当前函数 |
| Mocha retry | 迁移页列为不支持,需要单独设计,不能默默删除后认定等价 |
关键语义修订:Node 官方 API明确说明 t.skip() 只让输出标记为跳过,不终止函数。原迁移示例在它后面保留断言,或更危险的文件/数据库操作,仍可能继续执行。通常应写为:
test('仅在条件满足时执行', t => {
if (!conditionIsMet) {
t.skip('本环境缺少所需条件');
return;
}
// 此处才进行断言及被测操作。
});
这是控制流片段,conditionIsMet 应由真实项目提供。原文的文件钩子例子在工作目录创建和删除固定的 test.txt,移植时应改用每个用例自己的临时目录和明确清理,避免覆盖或删除已有文件。
codemod 会按检测到的包管理器从 package.json 移除 mocha 与 @types/mocha。在接受这些修改前,逐项检查剩余配置、测试发现规则、跳过逻辑、超时、异步完成方式和不支持的重试行为,并在受控环境实际跑过必要回归。覆盖率阈值、mock 和快照都是帮助检查的工具,不能代替对预期行为及副作用的判断。
本文仅静态审核,未发现硬编码秘密不等于代码没有漏洞。示例修订、中文合并编排和配图由未完纪制作;版权归属和 MIT 许可全文列于文末。Node.js、OpenJS Foundation 及相关商标归各权利人;本文不暗示官方背书。
Node.js 网站许可
原网站许可:Node.js Website WG contributors / MIT License。以下保留版权、授权与无保证条款。
MIT License
Copyright Node.js Website WG contributors. All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to
deal in the Software without restriction, including without limitation the
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
IN THE SOFTWARE.












暂无评论内容