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 相关依赖:
1 | npm install -D jest jest-preset-angular @types /jest |
3.2 配置 Jest
新建文件 src/jest-setup.ts
,内容参考:
1 2 | import 'jest-preset-angular/setup-jest' ; import '../__mocks__/jestGlobalMocks' ; |
新建文件 __mocks__/jestGlobalMocks.ts
,内容参考:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 | 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' , 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
文件,内容参考:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 | 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
文件内容调整,参考:
1 2 3 4 5 6 7 8 9 | { "extends" : "./tsconfig.json" , "compilerOptions" : { "outDir" : "./out-tsc/spec" , "module" : "CommonJs" , "types" : [ "jest" ] }, "include" : [ "src/**/*.spec.ts" , "src/**/*.d.ts" ] } |
3.3 使用 jest-codemods
迁移单测代码
首先测试一下效果,执行如下命令:
1 | npx jest-codemods src -d |
如果没有异常报错,可以执行真正的迁移命令如下(记得提前 commit 所有已修改的代码,以便随时可 reset 回滚):
1 | 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
对象解决:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | Object.defineProperty(window, 'location' , { value: { assign: jest.fn(), hash: '' , port: '' , protocol: 'http:' , search: '' , host: 'localhost' , hostname: 'localhost' , pathname: '/guide/sharing-ngmodules' , replace: jest.fn(), reload: jest.fn(), }, }); |
4.2 Jasmine 与 Jest API 修改与对照
jasmine.createSpyObj
->jest.fn
。示例:
1 2 3 4 | // 示例一: 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
类型文件入口等方式解决。参考:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | // 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
的尝试。
相关参考:
好吧,发现我也曾来过!
你留言本上的验证码显示不出来??