Better

Ethan的博客,欢迎访问交流

Jest 遇上第三方 esm 模块导致单测报错

最近项目工程中成员反馈单测运行报错,看报错信息其实显而易见,但当魔法叠加魔法后,解决问题还是花了不少时间,同时也发现了一些有趣的事情。

原因

报错原因和你直接使用 jest 去测试 esm 模块是一样的,你需要 babel 转译成 cjs 模块才能顺利完成测试。

项目使用 cra 进行创建,jest 相关的配置,脚手架已经帮我们内化了,通常不会报这种错误,这时候报这种错误的原因就是,使用的第三方模块是 esm 模块导致的。因为无论是 jest 的默认配置,还是 cra 里面提供的配置项 transformIgnorePatterns 都会将 node_modules 过滤掉,因为没有进行 babel 转译,从而报错了。

OK,既然知道了原因,那么解决起来应该很轻松,且 jest 官方文档中也提供了说明,甚至提供了 React Native 的案例如下

{
  "transformIgnorePatterns": [
    "node_modules/(?!(react-native|my-project|react-native-button)/)"
  ]
}

尝试解决

我项目中主要是两个模块导致的

  • three 模块:项目中使用 three 中 example 封装好的 esm 模块,具体路径为:three/examples/jsm
  • antd 模块:自己的子包中有使用 antd 组件,经由 babel-plugin-import 后会转为使用 antd/es 模块

如果只是单独解决 antd 的问题,可以通过 moduleNameMapper 的配置项进行解决,因为 antd 同时提供了 lib 模块,通过如下配置将 es 使用映射为 lib 即可,具体如下

{
    "moduleNameMapper": {
        "^antd/es/(.*)$": "antd/lib/$1",
    }
}

的确,通过 package.json 中设置 jest 字段,然后添加 moduleNameMapper 配置后,关于 antd 的报错就不见了。

但上述方式对于 three/examples/jsm 并不生效,因为其并没有提供 cjs 的模块,没办法,只能通过设置 transformIgnorePatterns 的方式了,于似乎设置如下

{
  "transformIgnorePatterns": [
    "node_modules/(?!(three/examples/jsm)/)"
  ]
}

但解决并不如人意,依旧报错,尝试过各种可能性,比如怀疑 jest 对于 transformIgnorePatterns 的支持度是不是有问题,但都没有生效,于是怀疑是不是 cra 传给 jest 的配置有问题,于是只能深入源码查看一下,接下来才是有趣的部分。

react-scripts

react-scripts 处理 jest 的逻辑十分简单,test.js 模块调用 createJestConfig.js 模块获取配置,组装好相关参数后,调用 jest 的 API 执行。

于是乎,我在 createJestConfig 模块中进行相关打印

module.exports = () => {
    //…… logic
    console.log(config);
    return config;
}

打印出来的结果发现,package.json 下的 jest 配置没有生效,得到的还是 cra 中内置的 jest 配置。如果我继续打印 package.json 中内容如下

module.exports = () => {
    //…… logic
    console.log(paths.appPackageJson);
    //…… logic
    console.log(config);
    return config;
}

打印结果中得到 package.json 中所有内容,唯独没有 jest 字段,哎,可是我关于 antd 配置是生效的了哇,不服气,继续在 jest 中打印 createJestConfig 返回值。

const createJestConfig = require('./utils/createJestConfig');
console.log(createJestConfig(
    relativePath => path.resolve(__dirname, '..', relativePath),
    path.resolve(paths.appSrc, '..'),
    false
))

有点颠覆三观事情发生了,配置生效了,但并不完全生效,为什么说不完全生效呢,因为 transformIgnorePatterns 的配置不符合预期,结果为

{
  "transformIgnorePatterns": [
    "[/\\\\]node_modules[/\\\\].+\\.(js|jsx|mjs|cjs|ts|tsx)$",
    "^.+\\.module\\.(css|sass|scss)$",
    "node_modules/(?!(three/examples/jsm)/)"
  ]
}

由于两个 node_modules 同时存在,按照 jest 定义的规则,类似并集的操作,因此 node_modules 还是被完全过滤掉了,为什么说不符合呢,因为 createJestConfig 中明确写明了,对于 array 类型,会直接整个替换,而现在被合并了。createJestConfig 中代码如下

supportedKeys.forEach(key => {
    if (Object.prototype.hasOwnProperty.call(overrides, key)) {
        console.log(overrides[key])
        if (Array.isArray(config[key]) || typeof config[key] !== 'object') {
            // for arrays or primitive types, directly override the config key
            config[key] = overrides[key];
        } else {
            // for object types, extend gracefully
            config[key] = Object.assign({}, config[key], overrides[key]);
        }
        delete overrides[key];
    }
});

震惊之余,突然想起我并不是直接通过 react-script 进行 test 的,而是使用了 react-app-rewired 模块。

react-app-rewired

之所以发生这种现象,是因为项目使用 react-app-rewired 去动态修改 webpack、jest 等相关配置。接下来直接看一段源码,解释为什么是上述的表现

// hide overrides in package.json for CRA's original createJestConfig
const packageJson = require(paths.appPackageJson);
const jestOverrides = packageJson.jest;
delete packageJson.jest;
// load original createJestConfig
const createJestConfig = require(createJestConfigPath);
// run original createJestConfig
const config = createJestConfig(
  relativePath => path.resolve(paths.appPath, "node_modules", paths.scriptVersion, relativePath),
  path.resolve(paths.appSrc, ".."),
  false
);
// restore overrides for rewireJestConfig
packageJson.jest = jestOverrides;
// override createJestConfig in memory
require.cache[require.resolve(createJestConfigPath)].exports =
  () => overrides.jest(rewireJestConfig(config));
// Passing the --scripts-version on to the original test script can result
// in the test script rejecting it as an invalid option. So strip it out of
// the command line arguments before invoking the test script.
if (paths.customScriptsIndex > -1) {
  process.argv.splice(paths.customScriptsIndex, 2);
}
// run original script
require(paths.scriptVersion + '/scripts/test');

相关解释

  • 正是由于它会将 package.json jest 字段进行删除,所以我们在 createJestConfig 打印时,只能得到默认的配置
  • 那为什么在方法外打印显示生效了呢,因为它会使用 jest 配置进行整合,得到最终配置后,使用关键魔法 require.cache 对模块进行了动态修改
  • 那为什么又不符合预期呢,因为它的 rewire 规则和 cra 不一样,具体看 rewireJestConfig 源码

rewireJestConfig 源码

// Jest configuration in package.json will be added to the the default config
Object.keys(overrides)
    .forEach(key => {
        //We don't overwrite the default config, but add to each property if not a string
        if(key in config) {
            if(typeof overrides[key] === 'string' || typeof overrides[key] === 'number' || typeof overrides[key] === 'boolean') {
                config[key] = overrides[key];
            } else if(Array.isArray(overrides[key])) {
                config[key] = overrides[key].concat(config[key]);
            } else if(typeof overrides[key] === 'object') {
                config[key] = Object.assign({}, config[key], overrides[key]);
            }
        } else {
            config[key] = overrides[key];
        }
    });

总结

所有不可思议的事情得到了解释,要解决问题,可以通过手动声明 overrides.jest 字段的方式进行修改,这样就能完全管控了。



留言