Mocha 本身并不支持在 describe 回调里直接使用 await import() 进行 ES 模块的动态导入;正确的做法是依赖顶层静态导入,将测试套件封装为独立函数导出,再在 describe 中传入该函数作为回调,从而完美兼容 ESM 并保持原有的测试组织结构。
许多从 CommonJS 迁移到 ES 模块的团队,在组织测试文件时都会遇到一个棘手的问题——希望将测试按功能拆分成多个独立文件,然后在主入口中动态加载,却发现 Mocha 并不买账。在 CommonJS 时代,只需在 describe 块内直接 require('./unit/test.spec.js') 即可,测试定义会立即注册,干净利落。但换成 ESM 的 await import() 后,Mocha 却仿佛无视了这些动态导入,输出 0 passing (0ms),令人困惑。
问题的根源在于:ESM 的动态导入会返回一个 Promise,模块的实际执行时机被推迟到了异步微任务中,而 Mocha 的测试发现阶段是同步执行的。当 Mocha 扫描完所有 describe 块时,动态导入的测试定义尚未完成注册。Mocha v10+ 明确要求所有测试定义必须在模块求值阶段同步完成,不允许在异步回调中延迟注册。这正是问题所在。
解决方案其实非常直接:将测试文件重构为导出一个无参函数,该函数内部正常调用 it、describe 等 Mocha API。主测试文件在顶层使用静态 import 引入这些函数,然后直接传递给 describe 作为回调。这样,模块在加载时就被解析,函数被 describe 调用时测试定义立即注册,完美避开了异步陷阱。
这样说可能有些抽象,直接看代码示例:
// ./unit/test.spec.js —— ESM 测试套件文件
export default function unitTestSuite() {
it('should assert equality', () => {
assert.strictEqual(1, 1);
});
it('should handle async operations', async () => {
const result = await Promise.resolve(42);
assert.strictEqual(result, 42);
});
}// test/index.spec.js —— 主测试入口(ESM 格式,需 .mjs 后缀或 package.json 中 "type": "module")
import unitSuite from './unit/test.spec.js';
import functionalSuite from './functional/test.spec.js';
describe('Unit tests', unitSuite);
describe('Functional tests', functionalSuite);
// 可继续添加更多套件...有几个关键点必须留意,否则容易踩坑:
- 绝对不要在 describe 内部使用 await import()。这会导致测试定义延迟到异步微任务中执行,而 Mocha 早已完成测试发现,自然认为“没有测试”。
- 必须使用顶层静态 import。确保模块在文件加载时立即被解析,导出函数随后被 describe 调用时,测试定义能及时注册。
- Node.js 版本需 ≥ 14.8.0,测试文件应以 .mjs 结尾,或者在 package.json 中声明 "type": "module"。
- 如果需要进行条件加载(例如只在 CI 环境中运行某套件),可以在顶层用同步逻辑控制是否调用 describe,但导入语句必须保留在顶层——ESM 规范不允许条件 import。
这套模式不仅完整复现了 CommonJS 下的模块化组织能力,还带来了额外好处:类型推导更友好(TypeScript 支持更顺畅)、作用域隔离更清晰、与现代构建工具(Vite、esbuild)天然兼容。本质上,它将“测试注册”显式建模为函数调用,不再依赖模块的副作用,让测试结构更可预测、更易调试。这才是关键所在。
