Discover how migrating to Jest ESM (a migration that is notoriously tricky) can accelerate your Angular Jest tests by over 100%, along with every solution needed to resolve the frequent and frustrating obstacles that appear during the process!

emoji_objects emoji_objects emoji_objects
Tomas Trajan

Tomas Trajan

@tomastrajan

Jan 10, 2023

13 min read

Jest ESM - Total Guide To More Than 100% Faster Testing For Angular ⚡
share

Idea, Prompts, Composition & Design by Tomas Trajan, Gen by MindJourney

For me, the end result was an almost 290% improvement—dropping from 50 seconds all the way down to just 17 seconds, which is a massive gain!

Testing with Jest happens in parallel, and there’s even an option to set the number of workers to align with your machine’s cores, boosting its speed considerably!

Jest NPM stats

Because of these factors, Jest emerged as the preferred alternative to the Karma test runner, which is included by default in Angular CLI workspaces.

This article specifically examines Jest ESM integration within standard Angular CLI workspaces,
employing plain jest in conjunction with jest-preset-angular. Alternative approaches
to using Jest with Angular projects exist, such as @angular-builders/jest or
built-in support in NX monorepos. Nonetheless, the principles and strategies
outlined in this article should prove valuable when migrating or debugging Jest ESM
across any of these setups!

ESM

When we refer to Jest ESM, it indicates Jest operating in a configuration where it
recognizes and leverages EcmaScript Modules, particularly the ESM import/export
syntax. This concept should be quite recognizable since it's what we've utilized in Angular
TypeScript files from the very start… (learn more)

import addDays from 'date-fns/addDays'; // ESM default import
import { Component } from '@angular/core'; // ESM import

@Component({
  /*...*/
})
export class AppComponent {} // ESM export

TLDR;

  • Jest performs well until it doesn't, which is why we need to deal with ESM
  • A basic Jest ESM configuration is fairly simple to set up
  • An optimal Jest ESM setup would work seamlessly if libraries shipped correct ESM code
  • In practice, Jest ESM fails because most libraries provide incorrect ESM implementations
  • The moduleNameMapper and transformIgnorePatterns are our key tools for solving every Jest ESM issue
  • We'll walk through a series of typical issues and their fixes (feel free to share your own in the comments to expand the list)

Jest is quick, but then it becomes painfully slow

Jest's biggest advantage is its speed, particularly when compared to the standard Karma setup…

What's stopping the Angular team from swapping Karma for Jest as the default test runner in Angular CLI workspaces?

Jest build pipeline

It turns out Jest includes its own "isolated" build pipeline that lacks modularity. Due to this, it can't ingest the output generated by tools like ng build.

To add Jest support, the Angular team would need to manage two distinct build pipelines simultaneously. Hopefully this clarifies their choice not to include Jest by default and the reasoning behind it!

Consequently, the Jest setup for Angular projects must replicate nearly all functionality that the Angular CLI provides for ng build, plus additional tasks. It also explains why this entire process is so intricate!

Furthermore, it highlights that most of these challenges (and our need to address them) stem from Jest's architecture, which prevents smooth integration with other tools (such as the Angular CLI build pipeline). Keep this in mind as we venture further into this configuration maze! 😅

Managing the complexity

Creating a separate Jest build pipeline to run ngcc for libraries or to compile Angular components alongside their templates would be quite tedious—this is where libraries like jest-preset-angular come in handy, as they conceal at least part of that complexity!

With Jest and jest-preset-angular set up, Angular testing with Jest in most projects "just worked" and delivered impressive speed right away—at least until Angular 12 was introduced!

Jest testing for Angular 12 (and newer versions) became drastically slower because Angular ceased shipping UMD bundles, which Jest depended on. As a result, Jest now must separately transpile Angular (and other Angular libs) within its own build pipeline

Angular 12 and the significant Jest slowdown

Angular 12's release introduced a significant overhaul to the Angular Package Format, altering how Angular libraries are distributed. See the relevant changes in the Angular changelog:

So, when we inspect the node_modules directory of an Angular project, we'll likely encounter something like the following…

node_modules/
  @angular/
    core/
      // no more umd/ folder !!!
      esm2020/
      esm2015/
      fesm2020/  // folder
        core.mjs // js bundle with ESM import / export syntax and .mjs ext
      fesm2015/
        core.mjs

      // other sub-entries like testing/...
      package.json
      index.d.ts

From version 12 onward, Angular has dropped UMD bundles completely, offering solely ESM bundles!

That development takes us back to Jest’s distinct compilation process—it functions natively in CommonJS (CJS) mode, while ESM compatibility requires explicit activation and setup.

Given that Angular 12 stopped shipping UMD bundles, attempting to execute our Jest tests right after migrating to Angular 12 would surface a fresh error…

SyntaxError: Cannot use import statement outside a module

Jest encountered an unexpected token

Jest failed to parse a file. This happens e.g. when your code or
its dependencies use non-standard JavaScript syntax,
or when Jest is not configured to support such syntax.

Out of the box Jest supports Babel, which will be used to transform
your files into valid JS based on your Babel configuration.

By default "node_modules" folder is ignored by transformers.

The adjustment to the standard Jest configuration was fairly straightforward to implement. Specifically, we needed to include @angular (or, more precisely, .*\.mjs) in the transformIgnorePatterns setting of the Jest config file (we'll dive deeper into that shortly).

As a result, the Jest environment had to first transpile the complete Angular library found in node_modules, converting it from ESM to CJS format, before it could be consumed seamlessly.

This necessity didn't stop with Angular itself. Any other libraries that were constructed with Angular 12, such as Angular Material or various custom internal packages that are frequently seen in larger corporate settings, also required this transpilation step.


Follow me on Twitter because that way you will never miss new Angular, NgRx, RxJs and NX blog posts and other cool frontend stuff!😉


Jest ESM basic setup

Now that we've clarified the reason behind the slower default Jest setup in Angular CLI workspaces, let's look at how we can restore performance by enabling Jest's ESM support.

As mentioned earlier, our approach relies on jest-preset-angular to manage the intricacies of combining Jest with Angular.

This helpful library also offers dedicated presets for ESM that are simple to activate. Furthermore, the library's GitHub
repository contains a wide range of examples that serve as excellent references for configuring
Jest properly within Angular CLI workspaces.

To begin, we'll install the necessary dependencies
(it's wise to consult the jest-preset-angular changelog to determine the
appropriate version for your specific Angular setup
)

npm i -D jest @types/jest jest-preset-angular

Now, let's add the npm script below to the root package.json file.

{
  "scripts": {
    "test": "node --experimental-vm-modules --no-warnings node_modules/jest/bin/jest.js --config src/jest.config.mjs"
  }
}

Rather than invoking jest directly as we typically would, we're launching it
via node and passing several environment variables that may be redundant
depending on which node version runs in your setup.

Additionally, we're specifying our Jest configuration file, here referred to as src/jest.config.mjs.

Now, let's turn to what goes inside the Jest config file…

export default {
  // use esm preset (from jest-preset-angular )
  preset: 'jest-preset-angular/presets/defaults-esm',

  // use esm global setup (from jest-preset-angular)
  globalSetup: 'jest-preset-angular/global-setup.mjs',

  extensionsToTreatAsEsm: ['.ts'],

  // another setup file which we will create in the next step
  setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],

  globals: {
    'ts-jest': {
      // path might be different based on your workspace setup
      // <rootDir> represents the location of jest.config.mjs
      tsconfig: '<rootDir>/tsconfig.spec.json',
      stringifyContentPathRegex: '\\.(html|svg)$',
      useESM: true,
    },
  },

  // more on this later
  moduleNameMapper: {
    // eg when importing symbol (tslib) use content of the file (path)
    tslib: 'tslib/tslib.es6.js',
  },

  // perf (you might try various options based on the available cores)
  maxWorkers: '8',
};

The annotations in the code should be clear enough on their own.
In essence, we rely on ESM-oriented presets provided by the jest-preset-angular
package and adjust the underlying ts-jest configuration by enabling
the useESM: true flag.

To wrap up the initial setup, we need to point to the referenced jest.setup.ts file,
which serves practical purposes like setting up global mocks
(for instance, for window.matchMedia) or importing certain global utilities
that enhance the testing and debugging workflow,
such as @angular-extensions/pretty-html-log.

With that in mind, the initial file's contents will resemble the following…

import 'jest-preset-angular/setup-jest.mjs';

And there you have it — that's the complete Jest ESM configuration! Naturally, things won't stay this straightforward for long…

Once this setup is in place, Jest will be able to natively consume Angular 12 ESM bundles (those .*\.mjs files) without
any transpilation step.

In a perfect world, Jest ESM would work out of the box

As we've demonstrated, the basic Jest ESM configuration isn't overly complicated — a couple of config files, maybe an unusual flag or two, but nothing particularly exotic overall.

If every package shipped proper ESM bundles (the way Angular does), that configuration would be all you'd ever need. Jest ESM would automatically locate the correct ESM bundle within the library directory in node_modules,
and we'd simply enjoy excellent test performance!

First, we'll outline what a properly shipped ESM-based library looks like (essentially mirroring what Angular already does), and then we'll examine all the scenarios where this breaks down and how to address them…

The ideal ESM library situation

Disclaimer: What follows is a somewhat simplified understanding of ESM based on how Jest actually resolves modules, and it may not align perfectly with the official ESM specification — please feel free to share additional reference materials in the comments to improve this!

The scenarios listed below follow the order in which Jest determines which file to load (import) whenever it comes across an import statement in our code.

In an ideal scenario, a library that provides ESM-based bundles would choose one of these strategies:

  • the package contains only ESM and sets "type": "module" within its package.json, using the .js extension for the ESM files
  • the package contains only ESM but omits "type": "module" from its package.json, relying on the .mjs extension for the ESM files
  • the package provides both ESM and CJS (and optionally UMD) bundles, with matching extensions per format — namely .mjs for ESM and .cjs (or .js) for CJS
  • the package ships both ESM and CJS (and possibly UMD) bundles, includes "type": "module" in its package.json, and uses .js for ESM files while reserving .cjs for CJS

Clearly, there are many valid combinations here, and all of them would function seamlessly with Jest right away — unfortunately, many packages deviate from these conventions.

This isn't all that surprising, given that the broader CJS-to-ESM migration within the Node ecosystem is quite messy and difficult to nail down correctly from the start; ideally, things will improve over time as more libraries transition to ESM-only or at least properly configured ESM.


Our toolkit

Before diving into the list of frequent problems and their fixes, let's take a brief side trip to explore the two key tools we'll rely on to resolve every issue we come across.

These tools are the two configuration options found within the jest.config.mjs file: the moduleNameMapper and the transformIgnorePatterns.

The moduleNameMapper

This configuration option lets us control which file gets loaded whenever we import a symbol from a library in our source code.

For instance, when we attempt to import something like…

import { Observable } from 'rxjs';

Under the hood, the module resolution process locates the rxjs/ directory within node_modules/.

It then looks for a package.json there, which, through fields like the exports map, directs the bundler to actual JavaScript code—for instance, mapping to "es2015": "./dist/esm/index.js".

By leveraging the moduleNameMapper option in jest.config.mjs, we can deviate from this default lookup and route those imports to an alternate file.

Take a look at this example…

{
  moduleNameMapper: {
    '^rxjs(/operators$)?$': '<rootDir>../../node_modules/rxjs/dist/bundles/rxjs.umd.js',
  }
}

In the snippet above, we’re redirecting rxjs imports to the dist/bundles/rxjs.umd.js
rather than to dist/esm/index.js, which would have been the default target.
At first glance, this might seem odd given that we’re aiming to configure Jest ESM,
but we’ll dive into the reasoning behind that choice in a later section.

The moduleNameMapper safety tip

This property is essentially a regular expression, so it’s wise to make your pattern as strict as you can,
for instance by anchoring it with ^ and $. Skipping those anchors can easily lead to a debugging headache.

To illustrate, if you set a rule that's simply rxjs, it would catch any import that merely
includes the letters rxjs, like my-custom-rxjs.operators.ts. That would trigger issues such as
Can't import myCustomOperator from my-custom-rxjs.operators.ts as the symbol was not exported.
The root cause here is that the import gets resolved to that dist/bundles/rxjs.umd.js
instead of your actual file.

The transformIgnorePatterns

Next up, we have transformIgnorePatterns, the second tool in our arsenal.

It’s a bit counterintuitive, at least when you first see it, because the typical usage involves a double negative.

The default value might look something like…

{
  transformIgnorePatterns: ['node_modules/'];
}

Consequently, we intentionally skip transforming everything within the node_modules/ directory, which is logical given that these packages ship as compiled artifacts.

The actual configuration is defined by the second negation: we utilize the regexp negative lookahead construct ?! to "exclude items from the previously excluded set".

Consider the following illustration.

{
  transformIgnorePatterns: [
    // ignore everything in node_modules besides the cases when:
    // 1. it has tslib in its path
    // 2. the file ends with.mjs
    'node_modules/(?!(tslib|.*.mjs)',
  ];
}

The snippet above illustrates the Jest configuration we could utilize to get Angular 12 working without undergoing the Jest ESM migration.

Typically, we rely on the transformIgnorePatterns to facilitate transpilation for libraries that can't be managed otherwise. Each library added here will inevitably slow down the overall test suite performance.

Hence, it's crucial to routinely assess whether these libraries can be eliminated from the transformIgnorePatterns list, especially as newer versions might introduce proper ESM support!

Now that we grasp the available tools, let's tackle the typical challenges and their fixes for integrating Jest ESM into our Angular CLI projects!



Common issues and how to solve them

Library falls back to UMD/CJS despite lacking (or having invalid) ESM

Take rxjs as a prime example: it includes esm directories and bundles, but unfortunately, they suffer from:

  • Using the .js file extension
  • Missing "type": "module" in its package.json

Together, these factors cause Jest to misinterpret the file content as CJS (or UMD) when it isn't, leading to the SyntaxError: Cannot use import statement outside a module error.

To resolve this, we can add the following mappings to moduleNameMapper.

{
  moduleNameMapper: {
    '^rxjs(\/operators)?$': '<rootDir>../../node_modules/rxjs/dist/bundles/rxjs.umd.js',
    '^rxjs/testing$': '<rootDir>../../node_modules/rxjs/dist/cjs/testing/index.js',
  }
}

Using moduleNameMapper directs Jest to the RxJs UMD bundles, which already have the expected content inside them.

As long as moduleNameMapper targets the right file, libraries with proper UMD will work without further tweaks!

Lib has invalid ESM AND invalid UMD

There are cases where a library completely ignores how Jest resolves imports, making it impossible to simply redirect to a valid ESM (or UMD) bundle.

When this happens, the recommended approach is to leverage moduleNameMapper to reference a bundle that uses ESM import/export syntax, while also ensuring the library is left out of the transformIgnorePatterns.

Good illustrations of such libraries are tslib and @googlemaps/markerclusterer.

moduleNameMapper: {
    tslib: 'tslib/tslib.es6.js', // didn't figure out why this one works without full path
    '@googlemaps/markerclusterer': '<rootDir>../../node_modules/@googlemaps/markerclusterer/dist/index.esm.js',
  },
  transformIgnorePatterns: [
    'node_modules/(?!(tslib|@googlemaps/markerclusterer))',
  ],

Once this configuration is in place, Jest targets files that rely on ESM syntax. However, because the module resolution defaults to expecting CommonJS, you must include these same files under the transformIgnorePatterns as well.

Lib has valid ESM but invalid package.json configuration

While I haven't run into this scenario yet, the fix is straightforward: guide Jest to the appropriate file using moduleNameMapper. For instance, if a library ships an index.mjs that holds proper ESM code, mapping that alias should be enough to resolve the issue.

{
  moduleNameMapper: {
    '^some-lib$': '<rootDir>../../node_modules/some-lib/index.mjs',
  }
}

Now that we’ve covered the three most common generic issues caused by setup (or packaging) problems in libraries that aim to ship ESM, we can move forward.


A more Angular-focused challenge arises next because of how Angular relies on Zone.js for change detection, which conflicts with the async / await style that the widely-used Angular CDK test harness APIs depend on…

The infamous ProxyZone error

This issue appears in projects that combine Angular CDK test harnesses with async / await syntax when authoring their own test cases.

When you run those tests under Jest ESM, you’ll encounter the expected failure message: Expected to be running in 'ProxyZone', but it was not found.

Here’s an illustration of what such a test might look like…

it('should check the checkbox if we set the value to true', async () => {
  component.initialValue = true;
  fixture.detectChanges();

  const checkbox = await loader.getHarness<MyOrgCheckboxHarness>(
    MyOrgCheckboxHarness,
  );

  expect(await checkbox.checked()).toBe(true);
});

The short version of why this happens is that when our test code
uses await on an async operation, everything on the following line
executes in a different zone, which ultimately triggers that error.

The first fix involves updating our tsconfig.spec.json to set
"target": "es2015". This forces the test code to be transpiled
down to a JavaScript version that predates async / await,
so the syntax gets replaced with a polyfill in practice.

While this resolves the issue in our own test code, the ProxyZone error still appears!

It turns out that even with our override in the tsconfig.spec.json,
Jest ESM picks @angular/cdk/fesm2020/cdk.mjs over
@angular/cdk/fesm2015/cdk.mjs (the exact reason is unclear),
and that es2020 version naturally includes native
async / await, which causes the failure.

To address this second issue, we need to override it using the moduleNameMapper!

{
  moduleNameMapper: {
    '^@angular/cdk$': '<rootDir>../../node_modules/@angular/cdk/fesm2015/cdk.mjs',
    '^@angular/cdk/testing$': '<rootDir>../../node_modules/@angular/cdk/fesm2015/testing.mjs'
  }
}

By doing this, Jest ESM will read the downleveled
async / await code, and the ProxyZone issue is gone for good.

Honorable mention: Unintended file matching with moduleNameMapper

As highlighted earlier, the moduleNameMapper settings are tricky—they can accidentally match your own source files, leading to elusive bugs. This is especially risky when the names are shorter, such as rxjs, which often appears in the filenames of your codebase.

A reliable safeguard is to wrap your patterns with ^ and $ so the regexp only touches what you intend.

Honorable mentions: The missing export error

Certain combinations of jest, jest-preset-angular, typescript and ts-jest versions may trigger the SyntaxError: The requested module does not provide an export name X. A practical fix is to make sure all .ts files are listed in the test tsconfig.spec.json configuration.

{
  "include": ["**/*.ts", "..."]
}

Real life results

Once every one of those adjustments was applied, I managed to cut the full test-suite execution time from 53 seconds down to 17 seconds using Jest ESM — that’s a staggering ~290% performance gain! ⚡

Jest test run results in CLI

Embrace the speed!

Excellent! I trust you found the journey into accelerating your Angular CLI test suites with Jest ESM and jest-preset-angular a rewarding one.

Given how these challenges vary based on the exact mix of dependencies and versions in your project, these same tactics can help you get Jest ESM operational in other setups, such as with @angular-builders/jest.

These strategies generally assist you in diagnosing and resolving issues whenever you can modify the Jest configuration, focusing specifically on the moduleNameMapper and transformIgnorePatterns settings.

Feel free to drop a comment and share the performance gains you've unlocked by adopting these techniques! 😉

Moreover, reach out me with any questions via the article comments or Twitter DMs @tomastrajan.

And always remember, the future looks bright

Obviously the bright Future

Clearly, that's the bright tomorrow! (📸 by Tomas Trajan )

Like the vibe of the code preview? Check out our brand new theme plugin

Skol - the top IDE theme

Skol - the ultimate IDE theme

Bring the aurora borealis vibe directly into your editor. A lightweight yet effective dark theme, combining a sleek look with eye-friendly comfort.

Enhance your Angular workflow by integrating AI capabilities

AI Integration for Angular: A Video Series

Angular + AI Video Course

A project-based tutorial that walks you through embedding AI within Angular apps via Hash Brown to craft smart, responsive interfaces.

Cover streaming conversation, function invocation, dynamic UI rendering, structured data outputs, and everything in between—progressively.

Seeking a hands-on manual for Angular Signal Forms structure, validation, and moving your existing code?

Angular Signal Forms Comprehensive Guide

Angular Signal Forms eBook

Craft Angular forms that are fully typed, validated, and ready for production by leaning on signals and a model-first mindset.

Discover schema-driven validation, form-state signals, custom controls, migrating from Reactive Forms, and clear API mapping techniques.

Appreciate the material and want to get hands-on with Angular's all-new Signal Forms?

Angular Signal Forms: Practical Deep Dive

Angular Signal Forms: Hands-On Masterclass

Work through Angular’s freshly introduced Signal-Forms across twelve step-by-step chapters that blend conceptual grounding with practical exercises.

Dive into core form concepts, validation logic, bespoke controls, nested form structures, migration routes, and beyond.

Win win deal illustration

Stay in the loop
with fresh articles

Subscribe to the Angular Experts Content Updates & News, and we’ll send you a heads-up every time a new post lands on Angular, Ngrx, RxJs, or other cool frontend topics.

Your email stays with us and is never shared with anybody, plus you can opt out anytime!

Newsletters might carry extra promotional content—check our Privacy policy for all the details.

Questions & feedback

Feel free to ask anything, drop your own insights, or share how you see things fitting into this subject

Tomas Trajan - GDE for Angular & Web Technologies

Tomas Trajan

Google Developer Expert (GDE)
for Angular & Web Technologies

Google Developer Experts logo X logo LinkedIn logo Github logo Github logo Spotify logo Medium logo public

My focus is on enabling development teams to ship robust Angular products through consulting and hands-on training, specializing in NgRx and architecture.

Holding the designation of Google Developer Expert for Angular & Web Technologies, I work as an Angular trainer and independent consultant. Currently, I support enterprise teams worldwide with core feature implementation, system architecture, adoption of industry best practices, knowledge transfer, and process optimization.

Tomas is committed to delivering tangible value to both clients and the broader developer ecosystem. His efforts are reflected in a rich portfolio of acclaimed technical writing, presentations at global meetups and conferences, and active participation in open-source development.

52

Blog posts

4.7M

Blog views

3.5K

Github stars

612

Trained developers

39

Given talks

8

Capacity to eat another cake

You might also like

Dive into the archives of Angular Experts for deeper insights into topics connected to Angular !

Top 10 Angular Architecture Mistakes You Really Want To Avoid

Top 10 Angular Architecture Mistakes You Really Want To Avoid

In 2024, Angular keeps changing for better with ever increasing pace, but the big picture remains the same which makes architecture know-how timeless and well worth your time!

emoji_objects emoji_objects emoji_objects
Tomas Trajan

Tomas Trajan

@tomastrajan

Sep 10, 2024

15 min read

Angular Signal Inputs

Angular Signal Inputs

Revolutionize Your Angular Components with the brand new Reactive Signal Inputs.

emoji_objects emoji_objects emoji_objects
Kevin Kreuzer

Kevin Kreuzer

@nivekcode

Jan 24, 2024

6 min read

Improving DX with new Angular @Input Value Transform

Improving DX with new Angular @Input Value Transform

Embrace the Future: Moving Beyond Getters and Setters! Learn how to leverage the power of custom transformers or the build in booleanAttribute and numberAttribute transformers.

emoji_objects emoji_objects emoji_objects
Kevin Kreuzer

Kevin Kreuzer

@nivekcode

Nov 18, 2023

3 min read

Put our years of know-how to work for your team

Through countless consulting engagements with both large enterprises and early-stage startups, plus workshops, tutorials, and a steady stream of open source contributions, Angular Experts have built up deep expertise in modern front-end development. We take a lot of satisfaction in applying that knowledge, and helping your business flourish would be a genuine pleasure