断言是测试的最小可信单元
写单元测试时,真正决定测试有没有价值的,不是测试框架多花哨,而是断言写得对不对。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的行为,仍然是读懂这些库底层逻辑的基础。
练习与思考
-
assert.equal(1,'1')和assert.strictEqual(1,'1')的结果分别是什么?为什么项目中应避免前者? -
用
assert.throws验证一个函数抛出TypeError,同时要求错误消息包含invalid,应该怎么写? -
异步测试中如果忘记
return或await,测试可能出现什么结果?如何排查? -
自定义断言
assertCodeNumberFormat如果要同时校验长度和前缀,内部应该组合哪些内置断言?
assert模块不复杂,但把它的每个断言用对、用准,需要对比较规则、错误匹配和异步时机有清楚的认识。测试的可信度来自断言的精确度,而不是用例的数量。把断言写扎实,测试才能真正起到拦住回归的作用。