Angular 单元测试框架由 karma 迁移为 Jest 实践

1,566次阅读
一条评论

共计 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/jestjest-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 总结与参考

小结一下以上实践过程,可以总结其主要流程如下:

  1. 添加 jest 相关依赖库
  2. 移除 karmajasmine 相关依赖库
  3. 配置 jest。根据单测执行反馈的异常,可能需要添加一些兼容配置,如:esm 模块路径映射、全局变量兼容等
  4. 执行 npx jest-codemods src -f 以辅助迁移单测代码
  5. 执行 pnpm jest,根据单测反馈的异常,逐个确认与手动兼容处理

最后提一下,相比因看不惯 jasmine 而创建出了 jestjest 也同样因被嫌弃过于臃肿而出现了 vitest,有时间可以再做一下 Angular 项目单测框架切换为 vitest 的尝试。

相关参考:

正文完
 0
任侠
版权声明:本站原创文章,由 任侠 于2022-10-20发表,共计6607字。
转载说明:除特殊说明外本站文章皆由CC-4.0协议发布,转载请注明出处。
评论(一条评论)
验证码
Lvtu 评论达人 LV.1
2022-11-08 10:02:03 回复
Safari 16.1 Safari 16.1 Mac OS X 10.15.7 Mac OS X 10.15.7

好吧,发现我也曾来过! :biggrin:
你留言本上的验证码显示不出来??

 Macintosh  Safari  中国广东省东莞市移动