← 语义化版本 Buffer.copy() 方法 →

Node.js Assert模块单元测试:从基础断言到异步测试与CI集成

著
原创 2026-10-14 Node.js 已有人查阅

断言是测试的最小可信单元

写单元测试时,真正决定测试有没有价值的,不是测试框架多花哨,而是断言写得对不对。Node.js把assert模块直接内置进运行时,不需要额外装包就能开始写断言,这一点在快速验证一段逻辑时非常实用。它的定位很清楚:对代码运行过程中的条件做检查,条件不成立就抛错。测试框架负责组织和执行,assert负责判断对错,两者分工明确。

assert的引入方式在CommonJS和ESM下略有差别:

// CommonJS
const assert = require('assert');

// ESM
import assert from 'node:assert/strict';

这里有个容易踩的细节:node:assert默认是宽松模式,node:assert/strict是严格模式。严格模式下equal等价于strictEqual,deepEqual等价于deepStrictEqual。新项目建议直接引入node:assert/strict,避免下面要讲的宽松比较带来的隐式类型转换问题。

基础断言:ok、equal、strictEqual、deepEqual

基础断言是所有测试的起点。它们的共同特征是:接收实际值、期望值和一条可选消息,不满足时抛出AssertionError。

const assert = require('node:assert/strict');

// ok:判断值是否为真值
assert.ok(true, '真值通过');
assert.ok(1, '非零数字通过');
assert.ok(false, '假值会抛出 AssertionError');

// equal:宽松比较(==)
assert.equal(5, 5, '数字相等');
assert.equal('hello', 'hello', '字符串相等');

// strictEqual:严格比较(===)
assert.strictEqual(5, 5, '类型和值都相等');
assert.strictEqual(5, '5', '类型不同,抛出 AssertionError');

// deepEqual / deepStrictEqual:深层比较
assert.deepStrictEqual({ a: 1 }, { a: 1 }, '对象内容一致');
assert.deepStrictEqual([1, 2, 3], [1, 2, 3], '数组内容一致');

ok只关心真假,适合断言“某个条件成立”;equal用==,存在隐式类型转换;strictEqual用===,不转换类型;deepEqual递归比较对象和数组的内容。在严格模式下,deepEqual对应deepStrictEqual,会同时比较类型和原型。

个人经验里,equal在真实项目中几乎不应该出现。assert.equal(5,'5')会通过,但这类隐式转换在业务代码中往往就是bug的来源。统一使用strictEqual和deepStrictEqual,能省掉后期排查类型问题的大量时间。

错误断言:throws与doesNotThrow

测试错误处理路径时,普通的断言不够用。throws用来验证某个函数确实抛出了错误,doesNotThrow用来验证某个函数不会抛错。

const assert = require('node:assert/strict');

// 验证函数抛出错误
assert.throws(
  () => {
    throw new Error('参数不合法');
  },
  Error,
  '应当抛出 Error'
);

// 用正则匹配错误消息
assert.throws(() => divide(10, 0), /Cannot divide by zero/);

// 验证函数不抛错
assert.doesNotThrow(() => {
  JSON.parse('{"ok": true}');
});

throws的第二个参数可以是构造函数、正则表达式或校验函数。只用构造函数时,断言范围偏宽,任何Error实例都会通过。更精确的做法是同时校验错误消息或错误类型。实际项目中,我倾向于用正则匹配关键错误码或消息片段,这样断言既不会太脆,也能确认走的是预期分支。

类型与错误值断言:ifError

ifError的用途比较特殊,它主要服务于Node.js早期常见的error-firstcallback风格。传入falsy值(null、undefined)时通过,传入真值(尤其是Error实例)时抛出。

const assert = require('node:assert/strict');

assert.ifError(null); // 通过
assert.ifError(undefined); // 通过
assert.ifError(new Error('回调出错')); // 抛出 AssertionError

在Promise和async/await普及之后,ifError的使用频率明显下降。但在维护老代码、封装callback风格API的测试时,它仍然是一个直接有效的判断手段。新代码里更推荐用assert.rejects处理异步错误。

自定义断言:把重复逻辑收进一个函数

内置断言覆盖了大部分场景,但项目里总有一些领域特定的判断。与其在每个测试用例里重复写同一组断言,不如封装成自定义断言函数。

const assert = require('node:assert/strict');

// 自定义断言:验证是正数
function assertPositiveNumber(value, message) {
  assert.strictEqual(
    typeof value,
    'number',
    message || '值必须是 number 类型'
  );
  assert.ok(value > 0, message || '值必须是正数');
}

// 自定义断言:验证代码号格式
function assertCodeNumberFormat(value) {
  assert.match(value, /^CN-\d{6}$/, '代码号格式应为 CN-000000');
}

assertPositiveNumber(5); // 通过
assertPositiveNumber(-1); // 抛出 AssertionError
assertCodeNumberFormat('CN-123456'); // 通过

自定义断言的价值在于把“什么算合法”这件事集中到一个地方。测试用例读起来更接近业务语义,修改校验规则时也只需要改一处。自定义断言内部仍然要调用内置断言来抛错,不要自己thrownewError,否则失败时的错误类型和堆栈信息会不统一。

在Mocha中使用Assert

assert模块本身不负责组织测试,需要配合测试框架。Mocha是搭配assert最常见的选择,describe定义测试套件,it定义单个用例。

const assert = require('node:assert/strict');
const { add, divide } = require('./code-number-math');

describe('code-number-math', () => {
  describe('add', () => {
    it('两数相加返回和', () => {
      assert.strictEqual(add(2, 3), 5);
    });
  });

  describe('divide', () => {
    it('正常相除返回商', () => {
      assert.strictEqual(divide(10, 5), 2);
    });

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

这段结构里,describe可以嵌套,形成清晰的层级;it的描述应当说明预期行为,而不是复述函数名。测试失败时,Mocha会把assert抛出的AssertionError连同消息一起展示出来,消息写得越具体,定位越快。

异步测试的两种写法

异步代码的断言必须在正确的时机执行,否则测试会在Promise完成之前就结束,出现“假通过”。Mocha中处理异步有两种主流写法。

返回Promise:

describe('code-number-fetch', () => {
  it('返回解析后的数据', () => {
    return fetchCodeNumber().then((result) => {
      assert.strictEqual(result.code, 'CN-123456');
    });
  });
});

使用async/await:

describe('code-number-fetch', () => {
  it('返回解析后的数据', async () => {
    const result = await fetchCodeNumber();
    assert.strictEqual(result.code, 'CN-123456');
  });
});

两种写法在断言层面等价,区别在于可读性。async/await更接近同步代码的阅读习惯,调试堆栈也更清晰。测试异步错误时,用assert.rejects比手动try/catch更直接:

it('请求失败时抛出错误', async () => {
  await assert.rejects(() => fetchCodeNumber({ fail: true }), /network error/);
});

用断言做开发期调试

断言不只属于测试文件。在开发阶段,把关键假设写成断言,可以让问题在离现场更近的地方暴露。

const assert = require('node:assert/strict');

function divide(a, b) {
  assert.notStrictEqual(b, 0, 'Cannot divide by zero');
  return a / b;
}

divide(10, 2); // 5
divide(10, 0); // 抛出 AssertionError: Cannot divide by zero

这种写法的取舍需要明确:断言适合表达“这里绝不应该发生”的内部假设,不适合做用户输入的校验。用户输入应当走正常的参数校验和错误返回,而不是靠断言中断流程。生产环境如果担心断言开销,可以在构建阶段通过工具移除,但更稳妥的做法是区分清楚哪些断言是开发期契约,哪些是运行时逻辑。

与CI流水线集成

测试写完只是第一步,让它自动跑起来才有持续价值。GitHubActions里配置Node.js测试流程的典型写法:

name: CI

on:
  push:
    branches:
      - main

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

这里用npmci而不是npminstall,原因是CI需要可复现的安装结果,npmci严格依据package-lock.json安装,不会修改锁文件。断言失败时进程以非零状态码退出,流水线随即标记为失败,回归问题就能在合并前被拦住。

项目实例:一个完整的测试文件

把上面的内容串起来,一个围绕代码号解析的测试文件大致长这样:

const assert = require('node:assert/strict');
const { parseCodeNumber, formatCodeNumber } = require('./code-number-parser');

describe('code-number-parser', () => {
  it('解析合法代码号', () => {
    const result = parseCodeNumber('CN-123456');
    assert.deepStrictEqual(result, { prefix: 'CN', serial: 123456 });
  });

  it('非法格式抛出错误', () => {
    assert.throws(() => parseCodeNumber('123456'), /Invalid code number/);
  });

  it('格式化后保持六位序列号', () => {
    assert.strictEqual(formatCodeNumber({ prefix: 'CN', serial: 42 }), 'CN-000042');
  });

  it('异步校验通过', async () => {
    await assert.doesNotReject(() => validateCodeNumber('CN-123456'));
  });
});

这个文件覆盖了正常路径、错误路径、格式化边界和异步校验。每个断言都指向一个明确的行为,失败时消息足以定位问题。

优势与局限的客观看待

assert模块的优势在于零依赖、API直观、与Node.js运行时一致。对大多数中小型项目和工具库来说,它足够完成单元测试的断言工作。

局限也很实际。它的断言风格偏底层,复杂对象比较失败时的差异输出不如一些第三方断言库友好;异步支持依赖测试框架的调度机制,单独使用assert无法组织异步用例;错误处理集中在断言失败上,对测试代码自身的异常缺少统一封装。项目规模变大、断言场景变复杂之后,引入Chai、Expect这类库或者直接使用Jest的内置断言是常见选择。但理解assert的行为,仍然是读懂这些库底层逻辑的基础。

练习与思考

  1. assert.equal(1,'1')和assert.strictEqual(1,'1')的结果分别是什么?为什么项目中应避免前者?

  2. 用assert.throws验证一个函数抛出TypeError,同时要求错误消息包含invalid,应该怎么写?

  3. 异步测试中如果忘记return或await,测试可能出现什么结果?如何排查?

  4. 自定义断言assertCodeNumberFormat如果要同时校验长度和前缀,内部应该组合哪些内置断言?

assert模块不复杂,但把它的每个断言用对、用准,需要对比较规则、错误匹配和异步时机有清楚的认识。测试的可信度来自断言的精确度,而不是用例的数量。把断言写扎实,测试才能真正起到拦住回归的作用。

← 语义化版本 Buffer.copy() 方法 →
分享笔记 (共有 篇笔记)
验证码:
微信公众号