1. Why switch from Karma+Jasmine to Jest?

The official recommended unit testing framework for Angular is Karma + Jasmine by default.

Karma is used to execute unit tests in a real browser environment by launching the Chromium browser. Jest specifies the runtime environment through configuration, usually jsdom, and each test file is executed in a separate runtime environment.

The main problems with Karma currently are the following.

  • the need to launch the browser, compile the entire project and execute the unit test cases in the browser
  • The result is unstable due to side effects of test case execution as it is executed in the browser and shared runtime environment.
  • No support for single-file testing, so the unit test development and debugging experience is relatively weak.
  • The CI execution environment requires chromium browser to be installed, which is relatively cumbersome

Advantages of replacing Jest with

  • The runtime environment is based on jsdom, no need to start a browser
  • Support single-file testing and caching of file compilation results
  • Supports parallel execution on multi-core CPUs
  • Exceptions are presented as a single-file call stack, with clear hints and easy to locate and analyze.
  • A generic Node.js docker image is sufficient for execution on CI

Jest should be chosen carefully for the following scenarios: CI Runner is a single-core, low-performance virtual machine. The main reason is that the single-file standalone environment of Jest, which can be executed in parallel, has no advantage in terms of execution efficiency. Since each test file needs to start the environment when running, file preloading and processing is extremely time-consuming for large projects with deep file dependency chains. Because single-core can only execute serially, the final execution time can be very long and almost unacceptable.

This is where karma+jasmine’s compile once, execute centrally in the browser approach comes into its own.

In general, the main purpose of migrating to Jest is to get a better single-file test writing development experience. However, for large projects, the Jest single-file standalone environment requires multi-core processors to get better execution efficiency, while karma+Jasmine requires less machine performance.

2. Options for Angular using Jest

The main popular solutions in the community are @angular-builders/jest and jest-preset-angular. Based on npm download trends, jest-preset-angular clearly has a definite advantage, so it is chosen.

Also, since the project already has a lot of unit tests written based on jasmine, the API calls need to be migrated, although Jest is compatible with its unit test writing style. You can use the tool jest-codemods.

3. Process reference for migrating from Karma to Jest

3.1. Jest dependency installation

Add Jest-related dependencies by executing the following command.

1
npm install -D jest jest-preset-angular @types/jest

3.2. Configuring Jest

Create a new file src/jest-setup.ts with the following reference.

1
2
import 'jest-preset-angular/setup-jest';
import '../__mocks__/jestGlobalMocks';

Create a new file __mocks__/jestGlobalMocks.ts with the following reference.

 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',
    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: Other global variables compatible
// Such as jquery:
window.$ = window.jQuery = require('jquery');
window.__DEV__ = 'test';
window.alert = (text: string) => console.warn(text);

Create a new jest.config.js file with the following content reference.

 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, // Disable type checking.
    },
  },
  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),
};

The contents of the tsconfig.spec.json file are adjusted with the following reference.

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. Migrating unit test code using jest-codemods

First test the effect by executing the following command.

1
npx jest-codemods src -d

If no exceptions are reported, the actual migration command can be executed as follows (remember to commit all modified code in advance so that it can be reset and rolled back at any time).

1
npx jest-codemods src -f

jest-codemods can help update the application of the more standardized jasmine API by replacing it with the implementation corresponding to the jest API. However, there will still be some less standardized writings that need to be confirmed and manually fixed based on the results of unit test execution feedback on a case-by-case basis.

3.4. Removing Karma

If the migration process described above is complete and all unit tests are running smoothly with jest, then the karma dependency can be removed.

  • Remove karma and jest dependencies from package.json.
  • Remove the karama configuration file

4. Problems encountered and solutions

4.1. Global object mock

Since karma compiles all files and launches tests in the browser, while jest executes tests based on jsdom as a single file, there is a big difference in its initialization environment, and some APIs not supported by jsdom and global APIs predefined by the application do not exist in the jest unit test environment. This can be solved by mocking the relevant APIs.

If the window.getComputedStyle API is not implemented in jsdom, you can create the file jestGlobalMocks.ts and implement its basic API, then introduce it in jest-setup.ts (see the jestGlobalMocks.ts file example above).

The string prototype is extended in utils/global.ts as in the application: String.prototype.isGb = () => {...}. Then you can introduce it in the jest-setup.ts file.

If you call location, jsdom will report the following exception.

Error: Not implemented: navigation (except hash changes)

This can be solved by mocking the location object.

 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',
    href: 'http://localhost/guide/sharing-ngmodules',
    origin: 'http://localhost',
    pathname: '/guide/sharing-ngmodules',
    replace: jest.fn(),
    reload: jest.fn(),
  },
});

4.2. Jasmine and Jest API modification and comparison

  • jasmine.createSpyObj -> jest.fn. The example is shown below.

    1
    2
    3
    4
    
    // Example 1.
    const spy = jasmine.createSpyObj(['getListSync']);
    // =>
    const spy = { getListSync: jest.fn() };
    

    moreā€¦

The main problem is that the module provides output of commonjs and esmodule types, and there are problems such as dependency library entry finding exceptions and transform exceptions. For the default use of esm mode to execute jest, you can configure transformIgnorePatterns to filter the translation of esm modules, configure moduleNameMapper to relocate the exception module to the corresponding esm type file entry, etc.

 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/$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
  ],
};

Reference :https://github.com/thymikee/jest-preset-angular/issues/1147

5. Summary and reference

To summarize the above practice process, we can summarize its main process as follows.

  1. add jest dependencies
  2. remove karma and jasmine dependencies
  3. configure jest. Depending on the exceptions from the unit test execution, you may need to add some compatibility configurations, such as esm module path mapping, global variable compatibility, etc.
  4. execute npx jest-codemods src -f to help migrate the unit test code
  5. Execute pnpm jest to confirm and manually handle compatibility with unit test exceptions on a case-by-case basis.

Finally, compared to jasmine and created jest, jest is also disliked too bloated and appeared vitest, there is time to do the Angular project unit testing framework switch to vitest attempt.