一、理解问题:为什么Jest会找不到模块?

1.1 路径错误到底长什么样?

很多朋友在写测试用例的时候,都会遇到一个熟悉得不能再熟悉的报错:Cannot find module './xxx'。你可能会想,我明明把文件放在那儿了,怎么就能找不到呢?其实,这里的“找不到”并不是说文件消失了,而是说你给 Jest 指的那条路不对。

咱们举个例子。假设你的项目结构是这样的:

project/
├── src/
│   └── utils.js
└── tests/
    └── app.test.js

如果 app.test.js 里写的是:

// 技术栈:JavaScript + Jest
import { helper } from './utils'; // 错误!tests目录下没有utils

那 Jest 就会一脸蒙圈,因为它会先到 tests 当前目录里找 utils 文件,根本找不到。正确的写法应该是:

// 技术栈:JavaScript + Jest
import { helper } from '../src/utils'; // 先到上级目录,再进src找utils

看,问题就这么简单,但为什么总有人犯呢?因为很多时候,咱们写路径是靠记忆而不是靠逻辑,一不留神就写岔了。

1.2 模块解析的底层逻辑

想要真正搞懂路径问题,得知道 Jest 是怎么找模块的。其实,Jest 借用了 Node.js 那套模块解析思路,说不复杂也复杂,说复杂也简单。

当你写一个相对路径开头的导入,比如 ./foo,Jest 会从当前测试文件所在的目录出发,一级一级去找。当前测试文件在哪?当然就是 app.test.js 所在的那个 tests 文件夹了。如果你写的是 ../src/foo,那它就先跳到上一级目录,再进入 src 文件夹找。

如果你写的是一个不带路径的模块名,比如 import React from 'react',Jest 会直接去 node_modules 文件夹里翻。这就是为什么你自己写的文件必须用相对路径或别名,否则它根本不会理你。

还有一个容易混的地方:某个测试文件里,如果用了 __dirname,它指的就是这个测试文件所在目录,不是项目根目录。很多人把 __dirname 和 process.cwd() 搞混,前者是当前文件路径,后者是执行测试命令时所在的目录。如果搞混了,也会引发路径错误。

1.3 别把“当前目录”搞混了

刚才提到了 process.cwd(),这个概念很重要。你在项目的根目录运行测试命令,此时 process.cwd() 就是项目根目录。但如果你的 Jest 配置文件 jest.config.js 放在根目录,而测试文件放在 tests 里,那么相对路径的基准并不是根目录,而是测试文件所在目录。这个“基准”搞不清楚,所有路径都会跟着乱掉。

二、常见错误场景与排查方法

2.1 相对路径写错

相对路径写错是最常见的,也是最好修的。常见的情况有:少写一个 ../,多写了一层目录,或者漏掉了文件名。比如你的文件在 src/components/Button.js,测试文件在 tests/components/Button.test.js,那么正确的相对路径是 ../../src/components/Button,有的人会写成 ../src/components/Button,因为少了一个 ../,就会从 tests/components 直接跳到 tests,然后进 src,结果变成 tests/src/components/Button,那当然找不到。

你看,这种细节一疏忽就完了。所以我的建议是,在写相对路径的时候,从测试文件所在的目录开始,一层一层地数出来,别直接背别人的路径。

2.2 别名(alias)没配置

现在很多项目都会用构建工具,比如 Webpack 或 Vite,给路径设置别名。最常见的别名是 @ 代表 src 目录。这样写出来的代码非常清爽:

// 技术栈:JavaScript + Jest
import { getData } from '@/api/data';

测试文件里也照葫芦画瓢:

// 技术栈:JavaScript + Jest
import { getData } from '@/api/data';

结果一跑 Jest,直接报错。原因很简单:Jest 不是 Webpack,它不会主动读取 Webpack 的配置文件。你必须得在 Jest 自己的配置里,把 @ 这个别名告诉它。怎么告诉呢?后面我们会在修复方案里详细说。

这里先给你一个判断技巧:如果你的代码在浏览器里正常运行,但测试就跑不起来,而且报错的模块名带着 @ 或者其他自定义前缀,八成就是别名没配置。

2.3 大小写敏感

这个坑很容易被忽略。在 Windows 和 macOS 上,文件系统对大小写不敏感。也就是说,你创建一个 Math.js 文件,然后写 import { sum } from './math',在本机能跑通。但是 CI 环境基本都是 Linux,文件系统对大小写敏感,Math 和 math 就是两个不同的文件,于是测试在本地活得好好的,一上 CI 就失败。

所以,在写路径的时候,尽量和文件名保持完全一致,别偷懒忽略大小写。尤其是团队协作时,不同成员的开发环境可能不一样,一个大小写问题可能让人排查半天。

2.4 文件扩展名被遗漏

Jest 默认能识别的扩展名就那么几种,比如 .js、.jsx、.ts、.tsx、.json。如果你的项目里用了 .mjs、.cjs 或者一些自定义扩展名,并且导入的时候没有写扩展名,Jest 就不知道该怎么处理。比如:

// 技术栈:JavaScript + Jest
import { config } from './config'; // 实际文件是config.mjs

如果你不告诉 Jest 去识别 .mjs,它就会到处找 config.js、config.ts 之类的文件,找不到就报错。解决办法是在 Jest 配置里添加 moduleFileExtensions 来扩展识别范围。

2.5 排错的第一步:看错误信息

当你遇到 Cannot find module 错误时,别急着改东改西。先仔细读一下错误信息,里面通常会告诉你它尝试在哪个路径下找。比如:

Cannot find module '@/utils/math' from 'tests/math.test.js'

这句话的意思是,在 tests/math.test.js 里,它尝试找 @/utils/math,但是失败了。如果你把鼠标移到错误信息的环境栈里,有时候还能看到它实际尝试的所有目录列表。找到那个列表,你就能判断是相对路径不对,还是别名没配置。

三、修复方案:从简单到工程化

3.1 最直接的修复:检查并修正相对路径

对于小项目或者临时测试,最直接的办法就是把路径改成正确的相对路径。咱们再复习一下:

project/
├── src/
│   ├── utils/
│   │   └── math.js
│   └── components/
└── tests/
    └── math.test.js

从 math.test.js 到 math.js 的路径是 ../src/utils/math。因为 math.test.js 在 tests 里,往上跳一级是项目根目录,然后进 src/utils。写成:

// 技术栈:JavaScript + Jest
import { add } from '../src/utils/math';

这样它就能找到了。记住,修改完路径后,最好重启一下测试,确保 Jest 重新加载文件。

3.2 利用 jest.config.js 配置模块解析

相对路径写多了,你会觉得到处都是 ../../,很丑,也很难维护。这时候可以用 Jest 的配置来优化。在项目根目录下创建或修改 jest.config.js:

// 技术栈:JavaScript + Jest
module.exports = {
  // 告诉Jest,模块查找的目录除了node_modules,还包括src
  moduleDirectories: ['node_modules', 'src'],
};

配置好了之后,你导入 src 下的模块时,可以直接用模块路径,不用再写相对路径。比如:

// 技术栈:JavaScript + Jest
import { add } from 'utils/math'; // 注意,这里没有./或../

Jest 会按照 moduleDirectories 的顺序去找。先在 node_modules 里找,找不到再去 src 里找。不过要小心,如果 node_modules 里也有一个叫 utils 的包,它就会优先用那个。

另外,你还可以设置 moduleFileExtensions 来指定哪些扩展名需要被尝试。比如:

// 技术栈:JavaScript + Jest
module.exports = {
  moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx', 'json', 'mjs'],
};

这样,遇到没有扩展名的导入时,Jest 会依次用这些扩展名去尝试找文件。

3.3 巧用 moduleNameMapper 解决别名和静态资源

如果你的项目里已经用了别名,比如 @,那么 moduleNameMapper 就是你的救星。它是一个“映射表”,告诉 Jest 怎么把符合特定规则的字符串转换成另一个字符串。咱们看个例子:

// 技术栈:JavaScript + Jest
module.exports = {
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
  },
};

这里 ^@/(.*)$ 是一个正则表达式,匹配所有以 @/ 开头的导入。$1 表示捕获到的第一个括号里的内容,也就是 @/ 后面的部分。然后拼上 <rootDir>/src/,就得到真实路径。比如 @/utils/math 变成 <rootDir>/src/utils/math。

除了 JavaScript 文件,moduleNameMapper 还能处理静态资源。项目中经常有这种导入:

// 技术栈:JavaScript + Jest
import './style.css';

Jest 不认识 CSS,所以会报错。你可以把这类导入映射成一个空的样式对象:

// 技术栈:JavaScript + Jest
module.exports = {
  moduleNameMapper: {
    '\\.(css|less|scss)$': '<rootDir>/__mocks__/styleMock.js',
  },
};

然后在 __mocks__/styleMock.js 里写:

// 技术栈:JavaScript + Jest
module.exports = {}; // 一个空对象,用来代替样式文件

这样 Jest 就能顺利通过样式导入了。是不是很巧妙?

3.4 使用 NODE_PATH 环境变量(辅助方法)

除了在 Jest 配置里设置模块目录,你还可以利用环境变量 NODE_PATH。在运行 Jest 之前,设置 NODE_PATH 指向 src 目录,然后 Jest 会把它加入模块解析路径。不过这个方式有局限性,特别是跨平台处理环境变量很麻烦,所以不是首选。建议还是用 moduleDirectories 更干净。

3.5 临时调试:用 __dirname 看路径

如果你实在搞不清楚路径哪里错了,可以在测试文件里临时加一行打印:

// 技术栈:JavaScript + Jest
console.log(__dirname); // 看看当前文件所在的目录是什么

这样你就能确认基准目录,然后再对照目标文件的位置,推出正确的相对路径。这是最笨但最有效的调试方法之一。

四、实战示例:一个完整的问题定位与修复过程

4.1 项目结构

咱们再看一个稍微复杂点的项目:

my-project/
├── jest.config.js
├── package.json
├── src/
│   ├── utils/
│   │   └── math.js
│   └── components/
│       └── Hello.js
└── tests/
    └── math.test.js

4.2 测试代码和错误信息

math.js 里的内容很简单:

// 技术栈:JavaScript + Jest
export function add(a, b) {
  return a + b;
}

tests/math.test.js 里一开始写的是:

// 技术栈:JavaScript + Jest
import { add } from '@/utils/math'; // 用了@别名

test('add函数应该正确相加', () => {
  expect(add(1, 2)).toBe(3);
});

运行测试命令之后,终端瞬间一片红:

Cannot find module '@utils/math' from 'tests/math.test.js'

4.3 一步一步排查

首先,我们打开 jest.config.js 看看:

// 技术栈:JavaScript + Jest
module.exports = {
  // 里面啥都没有?太不负责任了
};

果然,没有任何配置。那么 @ 别名肯定不会生效。这时有两种修复思路:一是把测试里的 @/utils/math 改回相对路径 ../src/utils/math,二是配置 moduleNameMapper。考虑到项目里其他地方也在用 @,为了让所有测试文件保持一致,我们决定配置映射。

4.4 最终修复

我们在 jest.config.js 里加上:

// 技术栈:JavaScript + Jest
module.exports = {
  rootDir: '.', // 项目根目录,就是当前文件夹
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1', // 把@/映射到src/
  },
  moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx', 'json'],
};

修改之后,再次运行测试。如果你用的是 watch 模式,记得先按 q 退出再重启,或者按 Ctrl+C 重新执行。这次,绿色的小对号出现了,测试通过。

4.5 回归验证

修复一个地方后,最好把整个测试套件都跑一遍,确保没有引入新的问题。因为 moduleNameMapper 是一个全局配置,如果正则写得不严谨,可能会误伤其他文件。比如你把 '^@/(.*)$' 写成了 '^@(.*)$',那 @foo 也会被映射,可能就会影响其他模块。所以,改完配置后,跑一遍全量测试是很有必要的。

五、应用场景、优缺点与注意事项

5.1 适用场景

  • 直接修相对路径:适合小项目、单文件临时测试,以及路径错误非常明显的情况。
  • 配置 moduleDirectories 和 moduleFileExtensions:适合中大型项目,并且项目里文件类型比较统一,想减少 ../ 神迹的情况。
  • 使用 moduleNameMapper:适合使用了 Webpack/Vite 别名、或者导入了静态资源的项目。这是目前最主流、最推荐的方法。

5.2 方法优缺点

直接修路径的优点是简单快速,不依赖任何配置,出错概率小。缺点是路径写起来又长又绕,一旦文件夹结构发生调整,所有相关测试文件都得跟着改。

配置 moduleDirectories 和 moduleFileExtensions 的优点是让模块导入看起来更清爽,不会有一大串 ../../。缺点是需要额外学习这些配置项,而且如果目录比较多,可能会引起名称冲突。

moduleNameMapper 的优点非常明显:它能精确控制映射关系,不仅支持别名,还能处理静态资源,灵活度极高。缺点是需要懂一点正则表达式,对新手可能有点门槛。而且配置一旦出错,错误信息会变得非常隐晦,增加了排查难度。

5.3 注意事项

我在实际使用中总结了几条经验,分享给大家:

第一,修改 Jest 配置后,一定要重启 Jest,不要只用 watch 模式刷新。因为某些配置项只在 Jest 启动时读取一次。

第二,配置 moduleNameMapper 时,最好使用 <rootDir> 而不是绝对路径。<rootDir> 是 Jest 提供的一个变量,它默认指向 jest.config.js 所在的目录。如果直接写 /home/user/project/src,换台电脑就挂了。

第三,注意 Windows 和 Linux 的路径分隔符差异。在 Windows 中,路径可能用反斜杠 \,但 Jest 配置里最好统一用正斜杠 /,这样才能跨平台。

第四,如果你同时使用了 Babel 和 Webpack,确保它们和 Jest 三者的模块解析规则保持一致。比如 Webpack 里设置了 @ 别名,Babel 里也设置了 @ 别名,那么 Jest 也必须设置同样的映射,否则就会出现“浏览器能跑,测试跑不了”的尴尬情形。

第五,对于静态资源的处理,最好准备一个通用的 styleMock.js 文件,不要为每个样式文件单独写映射,那样太累了。

六、总结

模块导入路径错误是 Jest 测试中最常见的问题之一,但它的解决方案并不复杂。我们只要理解了 Jest 的模块解析逻辑,再按照“先看错误信息,再判断场景,最后选择合适的修复方式”这个思路,就能从容应对。从简单的相对路径,到工程化的配置方案,再到强大的 moduleNameMapper,每一步都是为了让测试代码更可靠、更可维护。希望这篇文章能帮助你在测试路上少踩几个坑,顺利写出健壮的测试用例。