一、为什么非要折腾这一套
咱们平时写 TypeScript 项目,大多数情况下都会想着搭配一个测试框架,Jest 基本是默认选择。可是真把环境搭起来的时候,不少人会遇到一个特别诡异的情况:业务代码写得挺顺畅,一跑测试就报错,报错信息里经常出现 “Unexpected token ‘export’” 或者 “Cannot use import statement outside a module”。你仔细一看,代码没问题啊,import 也没写错,为什么 Jest 就不认识呢?问题的根源,八成就是模块系统打架了。
简单说,咱们的 TypeScript 代码里喜欢用 ES 模块的 import/export 这种写法,但 Jest 运行在 Node 环境里,Node 的传统模块系统是 CommonJS,它默认认识的是 require 和 module.exports。于是两兄弟碰面的时候,谁也不服谁,测试就跑不动了。再加上类型定义的问题,比如 describe、it、expect 这些测试函数在 TypeScript 里根本不认识,编辑器一片飘红,心态直接就崩了。
这篇文章要做的,就是把这些乱麻一点点理顺。咱们从零开始搭一套 TypeScript + Jest 的测试环境,把 ESM 和 CommonJS 怎么协调、类型定义怎么配置讲明白。你不会看到花里胡哨的概念,所有东西都带着真实的文件和代码,一句一句解释清楚。跟着走完,你的测试环境就能稳稳跑起来。
二、先弄明白模块那些事儿
2.1 CommonJS 是啥
CommonJS 是 Node.js 从早期就开始使用的模块规范。它的核心玩法是 require 和 module.exports。比如你写了一个文件,把函数挂到 module.exports 上,然后另一个文件用 require 把它拿过来用。Jest 默认跑在 Node 上,所以它天生就习惯这套规则,不用额外转换,直接可以执行。
2.2 ESM 又是啥
ESM 是 JavaScript 语言标准里定义的模块规范,用 import 和 export 关键字。浏览器原生支持它,现代的 TypeScript 代码也基本都在用这种写法。ESM 的好处是静态分析能力更强,比如可以做 tree-shaking,把没用的代码自动删掉。但坏处是,Node 天然支持 ESM 的历史比较短,需要一定配置才能跑得很顺。
2.3 为什么在 Jest 里会打架
Jest 默认的模块处理方式是 CommonJS。当它读取一个 TypeScript 文件,里面写的是 import { add } from './add',Jest 自己搞不定这种语法,就会把文件交给 ts-jest 或 babel-jest 去转换。如果转换器只把 TypeScript 类型和语法转成 JavaScript,但没把 import 转成 require,那 Node 看到的代码里还有 import,它就不认得,直接抛错。所以问题的关键,是得让 Jest 在加载文件之前,把 ESM 语法编译成 CommonJS 或者启动 Node 的 ESM 支持。
三、搭建一个最基础的环境
3.1 初始化项目
先建一个文件夹,咱们在里面开工。打开终端,输入下面这些命令。
技术栈:TypeScript 5.3 + Jest 29.7 + ts-jest 29.1
# 新建一个项目目录
mkdir ts-jest-demo
# 进入目录
cd ts-jest-demo
# 初始化 package.json,全部使用默认值即可
npm init -y
3.2 安装依赖
咱们需要安装四个东西:typescript 当然是必须的,jest 是测试框架,ts-jest 是让 Jest 能读懂 TypeScript 文件的桥梁,@types/jest 是为 Jest 提供类型定义的。一条命令全装好。
技术栈:TypeScript + Jest + ts-jest
# 安装开发依赖,-D 表示只用于开发阶段
npm install -D typescript jest ts-jest @types/jest
3.3 创建 TypeScript 配置文件
在项目根目录新建一个 tsconfig.json 文件。这里有一个特别需要注意的地方:module 要设置成 commonjs,这样 ts-jest 在编译时会把 import 自动转换成 require,Jest 就能识别了。另外 types 数组里一定要加上 jest,否则 TypeScript 会找不到测试函数。
技术栈:TypeScript + Jest + ts-jest
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"types": ["jest"]
}
}
3.4 创建 Jest 配置文件
新建一个 jest.config.js 文件。我们告诉 Jest 使用 ts-jest 这个预设,并且测试环境是 Node。
技术栈:TypeScript + Jest + ts-jest
// 技术栈:TypeScript + Jest + ts-jest
module.exports = {
// 使用 ts-jest 预设来处理 TypeScript 文件
preset: 'ts-jest',
// 测试环境是 Node.js
testEnvironment: 'node',
// 匹配测试文件,默认会找 __tests__ 目录或者以 .test/.spec 结尾的文件
testMatch: [
'**/__tests__/**/*.ts?(x)',
'**/?(*.)+(spec|test).ts?(x)'
]
};
3.5 写一个简单的业务模块和测试文件
先创建一个 src 目录,里面放一个 math.ts,写一个加法函数。
技术栈:TypeScript + Jest + ts-jest
// 技术栈:TypeScript + Jest + ts-jest
// 这个函数用来计算两个数字的和
export function add(a: number, b: number): number {
// 直接返回相加结果
return a + b;
}
然后创建一个 test 目录,写一个 math.test.ts。注意导入 add 的时候,路径要写对。因为 tsconfig 里 module 是 commonjs,ts-jest 会把 import 转成 require,所以这里用 import 没有任何问题。
技术栈:TypeScript + Jest + ts-jest
// 技术栈:TypeScript + Jest + ts-jest
// 引入待测试的 add 函数
import { add } from '../src/math';
// describe 用来描述一组测试
describe('add 函数', () => {
// it 用来定义一个具体的测试用例
it('应该返回两个数字的和', () => {
// expect 断言 add(1,2) 的结果等于 3
expect(add(1, 2)).toBe(3);
});
});
3.6 运行测试
回到终端,执行 npm test。因为 package.json 里的 scripts 默认已经有 "test": "jest",所以直接跑就行。
技术栈:TypeScript + Jest + ts-jest
# 执行测试
npm test
如果一切顺利,你就会看到 Jest 的输出,显示一个测试通过了。到这里,最基础的 TypeScript + Jest 环境已经搭好了。如果你现在只需要这么简单的环境,后面不用看也完全可以。
四、处理 ESM 与 CommonJS 冲突的两种实战姿势
4.1 劲儿使在 CommonJS 上(大多数项目的首选)
上面我们用的就是这种方案。核心思路是:让 ts-jest 把 TypeScript 文件编译成 CommonJS 格式,这样 Jest 不用开启任何 ESM 支持,直接用 Node 的 require 来加载模块。这种方案最稳,兼容性最好,几乎所有版本的 Node 都能跑。
这种方案的关键就是 tsconfig.json 里的 "module": "commonjs"。只要这个字段是 commonjs,ts-jest 就会把 import 转成 require,把 export 转成 module.exports。你写代码的时候照样用 import,不用改任何业务代码,转换的脏活累活交给 ts-jest 就行。
它的缺点是,如果你的项目本身是一个库,需要同时发布 ESM 和 CommonJS 两种格式,那这种方案就不够用。因为测试环境用的是 CommonJS,你无法在测试中模拟真正的 ESM 行为。如果你只是给业务项目写测试,那这个方案可以一直用到底。
4.2 拥抱原生 ESM(前方有坑,谨慎前行)
如果你的项目坚持要使用纯粹的 ESM,比如你的 package.json 里已经写上了 "type": "module",那你需要让 Jest 真正以 ESM 模式运行。好消息是 Jest 29 已经支持实验性 ESM 了,配合 ts-jest 的 ESM 预设也能跑通。坏消息是配置起来比 CommonJS 方案多几个步骤,而且容易踩坑。
第一步,修改 package.json,加上 "type": "module"。这样 Node 会把 .js 文件都当成 ESM 来处理。注意这一步会影响 Jest 的配置文件,因为 jest.config.js 本身也是一个 .js 文件,如果它使用了 module.exports 这种 CommonJS 写法,就会报错。所以我们要把 Jest 配置文件改成 ESM 语法,使用 export default。
技术栈:TypeScript + Jest + ts-jest(ESM 模式)
{
"name": "ts-jest-demo",
"version": "1.0.0",
"type": "module",
"scripts": {
"test": "jest"
},
"devDependencies": {
"@types/jest": "^29.5.12",
"jest": "^29.7.0",
"ts-jest": "^29.1.2",
"typescript": "^5.3.3"
}
}
然后,修改 jest.config.js,使用 ts-jest 的 ESM 预设。同时要告诉 Jest,把 .ts 文件也当作 ESM 文件来对待。
技术栈:TypeScript + Jest + ts-jest(ESM 模式)
// 技术栈:TypeScript + Jest + ts-jest(ESM 模式)
// 注意这里用的是 export default,而不是 module.exports
export default {
// 使用 ts-jest 专门为 ESM 提供的预设
preset: 'ts-jest/presets/default-esm',
testEnvironment: 'node',
extensionsToTreatAsEsm: ['.ts'],
transform: {
'^.+\\.ts$': [
'ts-jest',
{
// 开启 ESM 模式,ts-jest 会保留 import/export
useESM: true
}
]
}
};
接着修改 tsconfig.json,把 module 设置成 NodeNext,moduleResolution 也改成 NodeNext。这里要特别注意,在 NodeNext 模式下,相对导入的路径必须带上 .js 后缀,即使实际文件是 .ts 扩展名,这很容易让人不习惯。
技术栈:TypeScript + Jest + ts-jest(ESM 模式)
{
"compilerOptions": {
"target": "ES2020",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"types": ["jest"]
}
}
对应的测试文件也要改导入路径,在 ../src/math 后面加上 .js。
技术栈:TypeScript + Jest + ts-jest(ESM 模式)
// 技术栈:TypeScript + Jest + ts-jest(ESM 模式)
// 注意这里导入路径带了 .js 后缀,这在 NodeNext 模式下是必须的
import { add } from '../src/math.js';
describe('add 函数', () => {
it('应该返回两个数字的和', () => {
expect(add(1, 2)).toBe(3);
});
});
跑这个测试之前,最好先清理一下 Jest 的缓存,避免旧配置影响。
技术栈:TypeScript + Jest + ts-jest(ESM 模式)
# 清理 Jest 缓存
npx jest --clearCache
# 跑测试
npm test
这种 ESM 方案适合两种人:一种是库作者,需要验证自己的代码在 ESM 环境下的表现;另一种是使用 NestJS 或 Node 原生 ESM 项目做开发的开发者。日常业务项目用第一种 CommonJS 方案就足够了,没必要引入额外的复杂度。
五、类型定义那些坑
5.1 给 Jest 安上类型
很多同学搭好环境后,打开测试文件,发现 describe、it、expect 下面画着红色波浪线,TypeScript 提示找不到这些名称。原因很简单,因为 Jest 运行时的全局变量,在 TypeScript 的类型环境里并不存在。解决办法就是装 @types/jest,然后在 tsconfig.json 的 types 数组里配置上。
你可以在 tsconfig.json 里这样写:
技术栈:TypeScript + Jest + ts-jest
{
"compilerOptions": {
"types": ["jest"]
}
}
这样 TypeScript 就会读取 @types/jest 里声明的全局类型,describe、it、expect 就全部认识了。注意,如果你原本的 types 数组里还有其他类型库,比如 node,要全部写进去,不能丢掉任何一个。
5.2 自定义全局变量怎么办
有时候,测试里需要往全局挂一个变量,比如 globalThis.mockData。如果直接写,TypeScript 会抱怨这个属性不存在。解决办法是在项目里加一个 global.d.ts,用 declare global 来声明。
技术栈:TypeScript + Jest + ts-jest
// 技术栈:TypeScript + Jest + ts-jest
// 这个文件用来声明全局变量的类型
declare global {
// 往全局挂一个 mockData 变量,类型是 string
var mockData: string;
}
// 必须有这个 export,才能让 declare global 生效
export {};
然后在某个 setup 文件里给 mockData 赋值,同时在 jest.config.js 里配置 setupFiles 或 setupFilesAfterEnv。
技术栈:TypeScript + Jest + ts-jest
// 技术栈:TypeScript + Jest + ts-jest
// 这是 jest.setup.ts 文件,在测试运行前执行
globalThis.mockData = 'hello jest';
Jest 配置文件对应增加 setupFiles:
技术栈:TypeScript + Jest + ts-jest
// 技术栈:TypeScript + Jest + ts-jest
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
// 在测试环境准备阶段执行 setup 文件
setupFiles: ['./jest.setup.ts']
};
这样一来,测试文件里就可以直接使用 mockData 了,而且类型是 string,不怕写错。
六、应用场景、优点和缺点
6.1 适合哪些场景
这套环境特别适合以下几种情况。第一,你在做一个小型到中型的 TypeScript 项目,不想引入特别复杂的构建工具,只想快速把测试跑起来。第二,你维护一个 npm 库,需要给导出的函数写测试,防止以后改动的时候不小心弄坏功能。第三,你的团队从 JavaScript 切换到 TypeScript,需要一个稳定、好上手的测试基础设施。第四,你想在 Node 后端项目里用 Jest 写接口测试,这套环境也完全够用。
6.2 优点
最大的优点就是简单直接。ts-jest 不需要额外安装 Babel 全家桶,一个工具既能编译 TypeScript,又能给你做类型检查。Jest 本身提供了丰富的断言方法和 Mock 能力,比如 toBe、toEqual、jest.fn(),写起来很顺手。而且 Jest 社区巨大,遇到问题搜一搜基本都有答案。对于大多数业务项目,CommonJS 模式一次配置,永久受用,几乎不会出幺蛾子。
6.3 缺点
缺点也很明显。ts-jest 在每次跑测试的时候都要做 TypeScript 编译,如果项目很庞大,测试会明显变慢,体验没有 Babel 那种只转语法、不做类型检查的方式快。另外,ESM 的支持虽然有了,但配置比较繁琐,你还需要理解 NodeNext、extensionsToTreatAsEsm 这些额外概念,学习成本比 CommonJS 方案高出不少。再有就是版本兼容问题,Jest、ts-jest、TypeScript 三个库如果版本跨度太大,很容易出现不兼容的情况,必须小心谨慎地固定版本。
七、注意事项和避坑指南
7.1 版本别乱配
Jest 29 和 ts-jest 29 配合得比较好,如果你还在用 Jest 24 或者更老的版本,最好先升级。升级之前看看官方文档,别把 Jest 直接换成最新版,而 ts-jest 还停留在旧版,那样大概率跑不起来。最稳妥的办法,是把三个核心依赖的版本号写死,不写 ^ 那种允许自动升级的符号。
7.2 文件扩展名真的会咬人
如果你用了 ESM 模式,记住相对路径导入一定要写 .js 后缀,哪怕实际文件叫 .ts。这个容易错,错起来报错信息又很魔幻,会提示找不到模块。还有,如果你把某个文件命名为 .mts,那它会被强制当成 ESM,配置文件可能也要跟着调整。遇到这些情况别慌,检查一下扩展名和 tsconfig 的 module 设置。
7.3 记得清理缓存
Jest 和 ts-jest 都会缓存编译结果。有时候你改了配置,但跑测试还是走老缓存,导致看似正确的配置没生效。这时候执行一下 npx jest --clearCache,再重新跑测试,很多奇怪的问题就消失了。另外,创建或修改 tsconfig.json 之后,也建议重启一下 IDE,防止 TypeScript 语言服务使用旧的配置。
7.4 测试文件别放错地方
Jest 默认会找 __tests__ 目录,或者文件名里带 .test / .spec 的文件。如果你把测试文件命名为 math.ts,放在 src 目录下,Jest 是不会主动去跑它的。建议测试文件统一放在 test 目录里,跟业务代码分离,这样看起来也清爽。
八、总结
搭一套 TypeScript 项目的 Jest 测试环境,本身并不复杂。核心就两件事:第一,让 Jest 能够读懂你的模块语法,要么用 ts-jest 把 import 编译成 require,要么开启 Jest 的原生 ESM 支持;第二,给测试代码配上类型定义,避免编辑器满屏红色波浪线。模块冲突看起来吓人,其实就是 CommonJS 和 ESM 两者之间的沟通问题,我们只要选一个适合自己的方案,就能顺利解决。
平时写业务代码、公司项目,优先使用 CommonJS 方案,稳如老狗。如果是在写库,或者项目本身已经全面过渡到 ESM,那再考虑第二种方案,多折腾一下也是值得的。类型定义的问题,装好 @types/jest 然后在 tsconfig 里声明一下,基本就解决了。自定义全局变量就用 declare global 补充类型,既安全又清晰。
希望这篇文章能让你少走弯路。按照里面的步骤自己动手搭一遍,遇到问题再翻回来看对应的章节,相信你很快就能拥有一个丝滑的测试环境。测试跑通的那一刻,你会觉得一切都挺值。
评论
围绕“全面搭建TypeScript项目的Jest测试环境,处理ESM与CommonJS模块解析冲突及类型定义问题”参与讨论