Node.js 24:用内置 test runner 给参数校验做零依赖测试

231 次浏览3 条回复

Node.js 自带的 node:test 足以覆盖不少小型脚本和 CLI,无需先引入测试框架。下面用一个参数校验函数演示最小可运行结构。

环境:Node.js 24.x;Linux、macOS 或 Windows;项目使用 ESM。

先创建 package.json:

{
  "type": "module",
  "scripts": {
    "test": "node --test"
  }
}

创建 limit.js:

export function parseLimit(raw) {
  const value = Number(raw);

  if (!Number.isInteger(value) || value < 1 || value > 100) {
    throw new RangeError('limit must be an integer from 1 to 100');
  }

  return value;
}

创建 limit.test.js:

import test from 'node:test';
import assert from 'node:assert/strict';
import { parseLimit } from './limit.js';

test('accepts the supported boundaries', () => {
  assert.equal(parseLimit('1'), 1);
  assert.equal(parseLimit('100'), 100);
});

test('rejects invalid values', () => {
  for (const input of ['0', '101', '1.5', 'abc', '']) {
    assert.throws(
      () => parseLimit(input),
      { name: 'RangeError' },
      `input: ${JSON.stringify(input)}`
    );
  }
});

运行:

npm test

调试单个用例时,可以按名称过滤:

node --test --test-name-pattern=boundaries

这里有两个值得保留的细节:使用 node:assert/strict,避免宽松相等掩盖类型问题;错误断言先约束错误类型,错误文案以后调整时测试不会无谓失效。对于纯函数、参数解析和小型 CLI,这套结构通常已经够用。

这套用例已经覆盖了数值范围,但 CLI 参数还有一个容易遗漏的边界:Number() 会接受多种数字语法,例如 Number('1e2') === 100、Number('0x10') === 16,因此当前实现会放行指数或十六进制形式。

如果参数契约是“只接受十进制数字串 1 到 100”,可以先校验字面形式,再转换:

export function parseLimit(raw) {
  if (typeof raw !== 'string' || !/^(?:[1-9][0-9]?|100)$/.test(raw)) {
    throw new RangeError('limit must be an integer from 1 to 100');
  }

  return Number(raw);
}

对应补一组回归用例:

test('rejects alternate numeric syntax', () => {
  for (const input of ['1e2', '0x10', '01', ' 1 ', '+1']) {
    assert.throws(() => parseLimit(input), { name: 'RangeError' });
  }
});

如果产品希望容忍首尾空格,也建议显式 trim(),并把这一行为写进测试;这样参数语法是由代码契约决定,而不是由 Number() 的隐式转换规则决定。

纯函数用例之外,CLI 还值得补一层进程边界测试:参数有没有真正传入、输出去了哪个流、失败时退出码是否稳定。这些行为在直接调用 parseLimit() 时覆盖不到。

环境:Node.js 24.x;Linux、macOS 或 Windows;沿用楼主的 ESM 项目和 limit.js。新增 cli.js:

#!/usr/bin/env node
import { parseLimit } from './limit.js';

try {
  console.log(parseLimit(process.argv[2]));
} catch (error) {
  console.error(error.message);
  process.exitCode = 2;
}

再新增 cli.test.js。使用 process.execPath 可以确保子进程与测试运行器使用同一个 Node,fileURLToPath() 则兼容 Windows 路径:

import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';

const cli = fileURLToPath(new URL('./cli.js', import.meta.url));

function runCli(argument) {
  return spawnSync(process.execPath, [cli, argument], {
    encoding: 'utf8'
  });
}

test('prints a validated limit', () => {
  const result = runCli('10');

  assert.equal(result.status, 0);
  assert.equal(result.stdout.trim(), '10');
  assert.equal(result.stderr, '');
});

test('reports invalid input through stderr and exit code', () => {
  const result = runCli('0');

  assert.equal(result.status, 2);
  assert.equal(result.stdout, '');
  assert.match(result.stderr, /limit must be an integer/);
});

仍然直接运行 npm test 即可。这里把参数错误约定为退出码 2,便于调用方把“用法错误”与程序内部故障区分开;具体数值可以调整,但应由集成测试固定下来。

maplecedarLv1#2

纯函数用例之外,CLI 还值得补一层进程边界测试:参数有没有真正传入、输出去了哪个流、失败时退出码是否稳定。这些行为在直接调用 parseLimit() 时覆盖不到。

环境:Node.js 24.x;Linux、macOS 或 Windows;沿用楼主的 ESM 项目和 limit.js。新增 cli.js:

#!/usr/bin/env node
import { parseLimit } from './limit.js';

try {
  console.log(parseLimit(process.argv[2]));
} catch (error) {
  console.error(error.message);
  process.exitCode = 2;
}

再新增 cli.test.js。使用 process.execPath 可以确保子进程与测试运行器使用同一个 Node,fileURLToPath() 则兼容 Windows 路径:

import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';

const cli = fileURLToPath(new URL('./cli.js', import.meta.url));

function runCli(argument) {
  return spawnSync(process.execPath, [cli, argument], {
    encoding: 'utf8'
  });
}

test('prints a validated limit', () => {
  const result = runCli('10');

  assert.equal(result.status, 0);
  assert.equal(result.stdout.trim(), '10');
  assert.equal(result.stderr, '');
});

test('reports invalid input through stderr and exit code', () => {
  const result = runCli('0');

  assert.equal(result.status, 2);
  assert.equal(result.stdout, '');
  assert.match(result.stderr, /limit must be an integer/);
});

仍然直接运行 npm test 即可。这里把参数错误约定为退出码 2,便于调用方把“用法错误”与程序内部故障区分开;具体数值可以调整,但应由集成测试固定下来。

进程边界测试还可以先区分两类失败:CLI 按约定返回非零退出码,以及测试进程根本没有正常启动或被超时终止。spawnSync() 在后一种情况下可能把 status 设为 null,并通过 error 或 signal 报告原因;如果直接断言 status === 2,定位会比较绕。

可以给 helper 加超时、输出上限和启动错误检查,同时改成剩余参数,便于准确测试“完全没有参数”的场景:

function runCli(...args) {
  const result = spawnSync(process.execPath, [cli, ...args], {
    encoding: 'utf8',
    timeout: 2_000,
    maxBuffer: 64 * 1024
  });

  if (result.error) {
    throw result.error;
  }
  if (result.signal !== null) {
    throw new Error(`CLI terminated by ${result.signal}`);
  }

  return result;
}

test('rejects a missing argument', () => {
  const result = runCli();

  assert.equal(result.status, 2);
  assert.equal(result.stdout, '');
  assert.match(result.stderr, /limit must be an integer/);
});

环境仍是 Node.js 24.x、ESM,直接运行 npm test。这里的 timeout 不是业务行为断言,而是防止 CLI 回归成等待输入或事件循环不退出时拖住整套测试;maxBuffer 则让异常的大量输出尽早以明确错误失败。