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

目录
[隐藏]

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/$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 总结与参考

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

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

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

相关参考:

点赞 (0)
  1. Lvtu说道:
    Safari 16.1 Safari 16.1 Mac OS X  10.15.7 Mac OS X 10.15.7

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

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

Captcha Code