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/$1.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
的尝试。
相关参考:
好吧,发现我也曾来过!
你留言本上的验证码显示不出来??