共计 6607 个字符,预计需要花费 17 分钟才能阅读完成。
提醒:本文最后更新于2025-07-07 14:39,文中所关联的信息可能已发生改变,请知悉!
1 为什么要从 Karma+Jasmine
切换为 Jest
Angular 官方默认推荐的单元测试框架为 Karma + Jasmine
。
Karma
是通过启动 Chromium 浏览器,在真实的浏览器环境中执行单元测试。
Jest
通过配置指定运行环境,通常会配置为 jsdom
,每个测试文件都在独立的运行环境中执行。
Karma
当前存在的主要问题有:
- 需要启动浏览器,编译整个项目并在浏览器中执行单元测试用例
- 由于在浏览器中执行,共享运行环境,容易因用例执行副作用导致结果不稳定
- 不支持单文件测试,单测开发调试体验相对较弱
- CI 执行环境中需要安装 chromium 浏览器,相对比较繁琐
替换为 Jest
的优点:
- 运行环境基于
jsdom
,不需要启动浏览器 - 支持单文件测试、缓存文件编译结果
- 支持多核 CPU 并行执行
- 异常以单文件调用栈形式展现,提示信息清晰,容易定位分析
- CI 上执行时,通用的 Node.js docker 镜像即可
注意:
以下场景应谨慎选择
Jest
:CI Runner 为单核、性能低的虚拟机。
主要原因在于,Jest
的单文件独立环境可并行执行的特点,在执行效率上没有优势,由于每个测试文件运行时都需要启动环境,对于大型项目来说,当文件依赖链较深时,文件预加载与处理则极为耗时。又因为单核只能串行执行,会导致最终的执行时间非常久,几乎无法接受。此时karma+jasmine
的一次编译、浏览器中集中执行的方式则体现出了其优势。
综合来说,迁移至 Jest
的主要目的是希望能够获得较好的单文件测试编写开发体验。但对于大型项目,Jest 单文件独立环境执行的方式,需要多核处理器才能得到较好的执行效率,karma+Jasmine
则对机器性能要求较低。
2 Angular 使用 Jest 的方案选择
社区较为流行的方案主要有 @angular-builders/jest 和 jest-preset-angular。基于 npm
下载量趋势,jest-preset-angular
明显具有绝对优势,故无脑选择它。
另外由于项目已有大量基于 jasmine
编写的单元测试,虽然 Jest
可以兼容其单测写法,但 API 调用部分还需要进行迁移。可以使用工具 jest-codemods。
3 由 Karma 迁移至 Jest 的流程参考
3.1 Jest 相关依赖安装
执行如下命令添加 Jest 相关依赖:
npm install -D jest jest-preset-angular @types/jest
3.2 配置 Jest
新建文件 src/jest-setup.ts
,内容参考:
import 'jest-preset-angular/setup-jest'; | |
import '../__mocks__/jestGlobalMocks'; |
新建文件 __mocks__/jestGlobalMocks.ts
,内容参考:
import { jest } from '@jest/globals'; | |
const mock = () => { | |
let storage = {}; | |
return { | |
getItem: key => (key in storage ? storage[key] : null), | |
setItem: (key, value) => (storage[key] = value || ''), | |
removeItem: key => delete storage[key], | |
clear: () => (storage = {}), | |
}; | |
}; | |
Object.defineProperty(window, 'localStorage', { value: mock() }); | |
Object.defineProperty(window, 'sessionStorage', { value: mock() }); | |
Object.defineProperty(window, 'getComputedStyle', { | |
value: () => { | |
return { | |
display: 'none', | |
appearance: 'none', | |
getPropertyValue: () => '', | |
// more... | |
}; | |
}, | |
}); | |
Object.defineProperty(window, 'navigator', { | |
value: { | |
userAgent: | |
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36', | |
// more... | |
}, | |
}); | |
Object.defineProperty(window, 'location', { | |
value: { | |
assign: jest.fn(), | |
hash: '', | |
port: '', | |
protocol: 'http:', | |
search: '', | |
host: 'localhost', | |
hostname: 'localhost', | |
href: 'http://localhost/guide/sharing-ngmodules', | |
origin: 'http://localhost', | |
pathname: '/guide/sharing-ngmodules', | |
replace: jest.fn(), | |
reload: jest.fn(), | |
}, | |
}); | |
HTMLCanvasElement.prototype.getContext = <typeof HTMLCanvasElement.prototype.getContext>jest.fn(); | |
// more: 其他全局变量兼容 | |
// 如 jquery: | |
window.$ = window.jQuery = require('jquery'); | |
window.__DEV__ = 'test'; | |
window.alert = (text: string) => console.warn(text); | |
新建 jest.config.js
文件,内容参考:
const { pathsToModuleNameMapper } = require('ts-jest'); | |
const { compilerOptions } = require('./tsconfig'); | |
const paths = [ | |
'app', | |
'config', | |
'environments', | |
'lib', | |
].reduce( | |
(paths, dirname) => { | |
paths[`${dirname}`] = [`src/${dirname}`]; | |
paths[`${dirname}/*`] = [`src/${dirname}/*`]; | |
return paths; | |
}, | |
{ | |
...compilerOptions.paths, | |
} | |
); | |
// eslint-disable-next-line no-undef | |
globalThis.ngJest = { | |
skipNgcc: false, | |
tsconfig: 'tsconfig.spec.json', | |
}; | |
// Ignore transform for esm modules | |
const esModules = ['@angular', '@ngrx', 'rxjs', 'ng-zorro-antd', '@ant-design'].join('|'); | |
// jest.config.js | |
module.exports = { | |
preset: 'jest-preset-angular', | |
globalSetup: 'jest-preset-angular/global-setup', | |
setupFilesAfterEnv: ['< rootDir >/src/jest-setup.ts'], | |
globals: { | |
'ts-jest': { | |
tsconfig: '< rootDir >/tsconfig.spec.json', | |
stringifyContentPathRegex: '\\.(html|svg)$', | |
isolatedModules: true, // 禁用类型检查 | |
}, | |
}, | |
moduleNameMapper: { | |
...pathsToModuleNameMapper(paths, { prefix: '< rootDir >' }), | |
}, | |
resolver: '< rootDir >/__mocks__/helper/jest.resolver.js', | |
transform: { | |
'^.+\\.(ts|js|mjs|html|svg)$': 'jest-preset-angular', | |
'^.+\\.pug$': '< rootDir >/__mocks__/helper/pug-transform.js', | |
}, | |
transformIgnorePatterns: [ | |
`node_modules/(?!${esModules})`, | |
`< rootDir >/node_modules/.pnpm/(?!(${esModules})@)`, // for pnpm | |
// 'node_modules/(?!.*\\.mjs$)', // for esm | |
], | |
coverageReporters: ['html', 'text-summary'], // , 'text' | |
coveragePathIgnorePatterns: ['/node_modules/', 'src/lib/'], | |
testMatch: ['< rootDir >/**/*.spec.ts'], | |
moduleFileExtensions: ['mock.ts', 'ts', 'js', 'html', 'json', 'mjs', 'node'], | |
maxWorkers: Math.max(1, require('os').cpus().length), | |
}; |
tsconfig.spec.json
文件内容调整,参考:
{ | |
"extends": "./tsconfig.json", | |
"compilerOptions": { | |
"outDir": "./out-tsc/spec", | |
"module": "CommonJs", | |
"types": ["jest"] | |
}, | |
"include": ["src/**/*.spec.ts", "src/**/*.d.ts"] | |
} |
3.3 使用 jest-codemods
迁移单测代码
首先测试一下效果,执行如下命令:
npx jest-codemods src -d
如果没有异常报错,可以执行真正的迁移命令如下(记得提前 commit 所有已修改的代码,以便随时可 reset 回滚):
npx jest-codemods src -f
jest-codemods
可以帮助更新比较规范的 jasmine
API 的应用,将其替换为对应 jest
API 的实现。但仍会有一些不太规范的写法,需要根据单测执行反馈的结果逐个确认与手动修正。
3.4 移除 Karma
若前述流程迁移完成,所有单测都可以顺利使用 jest 跑起来,则可以移除 karma 依赖。主要有:
- 移除
package.json
中与 karma 和jest
相关的依赖 - 移除 karama 配置文件
4 遇到的问题与解决方案
4.1 全局对象 mock
由于 karma 时是编译所有文件并在浏览器中启动测试,而 jest 基于 jsdom 按单文件执行测试,其初始化环境有较大的差异,一些 jsdom 不支持的 API、应用预定义的全局 API 在 jest 单测环境中并不存在。这可以通过 mock 相关 API 的方法解决。
如 jsdom 中未实现 window.getComputedStyle
API。则可以创建文件 jestGlobalMocks.ts
文件并实现其基本 API,然后在 jest-setup.ts
中引入它(具体参考前文 jestGlobalMocks.ts
文件示例)。
如应用中在 utils/global.ts 中扩展了字符串原型:String.prototype.isGb = () => {...}
。则可在 jest-setup.ts
文件中引入它。
再如对 location
的调用,jsdom
会报告如下异常:
Error: Not implemented: navigation (except hash changes)
可以通过 mock location
对象解决:
Object.defineProperty(window, 'location', { | |
value: { | |
assign: jest.fn(), | |
hash: '', | |
port: '', | |
protocol: 'http:', | |
search: '', | |
host: 'localhost', | |
hostname: 'localhost', | |
href: 'http://localhost/guide/sharing-ngmodules', | |
origin: 'http://localhost', | |
pathname: '/guide/sharing-ngmodules', | |
replace: jest.fn(), | |
reload: jest.fn(), | |
}, | |
}); |
4.2 Jasmine 与 Jest API 修改与对照
jasmine.createSpyObj
->jest.fn
。示例:
// 示例一: | |
const spy = jasmine.createSpyObj(['getListSync']); | |
// => | |
const spy = { getListSync: jest.fn() }; |
more…
4.3 transform 相关问题:Cannot find module ‘@angular/common/locales/xxx’
一般主要是模块提供了 commonjs 与 esmodule 类型的输出,出现依赖库入口寻找异常、transform 异常等问题。
对于默认使用 esm 模式执行 jest 的情况,可以配置 transformIgnorePatterns
过滤对 esm 模块的转译、配置 moduleNameMapper
将异常模块重定位到对应的 esm
类型文件入口等方式解决。参考:
// Ignore transform for esm modules | |
const esModules = ['@angular', '@ngrx', 'rxjs'].join('|'); | |
/** @type {import('ts-jest').ProjectConfigTsJest} */ | |
module.exports = { | |
moduleNameMapper: { | |
// for @angular | |
'@angular/common/locales/(.*)$': '< rootDir >/node_modules/@angular/common/locales/\.mjs', | |
// for @ngrx | |
'@ngrx/store/testing': '< rootDir >/node_modules/@ngrx/store/fesm2015/ngrx-store-testing.mjs', | |
'@ngrx/effects/testing': '< rootDir >/node_modules/@ngrx/effects/fesm2015/ngrx-effects-testing.mjs', | |
// '\\.(css|less|sass|scss)$': 'identity-obj-proxy', | |
}, | |
transformIgnorePatterns: [ | |
`node_modules/(?!${esModules})`, | |
`< rootDir >/node_modules/.pnpm/(?!(${esModules})@)`, // for pnpm | |
], | |
}; | |
- https://github.com/thymikee/jest-preset-angular/issues/1147
5 总结与参考
小结一下以上实践过程,可以总结其主要流程如下:
- 添加
jest
相关依赖库 - 移除
karma
和jasmine
相关依赖库 - 配置
jest
。根据单测执行反馈的异常,可能需要添加一些兼容配置,如:esm
模块路径映射、全局变量兼容等 - 执行
npx jest-codemods src -f
以辅助迁移单测代码 - 执行
pnpm jest
,根据单测反馈的异常,逐个确认与手动兼容处理
最后提一下,相比因看不惯 jasmine
而创建出了 jest
,jest
也同样因被嫌弃过于臃肿而出现了 vitest
,有时间可以再做一下 Angular 项目单测框架切换为 vitest
的尝试。
相关参考:
- Angular Testing
- jest-preset-angular
- jest-preset-angular: troubleshooting
- jest-codemods
- @angular-builders/jest
- ANGULAR8 中引入 JEST 单元测试
- Vitest: Comparisons with Other Test Runners