首页 / Node.js 教程 / 内置测试运行器 node:test

Node.js 教程

内置测试运行器 node:test

本教程共 76 篇 · 第 60 篇 · 更新于 2026-07-25 · 约 8 分钟阅读

Node.js测试node:testmock覆盖率

60. 内置测试运行器 node:test

本节目标:- 用 node:test 写第一批测试,并用 node --test 运行 - describe 分组、it 写用例,配合 node:assert/strict 断言 - before/after/beforeEach/afterEach 钩子怎么用 - 内置 mock:函数替身、模块替身、时间控制 - 生成覆盖率报告

写代码不难,难的是改了 A 之后不知道 B 有没有跟着坏。测试(Testing)就是给代码上保险:你写一小段程序去验证另一段程序,跑通了就说明它还活着。Node.js 从 v20 起把测试运行器做成了内置模块 node:test,不用装任何依赖就能跑。这对小项目特别友好——你不必为了写几个测试就引入一整套框架。

这一章我带你把 node:test 用熟:怎么组织 describe/it、怎么断言、怎么处理异步、怎么用钩子搭环境,再到内置的 mock 和覆盖率报告。

写第一个测试

先建一个最简单的项目。不需要任何安装,Node.js 自带一切。

// math.js
export function add(a, b) {
  return a + b;
}

export function divide(a, b) {
  if (b === 0) {
    throw new Error('Division by zero');
  }
  return a / b;
}

测试文件通常叫 math.test.js,放在 test/ 目录里。注意「本节学什么」要写在文件开头。

// test/math.test.js
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { add, divide } from '../math.js';

describe('math 模块', () => {
  it('add 能正确相加', () => {
    assert.equal(add(1, 2), 3);
    assert.equal(add(-1, 1), 0);
  });

  it('divide 能正确相除', () => {
    assert.equal(divide(10, 2), 5);
  });

  it('divide 除零时抛错', () => {
    assert.throws(() => divide(10, 0), /Division by zero/);
  });
});

describe 负责把相关的用例归到一组,it(也可以写成 test)是具体的一条用例。assert 就是校验:不成立就报错,测试标记为失败。assert/strict 比基础的 assert 更严格,equal=== 比较,不会因为 '1' == 1 这种隐式转换而「误判通过」。

Tip

node:assert/strict,别用 node:assert。宽松模式下的 assert.equal==,类型不同时对不上号,坑过不少人。

运行测试

在 v24 LTS 里运行测试特别简单:

node --test

这条命令会自动扫描项目里匹配这些模式的文件:

  • **/*.test.js(和 .ts.mjs 等同族扩展名)
  • **/*.spec.js
  • **/test-*.js
  • **/test/*.js

只跑某一个文件也行:

node --test test/math.test.js

跑起来你会看到绿色的勾,以及每条用例耗时。失败的话会打印出断言期望值和实际值的差异,定位很方便。

Note

如果你用的是 CommonJS(require),把 import 改成 const { describe, it } = require('node:test') 即可。node:test 对 ESM 和 CommonJS 都支持,本书默认 ESM-first。

异步测试怎么写

Node.js 天生异步,测试异步代码也是一等公民。只要把用例函数写成 async,或者返回一个 Promise,运行器就会等它结束:

import assert from 'node:assert/strict';
import { describe, it } from 'node:test';

function delay(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

describe('异步示例', () => {
  it('等待 Promise 完成', async () => {
    await delay(10);
    assert.ok(true);
  });

  it('验证 reject', async () => {
    await assert.rejects(
      async () => { throw new Error('boom'); },
      /boom/
    );
  });
});

assert.rejects 专门测 Promise 被拒的情况,等于给异步抛错上了一道保险。

钩子:搭环境与收摊子

真实项目里,测试前要连数据库、要造假数据;测试后要把它们清掉。这些活儿交给钩子(Hook)最合适:

  • before:这一组用例开始前跑一次
  • after:这一组用例全结束后跑一次
  • beforeEach:每条用例前都跑
  • afterEach:每条用例后都跑
import assert from 'node:assert/strict';
import { describe, it, before, after, beforeEach, afterEach } from 'node:test';

describe('带钩子的套件', () => {
  let users = [];

  before(() => {
    console.log('套件启动,准备数据');
    users = [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }];
  });

  beforeEach(() => {
    console.log('每条用例前重置状态');
  });

  it('用户数量是 2', () => {
    assert.equal(users.length, 2);
  });

  after(() => {
    console.log('套件结束,清理数据');
    users = [];
  });
});
Warning

钩子里如果抛了错,会影响整组用例。特别是 after 里做清理时,最好别在里面再抛异常,否则可能掩盖真正的问题。

跳过与待办

有些用例还没写好,或者只在特定平台跑,可以用选项跳过:

import { describe, it } from 'node:test';

describe('条件执行', () => {
  it('在 Windows 上跳过', { skip: process.platform === 'win32' }, () => {
    // 不会在 Windows 上跑
  });

  it('以后补上', { todo: true }, () => {
    // 会被标记为 TODO,不算失败
  });
});

skip 是彻底不跑,todo 是标记为「待实现」但也不算失败。这俩在重构大活儿时挺救命——你可以先把用例骨架列出来,再一条条填。

内置 mock:造个假替身

测试讲究「隔离」。比如你要测一个函数,它依赖发 HTTP 请求,你总不能每次测试都真去请求外网。这时候就需要 mock——造一个假的替身,只验证你的代码有没有按预期去调用它。

Node.js 的 mock 来自测试运行器,能替你生成函数替身、模块替身,甚至控制时间。

函数替身 mock.fn

import assert from 'node:assert/strict';
import { describe, it, mock } from 'node:test';

function processUser(user, logger) {
  if (!user.name) {
    logger.error('User has no name');
    return false;
  }
  logger.info(`Processing user: ${user.name}`);
  return true;
}

describe('processUser', () => {
  it('有名字时调用 info', () => {
    const logger = {
      info: mock.fn(),
      error: mock.fn(),
    };

    const result = processUser({ name: 'Alice' }, logger);

    assert.equal(result, true);
    assert.equal(logger.info.mock.callCount(), 1);
    assert.equal(logger.error.mock.callCount(), 0);
  });
});

mock.fn() 造出的函数会记录自己被调了多少次、传了什么参数。用 callCount() 就能断言调用次数——这正是「验证行为」而不是「验证结果」的测试思路。

模块替身 mock.module

有时候你要替换掉整个被导入的模块。注意顺序:必须先设好 mock,再用动态 import() 去引入那个依赖它的模块,否则 mock 就来不及生效。

import assert from 'node:assert/strict';
import { describe, it, before, mock } from 'node:test';

describe('用模块替身', () => {
  let foo;

  before(async () => {
    mock.module('./bar.js', {
      namedExports: { bar: mock.fn(() => 42) },
    });

    // 必须是动态 import,且要在 mock 之后
    ({ foo } = await import('./foo.js'));
  });

  it('foo 调用了被替换的 bar', () => {
    assert.equal(foo(), 42);
  });
});
Note

mock.module 需要 --experimental-test-module-mocks 标志(部分版本)。而且 Node.js 会在每个测试后自动还原 mock,通常你不需要手动 restore(),省心不少。

控制时间 mock.timers

要测一个「三分钟后才触发」的逻辑,难不成真等三分钟?用 mock.timers 把时间拨快:

import assert from 'node:assert/strict';
import { describe, it, mock } from 'node:test';

function formatDelay(start) {
  const diff = Date.now() - start;
  return `${Math.round(diff / 1000)} seconds ago`;
}

describe('时间控制', () => {
  it('把时间往前拨', () => {
    mock.timers.enable({ now: new Date('2024-01-01T00:00:00Z') });
    const start = Date.now();
    mock.timers.setTime(new Date('2024-01-01T00:02:00Z'));
    assert.equal(formatDelay(start), '120 seconds ago');
  });
});

mock.timerssetTimeoutDate.now 这些都接管了,你让时间怎么走它就怎么走。

覆盖率报告

测试写了,但到底测了多少代码?看覆盖率(Coverage)。Node.js 通过 V8 自带的覆盖率能力来生成:

NODE_V8_COVERAGE=./coverage node --test

跑完后在 ./coverage 目录会生成一堆原始数据。想看人话版本,可以配合 c8

npm i -D c8
npx c8 node --test

自 v22 起还有一个实验性的内置开关 --experimental-test-coverage,能直接在测试输出里带上行级覆盖率,无需额外工具:

node --test --experimental-test-coverage
Tip

覆盖率不是越高越好。100% 覆盖率也可能全是没营养的断言。优先把核心逻辑、边界条件、错误分支覆盖到,比追数字有意义得多。

更顺手的运行姿势

测试跑多了,你会想要更多控制。几个常用开关:

# 只跑名字里带 "user" 的用例
node --test --test-name-pattern="user"

# 换输出格式:spec 层级清晰,dot 极简,tap 机器友好,junit 给 CI 用
node --test --test-reporter=spec

# 改文件自动重跑(开发神器)
node --test --watch

--watch 配合 --test 是我本地写代码时的标配:改一下实现,终端里测试立刻重跑,红绿一目了然。

组织测试目录

小项目把测试丢进 test/ 就够了。稍微大点的,按类型分一分更清楚:

project/
├── src/
│   ├── math.js
│   └── user.js
└── test/
    ├── unit/
    │   ├── math.test.js
    │   └── user.test.js
    └── integration/
        └── api.test.js

「单元」测单个函数,「集成」测多个模块拼起来(比如真连一下数据库或起一个 HTTP 服务)。这样 CI 里可以只跑快速的单元测试,集成测试单独跑,反馈更快。