jest-preset-angular: Slow tests with 9.0.0-next.6

šŸ’„ Performance Regression Report

I’ve spent a lot more time than I’d like to admit upgrading to 9.0.0-next* and getting things working, and trying all different configurations to determine if it’s ready to use. This past week was the 3rd time I’ve attempted to upgrade, but my conclusion is that the performance hit from upgrading to 9.0.0-next is still too big. I’m detailing a bunch of things that I tried here in hopes of both helping the performance effort for this project, and helping others with perf troubleshooting.

In a nutshell, I’ve found the release version (jest-preset-angular@8.3.1) to yield acceptably fast tests - ā€œI’d like faster, but Jest is the best option for most testsā€. And I’ve found 9.0.0-next.6, and earlier 9.0.0-next versions, with isolatedModules: false, unacceptably slow - 7x slower than @8.3.1 for the first run.

Note that 9.0.0-next.6 with isolatedModules: true is FAST - it’s what I’d hoped for using Ivy in Jest. Unfortunately we don’t want the limitations of typescript compiling with isolatedModules, as there’s no other benefit to using isolatedModules that we care about. If isolatedModules resulted in faster Angular build times, or some other similar benefit, it would probably be worth giving up const enum.

Primary request: Is there any way we can use the same jest compilation path that is being used for isolatedModules: true, but without isolatedModules: true in tsc?

Secondary request: Explanation as to why isolatedModules: true is soooo much faster than isolatedModules: false in 9.0.0-next.

Last working version

Worked up to version: 8.3.1

Stopped working in version: 9.0.0-next.3, 9.0.0-next.6

Motivation

The main reason I’ve been putting so much effort into upgrading to 9.0.0-next.6 is Ivy support: There are a number of benefits (type safety + build perf), but the main reason is that we have code that depends on Ivy behavior, than doesn’t exist in ViewEngine (right now we have to run those tests in Karma). Also, it makes sense to test with the same code used in production.

Since it’s available, I also decided to try Jest + node ESM support. Since we use ESM (primarily) in prod, and many libraries ship separate code for ESM, let’s use the ESM code in tests. And, I was hopeful it would help perf - partly b/c I figured we could use pre-built library code within Jest, reducing Jest’s compilation workload.

Upgrade steps

To clarify the changes between these different test runs:

yarn add -D jest-preset-angular@next jest@next

jest.config.js changes :

require('jest-preset-angular/ngcc-jest-processor');

module.exports = {
  preset: 'jest-preset-angular/presets/defaults', // or 'jest-preset-angular/presets/defaults-esm',

setup-jest.ts changes : Replace import 'jest-preset-angular'; with import 'jest-preset-angular/setup-jest';

Upgrading to ESM

In jest.config.js, use ā€˜jest-preset-angular/presets/defaults-esm’.

Run tests using node --experimental-vm-modules ./node_modules/jest/bin/jest.js .

Performance Tactics

A few of the things I tried to improve Jest performance:

  • Switching to use ESM. It turns out this doesn’t help noticeably, probably b/c UMD modules are loaded by Jest’s default resolver - for now, at least. It also doesn’t achieve my other goal, of using the same library code as we use in production - for now everything run in Jest is UMD (or CommonJS) and ES5.

Note that I could load internal ESM files during Jest compilation by using this in my Jest.config (and equivalent in tsconfig.spec.json):

moduleNameMapper: ā€˜^@this/([a-z\-]+)/([a-z\-]+)$’: ā€˜<rootDir>/dist/$1/fesm2015/this-$1-$2.js’

But all the external dependencies (angular, zone, rxjs) are still loaded as UMD - as noted in #751, this requires a resolver that is ESM aware.

  • Using pre-built library build output instead of source references, to reduce Jest compile workload.

In other words, changing tsconfig.spec.json from:

    "paths": {
      "@this/*": [
        "libs/*/src/public-api.ts"
      ],
    }

to

    "paths": {
      "@this/*": [
        "dist/*"
      ]
    }

and jest.config.js from :

  moduleNameMapper: {
    '^@this/(.*)$': '<rootDir>/libs/$1/src/public-api.ts'
  }

to

  moduleNameMapper: {
    '^@this/(.*)$': '<rootDir>/dist/$1', <-- Linking doesn't work
    'tslib': '<rootDir>/node_modules/tslib/tslib.es6.js'
  }

This didn’t work for me because we use some language features that break in ES5 (base class properties), and this linking path combined with jest’s default resolver pulls in UMD/ES5 library code. You can work around that for internal libraries using:

  moduleNameMapper: {
	'^@this/([a-z\\-]+)/([a-z\\-]+)$': '<rootDir>/dist/$1/fesm2015/this-$1-$2.js',
    '^@this/([a-z\\-]+)$': '<rootDir>/dist/$1/fesm2015/this-$1.js',
    'tslib': '<rootDir>/node_modules/tslib/tslib.es6.js'
  }

But it will still use UMD/ES5 for external libraries.

Timing data

This is non-scientific, run on an 8 core laptop with SSD.

jest-preset-angular@8.3.1 with jest 26, isolatedModules: false (default) : for 392 tests, 78 suites First run (no cache): 62s 2nd run (uses cache): 27s

jest-preset-angular@8.3.1 with jest 26, isolatedModules: true : for 381 tests, 71 suites (8 suites don’t compile) First run (no cache): 42s 2nd run (uses cache): 32s

jest-preset-angular@9.0.0-next.6 with jest jest@27.0.0-next.2, isolatedModules: false, ā€˜jest-preset-angular/presets/defaults’, +ngcc-jest-processor for 392 tests, 78 suites First run (no cache): 446s NOTE: The first test doesn’t complete for 115s 2nd run (uses cache): 33s

jest-preset-angular@9.0.0-next.6 with jest jest@27.0.0-next.2, isolatedModules: true, ā€˜jest-preset-angular/presets/defaults’ for 381 tests, 71 suites (8 suites don’t compile) First run (no cache): 43s 2nd run (uses cache): 32s

jest-preset-angular@9.0.0-next.6 with jest jest@27.0.0-next.2, isolatedModules: false, ā€˜jest-preset-angular/presets/defaults-esm’ for 392 tests, 78 suites First run (no cache): 435s NOTE: The first test doesn’t complete for 117s 2nd run (uses cache): 41s

jest-preset-angular@9.0.0-next.6 with jest jest@27.0.0-next.2, isolatedModules: true, ā€˜jest-preset-angular/presets/defaults-esm’ for 282 tests, 52 suites (27 suites don’t compile) First run (no cache): 41s 2nd run (uses cache): 32s

jest-preset-angular@9.0.0-next.6 with jest jest@27.0.0-next.2, isolatedModules: false, ā€˜jest-preset-angular/presets/defaults-esm’, internal library ESM references for 392 tests, 78 suites First run (no cache): 258s NOTE: The first test doesn’t complete for 40s 2nd run (uses cache): 40s

jest-preset-angular@9.0.0-next.6 with jest jest@27.0.0-next.2, isolatedModules: true, ā€˜jest-preset-angular/presets/defaults-esm’, internal library ESM references for 287 tests, 55 suites (24 suites don’t compile) First run (no cache): 38s 2nd run (uses cache): 27s

Tests per second for different configurations

Configuration 1st run 2nd run 1st run tests/sec 2nd run tests/sec
8.3.1 62s 27s 6.3 14.5
8.3.1 + isolatedModules 42s 32s 9.1 11.9
9.0 446s 33s 0.9 11.9
9.0 + isolatedModules 43s 32s 8.9 11.9
9.0 + ESM 435s 41s 0.9 9.6
9.0 + ESM + isolatedModules 41s 32s 6.9 8.8
9.0 + ESM + internal library ref 258s 40s 1.5 9.8
9.0 + ESM + isolatedModules + internal library ref 38s 27s 7.6 10.6

Link to repo

I can’t expose all our tests, but the setup is very similar to this repo + branch:

https://github.com/johncrim/repro-ivy-jest-preset-angular/tree/bug/inline-directive

envinfo

System:
    OS: Windows

Npm packages:
    jest: 27.0.0-next.2
    jest-preset-angular: 9.0.0-next.6
    typescript: 4.0.5

About this issue

  • Original URL
  • State: closed
  • Created 3 years ago
  • Comments: 28 (13 by maintainers)

Most upvoted comments

Maybe cache file miss on Windows ? After each time a file is read, it is put into memory. It can be that when getting from memory, the path is Windows path while cached path is different. This can be checked by producing the log file via env variable.

we don’t process .d.ts so skipLibCheck doesn’t see much difference.

Indeed I observed on CI that windows tests are consistently faster with the changes, about 25%.

Interesting about how it resolved the perf issue on your side, that could confirm my theories about something different between using TypeScript LanguageService vs using TypeScript Program. With <9.0.0, we use ts-jest to compile which uses Language Service underlying while 9.0.0 uses Program.

LanguageService is a wrapper around Program but it seems to have other hidden features. According to https://github.com/Microsoft/TypeScript/wiki/Using-the-Language-Service-API, seems like using LanguageService is better with Jest in term of ā€œon demand processingā€.

With isolatedModules: true, the experiment doesn’t change the implementation so it still goes to NgJestCompiler which contains the ā€œhackyā€ solution to use similar set of transformers like isolatedModules: false.

The link error is typical error with ESM, I’m not sure why that happens but I never see it happens on CI, only in my local. I don’t expect any perf on linking because the implementation for ESM in ts-jest is likely the same as NgJestCompiler.

I think it is wise to use ts-jest for isolatedModules: false then.

FYI, I’ve made a version which uses ts-jest to compile instead of NgJestCompiler. To test it, first you need to:

  • Download jest-preset-angular.zip
  • Download ts-jest.zip
  • Unzip and copy them to your node_modules. These are ts-jest and jest-preset-angular folders required for your node_modules.
  • Clear Jest cache and run tests

I myself tested and I didn’t see any differences comparing to the usage with NgJestCompiler. Note that only test with isolatedModules: false

Another note is: this version requires any types in constructor to be imported without using import type syntax. Apparently there is something different between TypeScript Program (use in NgJestCompiler) vs TypeScript Language Service (use in ts-jest).

The whole experiment is making 9.0.0 work exactly the same like 8.3.2 except that using different AST transformers.

  • Switching to use ESM. It turns out this doesn’t help noticeably, probably b/c UMD modules are loaded by Jest’s default resolver - for now, at least. It also doesn’t achieve my other goal, of using the same library code as we use in production - for now everything run in Jest is UMD (or CommonJS) and ES5.

Check discussion about improving default resolver here https://github.com/facebook/jest/issues/11034

rootNames comes from the setting in tsconfig. Normally, for a test tsconfig, it will include all test files + all custom typings. However, there is an issue to create Program initially with the list of files. The Program will take lots of time to read all those files as well as module resolution which leads to slow startup.

So we took the approach to stimulate incremental compilation. Test files are excluded from startup Program and other files which are not known at startup will be automatically added to Program later, as well as test files. This will reduce I/O on disk which helps for non-SSD users.

Fixing the initial rootNames is not actually the right fix, the main issue is that, each worker thread has its own Program. That significantly increases the workload on CPU. IMO, the best approach is compile ahead of test starts and let Jest transformer reuse the cached compile codes as well as cached file contents.

In general, the approach to work with Jest is file by file processing. isolatedModules: true is a completely different thing. It is a Program which only knows the information of current processing file but doesn’t know about other files, that is why it is called ā€œisolated modulesā€

Like I explained above, isolatedModules: true actually uses a simple TypeScript Program behind the public API ts.transpileModule. I have copied the whole implementation of that function from TypeScript source code to be able to hack it to use all possible Angular AST transformers, see https://github.com/thymikee/jest-preset-angular/blob/master/src/compiler/ng-jest-compiler.ts#L179

Short explanation according to what I’ve experienced and understood from ts.transpileModule

  • Create a simple TypeScript Program.
  • Ignore readFile. This is different from isolatedModules: false where the compiler needs to read files.
  • Skip module resolution (noResolve: true). This is different from isolatedModules: false where module resolution is required to get enough information for type checking as well as compilation.
  • Some other hidden things which I’m not aware of

Most of the hard work in isolatedModules: false is related to read file, module resolution and perhaps some other things which are related to how type checking works.

Currently I only have one solution to improve for isolatedModules: false is skip processing Angular package format umd, which speeds up a bit but not as significantly like isolatedModules: true.

The performance gain for isolatedModules: true comes mostly from tsc. The transformers are only used to fill a gap which allows angular syntax to be used with Jest.

The different transformers are almost the same implementation, it’s just that we switched to Angular’s implementation since that means less code to maintain for us.