Node.js 测试与 mock:单元测试与模块边界

在 Node 里写测试,表面上是调 Jest / Vitest / node:test,真正费劲的往往是:异步怎么断言、模块怎么替换、I/O 要不要打桩
这一篇不对比框架细节,而是想讲清楚几件事:测什么算「单元」、mock 在破坏什么边界、以及和 ESM/CJS 混用相关的常见坑

可以粗分为三层(名字各家略有出入):

  • 单元测试:单个模块、可控输入,外部依赖尽量假换或桩掉;
  • 集成测试:几个真实模块 + 真实或容器化的依赖(如本机 Redis、Testcontainers);
  • 端到端:整服务、真实 HTTP、黑盒行为。

Node 服务端最常见的浪费是:

  • 用集成测试冒充单元测试 → 慢、飘、难定位;
  • 单元测试里到处连真实网络 → 不稳定。

先想清楚「这条用例要证明什么」,再选层级。

现代测试框架都支持 async 测试函数,核心是:

  • 每个异步分支都要 await 或 return Promise,否则用例可能 先绿后红(断言没跑到就结束);
  • fake timersetTimeout 等)要显式切模式,否则和真实时间混在一起会 flaky。

也就是说,异步测试的可靠性,一半在框架,一半在 你是否把异步链路写完整

mock / stub 常见用途:

  • 固定时间、随机数、UUID,让输出可断言;
  • 替换网络、文件、环境变量,避免副作用;
  • 验证某依赖是否被以何种参数调用(间谍 spy)。

代价是:

  • mock 过多的测试会绑死在实现细节上,重构一行就全红;
  • 掩盖真实集成问题——模块之间「协议」错了,单测仍全绿。

更稳的习惯:

  • 对外部系统:用 接口层(repository、client)集中 mock;
  • 对复杂纯函数:尽量 输入输出断言,少断言「调了几次内部函数」。

在 CJS 时代,不少测试工具用 重写 require 缓存 注入 mock。

ESM 下:

  • 静态 import 更不利于运行时替换;
  • 需要 import()依赖注入、或 测试专用入口 等模式配合。

工程含义:

  • 可测性 increasingly 等于 可注入依赖
  • 新代码里把 IO 与纯逻辑 分开,比事后补 mock 更省力。

常见手段:

  • fixture 文件:JSON / SQL seed,版本和内容一起进仓库;
  • 工厂函数:生成合法对象,避免每个用例手写一大坨字面量;
  • 快照测试:适合 稳定输出(序列化、模板),不适合 频繁变的业务文案

快照滥用会:

  • 让「更新快照」变成无脑点确定,失去回归保护
  • 层级选对:单元快、集成准、E2E 少而关键;
  • 异步与计时 要显式处理,避免假绿;
  • mock 打在边界上,配合 依赖注入ESM 约束 一起设计。

把测试策略和模块边界一起看,比纠结「用 Jest 还是 Vitest」更有意义——工具换一版,思路还在。