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)
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.tssoskipLibCheckdoesnā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
LanguageServicevs using TypeScriptProgram. With <9.0.0, we usets-jestto compile which usesLanguage Serviceunderlying while 9.0.0 usesProgram.LanguageServiceis a wrapper aroundProgrambut it seems to have other hidden features. According to https://github.com/Microsoft/TypeScript/wiki/Using-the-Language-Service-API, seems like usingLanguageServiceis better with Jest in term of āon demand processingā.With
isolatedModules: true, the experiment doesnāt change the implementation so it still goes toNgJestCompilerwhich contains the āhackyā solution to use similar set of transformers likeisolatedModules: 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-jestis likely the same asNgJestCompiler.I think it is wise to use
ts-jestforisolatedModules: falsethen.FYI, Iāve made a version which uses
ts-jestto compile instead ofNgJestCompiler. To test it, first you need to:node_modules. These arets-jestandjest-preset-angularfolders required for yournode_modules.I myself tested and I didnāt see any differences comparing to the usage with
NgJestCompiler. Note that only test withisolatedModules: falseAnother note is: this version requires any types in constructor to be imported without using
import typesyntax. Apparently there is something different between TypeScriptProgram(use inNgJestCompiler) vs TypeScriptLanguage Service(use ints-jest).The whole experiment is making 9.0.0 work exactly the same like 8.3.2 except that using different AST transformers.
Check discussion about improving default resolver here https://github.com/facebook/jest/issues/11034
rootNamescomes 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
rootNamesis 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: trueis 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: trueactually uses a simple TypeScriptProgrambehind the public APIts.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#L179Short explanation according to what Iāve experienced and understood from
ts.transpileModuleProgram.isolatedModules: falsewhere the compiler needs to read files.noResolve: true). This is different fromisolatedModules: falsewhere module resolution is required to get enough information for type checking as well as compilation.Most of the hard work in
isolatedModules: falseis 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: falseis skip processing Angular package formatumd, which speeds up a bit but not as significantly likeisolatedModules: true.The performance gain for
isolatedModules: truecomes mostly fromtsc. 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.