Node.js 内置测试:.test.mjs 文件完全指南
以订单价格计算模块为例,讲透 .test.mjs 的命名约定、ESM 原理、四层结构、执行链路与可测试边界——零依赖、免配置的 Node 原生测试方案。
本文以一个自编的”订单价格计算”纯函数模块为例,讲透
.test.mjs的命名约定、ESM 原理、四层结构、执行链路与可测试边界。
一、一句话定义
.test.mjs 是 Node.js 内置测试框架(node:test)的单元测试文件——不依赖 Jest / Mocha 等任何第三方库,用 Node 自带的模块即可运行。它给对应的业务模块(如 priceCalc.js)写”体检报告”,验证纯函数行为是否符合预期。
二、文件名解剖
| 部分 | 含义 | 说明 |
|---|---|---|
priceCalc | 被测模块名 | 与源文件同名、同目录存放,见名知意 |
.test | 测试文件标记 | node --test 自动发现 *.test.{js,mjs,cjs},无需配置 |
.mjs | ES Module 后缀 | 强制 Node 按 ESM 解析该文件 |
三、为什么是 .mjs 而不是 .js
项目的 package.json 没有 "type": "module" 字段时,Node 默认把所有 .js 按 CommonJS(require)解析。测试文件用了 import / export 的 ESM 语法,若命名为 .js 会直接报 Cannot use import statement outside a module。
.mjs 后缀就是明确告诉 Node:“这个文件是 ESM,按 ESM 规则解析”,从而绕过 package.json 的类型判断。.cjs 则相反,强制 CommonJS。
| 后缀 | 解析方式 | 触发条件 |
|---|---|---|
.js | 看 package.json 的 type 字段 | 默认 CommonJS |
.mjs | 强制 ESM | 无视 type 字段 |
.cjs | 强制 CommonJS | 无视 type 字段 |
注意:测试跑在 Node 环境(node --test),不经过 webpack 打包,所以必须靠后缀声明模块类型;业务 .js 交给 webpack 处理,两者互不干扰。
四、内部结构:四层组织
先给出贯穿全文的自编示例模块:
// src/utils/order/priceCalc.js —— 订单价格计算(纯函数,无副作用)
export function calcTotalPrice(items, couponAmount = 0) {
const subtotal = items.reduce((sum, i) => sum + i.price * i.count, 0);
return Math.max(0, subtotal - couponAmount); // 优惠后不为负
}
export function formatPrice(cents) {
return (cents / 100).toFixed(2); // 分 → 元
}
对应的测试文件 priceCalc.test.mjs,从上到下依次是 import → describe() → it() → assert:
import {describe, it} from 'node:test'; // ① 测试框架 API(Node 内置)
import assert from 'node:assert/strict'; // ② 断言库(严格模式)
import {calcTotalPrice} from './priceCalc.js'; // ③ 被测模块
describe('calcTotalPrice', () => { // ④ 测试组:按函数/行为分组
const items = [{name: '键盘', price: 29900, count: 1}]; // 组内共享测试数据
it('calculates subtotal without coupon', () => { // ⑤ 单个用例:描述行为
const total = calcTotalPrice(items); // 真实调用被测函数
assert.equal(total, 29900); // ⑥ 断言:实际 vs 期望
});
it('never goes below zero when coupon exceeds total', () => {
assert.equal(calcTotalPrice(items, 50000), 0);
});
});
几个容易混淆的点:
- describe 是”分组”,不是”被测函数”。它只做两件事:命名分组 + 注册 it。同一个 describe 可以覆盖多个相关函数(如
calcTotalPrice / formatPrice)。 - it 里是对被测函数的真实调用,模拟的只是输入数据(上面的
items是手工构造的假数据)。真正的 mock 是”依赖注入”——把假实现(假接口函数、假存储)作为参数传进去,替换外部依赖。 assert.equal与assert.deepEqual的分工:assert/strict下equal即===(适合数字、字符串等原始值);deepEqual递归比较对象/数组结构(两个内容相同的对象引用不同,equal永远失败,必须deepEqual)。断言第一个参数是实际值(函数调用结果),第二个是期望值。
五、运作逻辑:一条命令到一份报告
node --test 的完整链路分四个阶段:
- 发现(Discovery):递归扫描目录,按约定匹配
*.test.{js,mjs,cjs},命中即入队。文件命名就是”注册”本身。 - 加载(Loading):每个测试文件在独立子进程中运行,文件之间互不污染。
import被测模块时其顶层代码会真实执行——所以纯函数模块(无副作用)才是node:test的最佳对象。 - 执行(Execution):
describe回调只在注册期执行;it回调按顺序逐个运行,断言抛异常即记为失败。 - 报告(Reporting):按 TAP 协议输出
ok / not ok,最终退出码 0=全过,1=有失败,CI 据此判断构建绿/红。
六、能测什么?一条判断标准
node:test 跑在纯 Node 环境——没有浏览器、没有 DOM、没有 window、没有真实 SDK。判断标准:
顺着被测代码的依赖链找:只要不直接触碰浏览器 API(window/document/DOM/真实 SDK),就能测;碰了,就把”碰”的那一步用注入的替身替换,测替身两侧你的逻辑。
事件总线:最容易测
mitt 是纯 JS 库,不依赖浏览器,测试”发布-订阅”就是它的主场。以购物车事件为例:
describe('cart:item-removed 事件', () => {
it('emit 商品 id 时从购物车移除', () => {
const bus = createBus(); // mitt 实例,Node 里直接创建
const cart = {items: [{id: 1}, {id: 2}]};
bus.on('cart:item-removed', (id) => { // 注册监听
cart.items = cart.items.filter((i) => i.id !== id);
});
bus.emit('cart:item-removed', 1); // 真实触发
assert.deepEqual(cart.items, [{id: 2}]); // 断言结果
});
});
地图实例:测”该不该调、调多少”
真实地图 SDK 实例需要浏览器环境,Node 里创建不了——而且也不该测(缩放对不对是 SDK 自己的事)。把”算级别”和”调地图”拆开,只测纯逻辑:
// resolveMapZoom.js —— 纯逻辑,无副作用
export function resolveMapZoom(category) {
return category === 'detail' ? 18 : 12;
}
describe('resolveMapZoom', () => {
it('detail 场景放大到 18 级', () => {
assert.equal(resolveMapZoom('detail'), 18);
});
});
// 调用侧(组件/工具里)才真正碰地图:
// map.setZoom(resolveMapZoom('detail'));
路由守卫:vue-router 专门留了后门
createMemoryHistory 是 vue-router 为测试/SSR 设计的,守卫可以真实触发。更稳的白盒策略是把决策抽成纯函数,beforeEach 只留一行壳:
// guard.js —— 可测:决策是纯函数
export function resolveAuthRedirect(to, {isLoggedIn}) {
return to.path !== '/login' && !isLoggedIn ? '/login' : null;
}
// 测试:assert.equal(resolveAuthRedirect({path: '/dashboard'}, {isLoggedIn: false}), '/login');
关键理念:决策逻辑(要不要、跳哪、调多少、发什么)→ 能测;平台能力(真实地图、真实 DOM、真实网络)→ 不测,那是依赖的职责。
七、运行方式
node --test src/utils/order/priceCalc.test.mjs # 跑单个文件
node --test src/utils/ # 递归发现所有测试
需要 Node ≥ 18.13。建议在 package.json 里补 "test": "node --test src/utils/"。
八、结语
.test.mjs 是 Node 原生生态给前端工程的一份礼物:零依赖、免配置、进程隔离。它最适合给 utils 里的纯函数写测试——不碰 DOM、不碰组件。判断一件事能不能用 .test.mjs 测,只需要问一句:被测代码的依赖链上,有没有”抽不走、注不进”的浏览器能力?