本节摘要:单元测试验证最小可测单元的行为,Jest 是 Node 生态的事实标准。本节讲测试的组织结构、matcher 断言体系、异步测试的三种写法与超时控制,以及测试替身的基本功。重点在异步:Node 代码的单元几乎都返回 Promise 或触发事件,断言「未来的值」需要专门的手法。
被测对象是一个纯函数——折扣计算:
// 被测: 折扣计算 function calcDiscount(price, rate) { if (typeof price !== 'number' || price < 0) { throw new Error('价格非法'); } const r = Math.min(Math.max(rate, 0), 1); return Math.round(price * r * 100) / 100; } module.exports = { calcDiscount };
测试文件与测试的结构:
const { calcDiscount } = require('./discount'); describe('折扣计算', () => { it('正常计算并保留两位小数', () => { expect(calcDiscount(100, 0.85)).toBe(85); expect(calcDiscount(33.3, 0.5)).toBe(16.65); }); it('折扣率越界时收敛到有效区间', () => { expect(calcDiscount(100, 1.5)).toBe(100); expect(calcDiscount(100, -0.2)).toBe(0); }); it('非法价格抛错', () => { expect(() => calcDiscount(-5, 0.5)).toThrow('价格非法'); }); });
matcher 是断言的词汇表,常用的就十来个:toBe(同值)、toEqual(深比较)、toMatchObject(部分匹配)、toThrow(抛错)、resolves/rejects(Promise 落点)、toHaveLength、toContain。选词的原则:断言越具体,失败信息越有用——用 toBe(85) 而不是 toBeTruthy(),挂掉时你能直接看到 85 与 84.999 的差别。
Node 的单元多半是异步的。Jest 的规则:it 的回调返回什么,Jest 就等什么。三种等价写法:
const { getUser } = require('./user-service'); // 写法一:返回 Promise it('返回用户', () => { return getUser(7).then(u => { expect(u.id).toBe(7); }); }); // 写法二:await(推荐) it('返回用户', async () => { const u = await getUser(7); expect(u.id).toBe(7); }); // 写法三:resolves/rejects 匹配器 it('不存在时拒绝', async () => { await expect(getUser(-1)).rejects.toThrow('用户不存在'); });
最隐蔽的坑是忘了 return 或 await:it 同步结束、Jest 认为通过,断言其实从未执行——绿灯是假的。规矩很简单:it 回调里出现 Promise 就必须 return 或 await,一条不许漏。
超时参数也值得知道:默认 5 秒,慢测试可以单独放宽,但更好的做法是先问「为什么慢」——单元测试超过几百毫秒通常说明它其实是个披着单元外衣的集成测试。
it('慢但必要的场景', async () => { // ... }, 10000); // 第三个参数放宽到 10 秒
测「发邮件」不能真发邮件。替身的核心 API 是 jest.fn——一个可编程的假函数:
const notify = jest.fn(); const service = createOrderService({ notify }); // 依赖注入 it('下单成功后通知一次', async () => { await service.place({ sku: 'A1', qty: 2 }); expect(notify).toHaveBeenCalledTimes(1); expect(notify).toHaveBeenCalledWith(expect.objectContaining({ sku: 'A1' })); });
注意这里用的手法是依赖注入而非黑盒替换:把 notify 作为参数传进去,测试传假、生产传真。这让代码本身也变得更可测——可测试性从来不只是测试技巧,更是设计反馈。
被测代码里有 setTimeout 或 setInterval 怎么办?真等几十秒是自杀。Jest 的假时钟把时间变成可拨动的旋钮:
describe('重试退避', () => { beforeEach(() => jest.useFakeTimers()); afterEach(() => jest.useRealTimers()); it('失败后按退避间隔重试', async () => { const fn = jest.fn().mockRejectedValueOnce(new Error('网络抖动')); const task = retry(fn, { times: 3, delayMs: 1000 }); jest.advanceTimersByTimeAsync(1000); // 快进 1 秒 await task; expect(fn).toHaveBeenCalledTimes(2); }); });
假时钟的本质是把「等时间」变成「推时间」——事件循环里排队的定时器回调被快进触发,测试从分钟级降到毫秒级。凡是依赖时间的逻辑(过期、节流、重试、轮询)都该用它。

反金字塔(大量端到端、缺少单元)的团队会陷入「测试一跑四十分钟,大家开始跳过失败的用例」的泥潭——金字塔不是教条,是维护成本的账。
jest --coverage 输出行覆盖、分支覆盖等指标。读法要点:覆盖率是排除器不是目标——90% 的数字不能证明测得好,但 30% 能立刻告诉你哪些模块完全裸奔。实践里把关键业务模块的阈值设进 CI(如 statements 80%),全局数字仅供参考。被断言行数堆出来的高覆盖更是假象:跑过不等于验证过。
测试腐烂的头号原因是「红了就跳过」。跳过的测试用例会越积越多,最后整个套件名存实亡。可执行的纪律有三条:跳过必须写明原因与责任人,定期清理;失败在二十四小时内要么修复要么回滚引发它的改动;CI 上跑全量,本地开发用文件级过滤保持秒级反馈。测试体系的价值取决于最弱的一条纪律。
最后定一个肌肉记忆:断言失败时先读 Jest 打出的差异说明,再决定看哪段代码——差异里往往直接写着「期望 85、实际 84.999」,一行浮点精度问题就此现形,根本不用打开调试器。学会信任并精读测试输出,是新手到熟手之间性价比最高的一步。