Angular v16 surviving the preview. Jest

Sergey Gultyayev
4 min readJun 19, 2023

With the release of Angular v16 we got a number of interesting features which are in developer’s preview. Our team has decided to try them out on a real project and now I’m ready to share our first observations.

I’ve been writing unit tests both in Jasmine and Jest and find the latter one to be faster (mostly because it doesn’t need to start a browser), but also the API is more appealing to me.

To use Jest instead of Jasmine we need to uninstall all Jasmine/Karma dependencies and install Jest npm i jest .

After that, we should update the architect.test config in angular.json with the following value:

"test": {
"builder": "@angular-devkit/build-angular:jest",
"options": {
"tsConfig": "tsconfig.spec.json",
"polyfills": ["zone.js", "zone.js/testing"]
}
}

If you don’t remove the options that were in the old configuration — ng test won’t run until you remove them. After the config was changed you should be able to run ng test and voila! Tests are ran using Jest and ESBuild.

One more interesting thing is that we first compile .ts to .cjs and only then tests are run. With Jasmine, the app was still compiled to some extend and this took time which wasn’t explicitly logged. Therefore, it was hard to tell whether your app is big and it’s taking a lot of time to compile, or the tests are slow. You only had one metric — which is the whole run.

Now, we have separate logs: for app build with its metrics and for tests. This provides a clearer view on the timings.

Mocking modules

The first thing that caught us off-guard is that jest.mock() doesn’t work anymore. The reason for that was quickly found in the compiled .cjs files. The files look like

import { SomeClass } from 'chunk.HASH.cjs'

jest.mock('chunk', ...)

And this explains why Jest is unable to mock the module. Its name contains some hash of a chunk. The solution is pretty simple. Instead of using jest.mock we import the external dependency in our test, then we use jest.spyOn . This way we don’t rely on the import’s path.

import Location from '@angular/core';

jest.spyOn(Location, 'joinWithSlash').mockReturnValue(...);

For cases when you need to mock an exported function/class it doesn’t work now due to the way ES Build produces modules (non-configurable JS objects). You can track the issue and draw more attention here.

Mocking file imports

This one was interesting because instead of being an import ESBuild included the whole module into the test suite and assigned it to a variable.

import { environment } from 'environment.ts'; // service.ts

// becomes

const environment = {}; // service.mjs

Here, we don’t need jest.spyOn because with the file replacements (usually it’s environment.ts files) it’s static objects. So we can simply import the object in the test suite and re-assign the value with some simple generic value so that we don’t depend on the external dependency.

import { environment } from 'environment.ts'

environment.url = 'testUrl'

Mocking dependencies

Since we start with Angular v16, we also use inject function instead of the constructor. Also, we don’t use TestBed to speedup the tests and test only the classes without building components and their templates etc.

To mock an injected class we have to create an injector, provide our mocks and get the component/service’s instance inside the injection context.

describe('AppComponent', () => {
let component: AppComponent;

let serviceA: any;
let serviceB: any;

beforeEach(() => {
serviceA = {} // mock serviceA implementation
serviceB = {} // mock serviceB implementation

const injector = Injector.create({
providers: [
{ provide: ServiceA, useValue: serviceA },
{ provide: ServiceB, useValue: serviceB },
]
}); // We create an injector and feed it with our providers' stubs

// this handy function allows `inject` functions
// to run and grab the values from the injection context
runInInjectionContext(injector, () => {
component = new AppComponent();
});
})

it('should create', () => {
expect(component).toBeTruthy()
})
})

Excluding files from tests

We have Playwright for E2E tests in the project. Tests are located outside the src folder, however they have the same naming convention .spec.ts . Somehow Angular ends up trying to run those tests as well, totally ignoring the tsconfig.spec.json which points only to the src folder.

To fix it we can use an undocumented configuration option in angular.json . We do this by specifying the include option for Jest builder.

"test": {
"builder": "@angular-devkit/build-angular:jest",
"options": {
"tsConfig": "tsconfig.spec.json",
"polyfills": ["zone.js", "zone.js/testing"],
"include": ["src/**/*.spec.ts"]
}
}

Now Jest only runs the tests inside the src folder and Playwright tests are untouched!

Fixing failed tests in files that never had them

One interesting bug (or feature) that I have observed, is that if a test imports something from MSAL library (for Azure SSO) during ng test we get a StandardController.mjs in the output in dist folder (that’s where the tests run now). When the tests are ran, they literally execute every single .mjs file in the output and hence tests fail. StandardController is a compiled library source code. It doesn’t contain any tests.

For now I found a workaround — manually adjust the script that executes Jest. Instead of using <rootDir>**/*.mjs glob use <rootDir>**/*.spec.mjs . The change should happen in node_modules/@angular_devkit/build_angular/src/builders/jest/index.js.

You can track the issue on GitHub.

This is no longer the case! Now, only files that have actual tests are ran!

--

--

Sergey Gultyayev

A front-end developer who uses Angular as a main framework and loves it