Jest Cannot use import statement outside a module 单测报错完全解决方案

7,603 阅读5分钟

用 vitest 吧!单测执行速度更快问题更少!- 2023-09-27 更新

本文是解决 node_modules 导致单测失败,如果是业务代码导致报错请参考让 Jest 支持测试 ESM 业务代码

因为业界的解决方案都不适用且有更好的解决方案,故记录之。

若能帮助到你不妨点赞支持 😄。

一般来说通过 create-test-app cli 一键配置单测环境即可正常运行单测,但是一旦单测引入的三方模块是 esm 就会出现『Cannot use import statement outside a module』或『Cannot use export statement outside a module』。日志如下:

Jest encountered an unexpected token
This usually means that you are trying to import a file which Jest cannot parse, e.g. it's not plain JavaScript.
By default, if Jest sees a Babel config, it will use that to transform your files, ignoring "node_modules".
Here's what you can do:
• If you are trying to use ECMAScript Modules, see https://jestjs.io/docs/en/ecmascript-modules for how to enable it.To have some of your "node_modules" files transformed, you can specify a custom "transformIgnorePatterns" in your config.
• If you need a custom transformation specify a "transform" option in your config.
• If you simply want to mock your non-JS modules (e.g. binary assets) you can stub them out with the "moduleNameMapper" config option.


Details:
    src/node_modules/some-esm/esm/foo.js:1

    ({"Object.<anonymous>":function(module,exports,require,__dirname,__filename,global,jest){import{__awaiter as t,__generator as r}from"tslib";import{isPlainObject as n}from};
		...

    SyntaxError: Cannot use import statement outside a module

从描述得知,单测引入的文件是 ECMAScript Modules 即 esm 格式,jest 没法处理,刚好报错文件确实是一个 esm。

基于以下两个事实:

  1. jest 官方文档配置的 babel.config.js 默认只会编译业务代码,
  2. jest 不会对 node modules 内的文件编译,故配置 babel 并不能直接解决该问题。

从错误描述很自然可以得到一种解决方案,即方案三:开发者显示告诉 babel 必须编译哪些出错的三方包即可。

下面我们将从优到劣列出所有解决方案。

解决方案一:映射到 cjs ✅

一般三方模块都会同时编译一份 esm 和 cjs,故我们在单测中将其映射成 cjs 即可正常运行单测。

jest.config.js

moduleNameMapper: {
  // 将 pkg1 esm 替换成 cjs
  'pkg1/esm/(.*)': '<rootDir>/node_modules/pkg1/cjs/$1',

  // 比如常见的 lodash-es 
  'lodash-es/(.*)': 'lodash/$1',
},

优点

  1. 无编译,单测执行快 🚀;
  2. 新增 api 无需增加大量的 mock。而且也不现实,因为有些 api 很复杂没法 mock,相当于重写三方依赖了。

缺点

对应的三方库必须有 cjs 版本。

解决方案二:mock 三方模块 ✅

在闲暇时间花了点时间用手机粗略阅读了下 jest 文档,发现模块都可以被 mock,瞬间天朗气清。

以 TS 项目为例:

1 jest.config.js

设置两个字段即可。

  1. setupFiles 内含 mock 逻辑,需要在单测运行前执行,故放到 jest.setup.js 中。
  2. 运行环境从 node 改成 jsdom,因为我们是 H5 项目依赖了 window 和 self,改成 jsdom 能减少诸多不必要的 mock。当然第二步并非必要,而且从单测速度来看,两环境相差不大。
{
+ setupFiles: ['./jest.setup.js'],
- testEnvironment: 'node',
+ testEnvironment: 'jsdom',
}

完整内容:

/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
  transform: {
    // ts 项目无需
    '^.+\.(js)$': 'babel-jest',
  },

  // 🔥 https://jestjs.io/zh-Hans/docs/configuration#setupfiles-array
  setupFiles: ['./jest.setup.js'],
  
  preset: 'ts-jest',

  // 🔥 fix > ReferenceError: self is not defined
  testEnvironment: 'jsdom',
  
  // 以下字段非关键字段,可忽略
  coverageThreshold: {
    global: {
      branches: 100,
      functions: 100,
      lines: 100,
      statements: 100,
    },
  },
  coverageProvider: 'v8',
  coverageDirectory: 'coverage',
};

2 jest.setup.js

将报错的三方模块 mock 即可,同时按需 mock 一些不存在的对象。故 mock 内容如下:

// 这些是报错的模块,通过 mock 搞定
// https://dev.to/dstrekelj/how-to-mock-imported-functions-with-jest-3pfl
jest.mock('some-esm', () => ({}));
jest.mock('other-esm', () => ({
  AClass: class AClass {
    foo() {}
  },
}));
jest.mock('other-esm-2', () => ({
  isIOS: true,
}));

// 以下是不存在的全局对象,通过设置到 global 搞定
const Bar = {
  baz({ success }) {
    success(Date.now());
  },
};

// mock window.Bar in testEnvironment=jsdom
global.Bar = Bar;

优点

mock 方案更简单可控,无需编译三方模块,速度更快 🚀。

缺点

需按需 mock 模块。三方模块新增的 api 若单测使用了,需手动添加 mock。 有些 api 还没法 mock,否则相当于实现了三方库。

解决方案三:编译三方模块

jest.config.js transformIgnorePatterns

{
+ transformIgnorePatterns: ['node_modules/(?!(some-esm)/)'],
}

执行一次 test,然后发现还有其他包报错,则继续加入即可。语法是用 |分隔:

{
+ transformIgnorePatterns: ['node_modules/(?!(@some-esm|other-esm)/)'],
}

优点

解决方案符合直觉 😄。

缺点

  1. 单测执行速度慢,三方模块编译会拖慢整个单测时间;
  2. non-determinstic。编译方式可能和最终代码发布版本的不一致,导致测试结果不具备信服力;
  3. 配置和正则都是逆向逻辑,难以理解

总结

吐槽一句,jest 配置其实比 mocha 还要复杂,而且执行慢一个文件需要 12s-18s,不够 joyful 😄。

其次 transformIgnorePatterns这个命名让配置理解起来很绕 🙄,本身是逆向逻辑,然后其内的正则表达式还要取反,双重否定,最终表达的配置意图是 A 和 B 模块需要编译,而 transformIgnorePatterns 的字面意思是无需编译的模块 😡。

附录

jest 支持测试业务 ESM 代码,注意这是本文的前提。如何让其支持官方列出了三种方式:

  1. transform + babel.config.js(creat-test-app 已支持);

  2. 打开 node 实验性质的 esm flag:node --experimental-vm-modules node_modules/jest/bin/jest.js or NODE_OPTIONS=--experimental-vm-modules npx jest

  3. package.json 增加 type = module。

一般我们都会选择方式 1,本文在其基础上让其支持测试三方 esm 模块。

参考

  1. dev.to/dstrekelj/h…

  2. 让 jest 支持测试业务 ESM 代码:jestjs.io/docs/ecmasc…

TODO

  • npm 包 create-test-app 增加 jest.setup.js