一、为什么非要折腾这一套

咱们平时写 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 从早期就开始使用的模块规范。它的核心玩法是 requiremodule.exports。比如你写了一个文件,把函数挂到 module.exports 上,然后另一个文件用 require 把它拿过来用。Jest 默认跑在 Node 上,所以它天生就习惯这套规则,不用额外转换,直接可以执行。

2.2 ESM 又是啥

ESM 是 JavaScript 语言标准里定义的模块规范,用 importexport 关键字。浏览器原生支持它,现代的 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 里 modulecommonjs,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 设置成 NodeNextmoduleResolution 也改成 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 安上类型

很多同学搭好环境后,打开测试文件,发现 describeitexpect 下面画着红色波浪线,TypeScript 提示找不到这些名称。原因很简单,因为 Jest 运行时的全局变量,在 TypeScript 的类型环境里并不存在。解决办法就是装 @types/jest,然后在 tsconfig.jsontypes 数组里配置上。

你可以在 tsconfig.json 里这样写:

技术栈:TypeScript + Jest + ts-jest

{
  "compilerOptions": {
    "types": ["jest"]
  }
}

这样 TypeScript 就会读取 @types/jest 里声明的全局类型,describeitexpect 就全部认识了。注意,如果你原本的 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 里配置 setupFilessetupFilesAfterEnv

技术栈: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 能力,比如 toBetoEqualjest.fn(),写起来很顺手。而且 Jest 社区巨大,遇到问题搜一搜基本都有答案。对于大多数业务项目,CommonJS 模式一次配置,永久受用,几乎不会出幺蛾子。

6.3 缺点

缺点也很明显。ts-jest 在每次跑测试的时候都要做 TypeScript 编译,如果项目很庞大,测试会明显变慢,体验没有 Babel 那种只转语法、不做类型检查的方式快。另外,ESM 的支持虽然有了,但配置比较繁琐,你还需要理解 NodeNextextensionsToTreatAsEsm 这些额外概念,学习成本比 CommonJS 方案高出不少。再有就是版本兼容问题,Jest、ts-jest、TypeScript 三个库如果版本跨度太大,很容易出现不兼容的情况,必须小心谨慎地固定版本。

七、注意事项和避坑指南

7.1 版本别乱配

Jest 29 和 ts-jest 29 配合得比较好,如果你还在用 Jest 24 或者更老的版本,最好先升级。升级之前看看官方文档,别把 Jest 直接换成最新版,而 ts-jest 还停留在旧版,那样大概率跑不起来。最稳妥的办法,是把三个核心依赖的版本号写死,不写 ^ 那种允许自动升级的符号。

7.2 文件扩展名真的会咬人

如果你用了 ESM 模式,记住相对路径导入一定要写 .js 后缀,哪怕实际文件叫 .ts。这个容易错,错起来报错信息又很魔幻,会提示找不到模块。还有,如果你把某个文件命名为 .mts,那它会被强制当成 ESM,配置文件可能也要跟着调整。遇到这些情况别慌,检查一下扩展名和 tsconfigmodule 设置。

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 补充类型,既安全又清晰。

希望这篇文章能让你少走弯路。按照里面的步骤自己动手搭一遍,遇到问题再翻回来看对应的章节,相信你很快就能拥有一个丝滑的测试环境。测试跑通的那一刻,你会觉得一切都挺值。