一、断言测试解决的是什么问题
写业务代码时,一个函数写完,开发者通常会在脑子里过一遍:传这个参数应该返回那个结果。这种"过一遍"就是最原始的验证。问题在于,人脑的验证不可重复,改了几行代码之后,之前验证过的逻辑可能已经被破坏,而开发者未必记得再跑一遍。
断言测试把这种验证固化下来。它的基本形式是一句判断:实际拿到的值,和预期的值是否一致。一致就通过,不一致就抛出AssertionError,中断执行并告知哪里的预期落空了。Node.js把这类判断能力内置到了assert模块,不需要引入Jest、Mocha这类测试框架就能用。
需要区分一个概念:assert模块本身只是"判断工具",它不负责组织测试、不负责生成报告。真正让测试跑起来的是node:test这样的测试运行器。两者配合使用,才是完整的单元测试方案。
二、assert模块的导入与基本判定
Node.js推荐用node:前缀导入内置模块:
const assert = require('node:assert');
也可以导入严格模式版本:
const assert = require('node:assert/strict');
两者的区别在于:严格模式下,equal等价于strictEqual,deepEqual等价于deepStrictEqual。项目里我倾向于直接用严格模式,理由是松散比较的隐式类型转换在测试场景中弊大于利——测试要的是精确判定,不是"差不多就行"。
三、常用断言方法逐一说明
assert模块的方法可以按判定对象分成几组。
真值判定组:assert()和assert.ok()是同一个函数,判断传入值是否为真值。assert.ifError()逻辑相反,传入真值时抛错,传入null或undefined时静默通过。ifError在回调风格的代码里比较实用,用来断言err参数为空。
相等判定组:equal用==比较,notEqual用!=比较,这两个是松散比较。strictEqual用===,notStrictEqual用!==,这两个是严格比较。日常写测试,strictEqual的使用频率远高于equal。
深度判定组:deepEqual和deepStrictEqual用于比较对象或数组的内部结构。区别同样在于是否做类型严格校验。deepStrictEqual会检查原型链和类型,deepEqual不会。
异常判定组:throws验证函数是否抛出错误,doesNotThrow验证函数不抛错。异步场景对应rejects和doesNotReject。
主动失败组:fail()直接抛出AssertionError,通常用在switch的default分支或理论上不该到达的代码路径。
| 方法 | 判定规则 | 典型用途 |
|---|---|---|
assert.ok(value) |
value为真值则通过 | 通用真值校验 |
assert.strictEqual(a,b) |
a===b则通过 | 基本类型精确比较 |
assert.notStrictEqual(a,b) |
a!==b则通过 | 确认两值不同 |
assert.deepStrictEqual(a,b) |
结构相同且类型严格一致 | 对象、数组比较 |
assert.throws(fn,error) |
fn抛出匹配error的异常 | 校验同步报错逻辑 |
assert.rejects(asyncFn,error) |
Promise以匹配error拒绝 | 校验异步报错逻辑 |
assert.ifError(err) |
err为假值则通过 | 回调错误参数检查 |
assert.fail(msg) |
无条件抛出AssertionError | 标记不可达分支 |
四、assert.throws的参数结构与匹配规则
throws的完整签名是assert.throws(fn[,error][,message])。
第一个参数fn是待验证的函数,assert会调用它并捕获抛出的异常。第二个参数error是可选参数,用来描述"期望的错误长什么样",它可以是构造函数、正则表达式、校验函数或对象。第三个参数message是断言失败时附加的提示信息。
const assert = require('node:assert');
function parseAge(input) {
const age = Number(input);
if (Number.isNaN(age)) {
throw new TypeError('年龄必须是数字');
}
return age;
}
assert.throws(
() => parseAge('abc'),
TypeError
);
console.log('类型错误校验通过');
error传TypeError时,assert会检查抛出的异常是否为TypeError的实例。如果传正则表达式,则匹配错误消息文本:
assert.throws(
() => parseAge('abc'),
/年龄必须是数字/
);
两种写法各有适用场景。传构造函数适合校验错误类型,传正则适合校验错误消息内容。项目里如果错误消息会随版本调整,用构造函数更稳定;如果错误类型统一但需要区分具体原因,用正则更精确。
这里有个细节值得注意:throws返回的是被抛出的错误对象。也就是说可以把它赋值给变量,后续继续检查错误的属性:
const err = assert.throws(() => parseAge('abc'), TypeError);
console.log(err.message);
五、assert.rejects与异步错误校验
异步函数抛出的错误不会同步传播,throws捕获不到。这时要用rejects。
const assert = require('node:assert');
async function fetchUser(id) {
if (!id) {
throw new Error('用户 ID 不能为空');
}
return { id, name: '代码号学习编程' };
}
async function runTest() {
await assert.rejects(
() => fetchUser(''),
/用户 ID 不能为空/
);
console.log('异步错误校验通过');
}
runTest();
rejects的第一个参数可以是Promise,也可以是返回Promise的函数。传函数的好处是assert会负责调用它并处理同步抛错的情况。如果直接传一个已经rejected的Promise,且在传入之前没有处理拒绝,Node.js可能触发unhandledRejection警告。所以更稳妥的写法是传函数。
rejects返回的是一个Promise,必须用await等待,否则断言结果不会被正确捕获。这一点在写测试时容易漏掉,漏掉的表现是测试"通过了"但实际上什么都没校验。
六、配合node:test组织测试用例
assert负责判断,node:test负责组织。node:test模块提供了test()函数来定义测试用例。
const test = require('node:test');
const assert = require('node:assert');
function add(a, b) {
return a + b;
}
test('代码号学习编程 - 加法函数测试', () => {
assert.strictEqual(add(2, 3), 5);
});
把这段代码保存为add.test.js,在命令行执行node--testadd.test.js,会看到TAP格式的输出,包含测试名称、通过状态和耗时。TAP是TestAnythingProtocol的缩写,一种跨语言的测试报告格式。
测试用例可以嵌套,用t.test()或者describe/it风格组织:
const test = require('node:test');
const assert = require('node:assert');
test('用户模块', async (t) => {
await t.test('ID 为空时应该报错', async () => {
await assert.rejects(
() => fetchUser(''),
/用户 ID 不能为空/
);
});
await t.test('正常 ID 返回用户对象', async () => {
const user = await fetchUser(1);
assert.strictEqual(user.name, '代码号学习编程');
});
});
这种嵌套结构在测试报告里会体现出层级关系,出问题时能快速定位是哪个子场景失败。
七、经验分享:strictEqual与deepStrictEqual的选型
项目里经常看到两种写法混用的情况。我的建议是按数据类型分:
基本类型(数字、字符串、布尔、null、undefined)用strictEqual。对象和数组用deepStrictEqual。原因很直接:对象用strictEqual比较的是引用地址,两个内容相同的对象会判定为不相等,这不是测试想要的结果。
const assert = require('node:assert');
assert.deepStrictEqual(
{ name: '代码号学习编程', level: 1 },
{ name: '代码号学习编程', level: 1 }
);
deepStrictEqual会递归比较每个属性的值和类型。如果对象里嵌套了Date、RegExp这类特殊对象,它也能正确比较。但要注意,deepStrictEqual不比较原型链上的方法,只比较自有属性。
另一个经验是:不要用deepEqual(松散版本)。松散深度比较在遇到1和'1'时判定为相等,这在项目里往往是bug而不是预期行为。严格模式下的deepStrictEqual能避免这类误判。
八、错误处理与断言消息
断言失败时抛出的AssertionError会包含actual、expected、operator等属性,测试运行器会把这些信息格式化输出。如果觉得默认信息不够明确,可以在方法末尾加message参数:
assert.strictEqual(
add(2, 3),
5,
'加法函数返回值不符合预期'
);
这条消息会出现在失败报告中,帮助定位问题。在参数化测试或多场景测试里,加上有意义的message能省不少排查时间。
还有一点:assert的断言失败是抛异常,不是返回值。所以它不能被if包裹,也不能用try/catch吞掉后当作通过。断言失败就应该让测试失败,这是它的职责。
九、本节课程知识要点
-
assert模块是Node.js内置模块,用
require('node:assert')导入,严格模式用node:assert/strict -
strictEqual用于基本类型精确比较,deepStrictEqual用于对象和数组结构比较 -
throws校验同步函数抛错,rejects校验异步函数或Promise拒绝,两者都支持构造函数、正则、对象、校验函数四种匹配方式 -
rejects第一个参数建议传函数而非已创建的Promise,避免unhandledRejection -
rejects返回Promise,必须await,否则断言不生效 -
配合
node:test的test()函数组织用例,用node--test运行,输出TAP格式报告 -
断言失败抛AssertionError,不能也不应该被catch后忽略
十、练习与思考
-
写一个
divide(a,b)函数,当b为0时抛出RangeError,用assert.throws校验这一行为,并额外用正则匹配错误消息。 -
写一个返回Promise的
fetchConfig()函数,在配置缺失时reject,用assert.rejects校验,并尝试传构造函数和正则两种error参数,观察差异。 -
用
node:test为一个包含三个方法的工具模块写测试,其中至少有一个测试用deepStrictEqual比较返回的对象。 -
思考:如果某个断言在循环里反复执行,失败时如何让报告能定位到是第几次迭代出的问题?
assert模块的API数量不多,但每个方法的判定规则有细微差别,尤其是松散比较和严格比较、同步校验和异步校验这两组区分。实际项目里,先把strictEqual、deepStrictEqual、throws、rejects这四个用熟,配合node:test就能覆盖大部分单元测试场景。遇到断言结果不符合直觉时,先确认用的是不是严格版本,再检查比较的数据类型是否一致,多数问题都出在这两点上。