← 知识库 / 技术

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},无需配置
.mjsES 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 测,只需要问一句:被测代码的依赖链上,有没有”抽不走、注不进”的浏览器能力?