Angular logo

Integration tests in Angular using PollyJS

Jonathan Harrison
ITNEXT

--

TLDR: The code is available at https://github.com/JonJam/angular_pollyjs_playground

Backstory

During 2022, my squad has been trying to improve developer productivity by speeding up the build times (average 30 minutes) of a monolith we work in. We have had some great success by splitting out the frontend, which is a large Angular app into its own git repository.

The Angular app build takes around 25 minutes and the main culprit is the end to end testing setup; this takes on average 12 minutes to run, 47% of the total build time.

Other squads who use React have an integration test framework that utilizes PollyJS that was introduced to:

“help convert a lot of E2E tests to speedier and deterministic integration tests”.

For those reasons, I set out to do the same for Angular but when looking online I didn’t come across much guidance. That’s what motivated me to write this post to share how I achieved this, with the hope that it will help others.

Aim

As mentioned in the Backstory section, the aim was to build an integration test framework in Angular that would:

  1. Use the same packages as for unit tests: jest with ngneat/spectator.

2. Automatically record any network requests/responses and be able to replay them for CI/CD.

3. Allow tests to be written using a black-box style in the same fashion as end to end tests.

Packages

Angular Builders — Jest Builder

Angular Builders — Jest Builder allows running ng test (i.e unit tests) with Jest instead of Karma & Jasmine (which is the default in Angular).

Related to Aim 1, this was re-used to create a new build target for integration tests.

ngneat/spectator

ngneat/spectator is used to make unit tests simpler to write and allow querying the DOM akin to Testing library.

ngneat/spectator has a special factory, createRoutingFactory, that can be used to test routing (and even mentions integration tests).

Related to Aim 1 and 3, this is used to create and query/interact with the Angular app under test.

PollyJS

PollyJS is JavaScript library created by Netflix and as stated on their documentation:

Polly.JS is a standalone, framework-agnostic JavaScript library that enables recording, replaying, and stubbing of HTTP interactions.

This package allows us to achieve Aim 2.

Integration test framework

Utilizing the Angular Tutorial — Tour of Heroes as an app to test and combining the packages mentioned in the previous section, resulted in this integration test framework that we will walkthrough.

The complete example can be found on GitHub: JonJam/angular_pollyjs_playground.

Integration tests directory

A new directory integration-tests exists outside of the src folder which contains the tests, as well as much of the configuration below needed to support them.

Typescript configuration for Integration tests

tsconfig.integration.json extends the unit test configuration to include the code within the integration-tests directory.

{
"extends": "./tsconfig.spec.json",
"include": [
"integration-tests/**/*.ts",
],
"compilerOptions": {
"types": [
"integration-tests/index.d.ts"
]
}
}

Angular integration tests build target

A new build target test-integration has been added to angular.json that uses Jest Builder with integration test specific config files.

"test-integration": {
"builder": "@angular-builders/jest:run",
"options": {
"configPath": "integration-tests/jest-integration.config.js",
"tsConfig": "tsconfig.integration.json"
}
}

Integration tests npm scripts

There are two new npm scripts in package.json which run the integration tests.

"test:integration": "ng run angular.io-example:test-integration",
"test:integration:record": "POLLY_MODE=record ng run angular.io-example:test-integration"

test:integration:record will run the integration tests in record mode meaning the tests will make actual network requests and then upon completion, saves the recordings as .har files.

test:integration will run the integration tests in replay mode meaning the recordings will be used instead of making actual requests. This is what you would use in a CI/CD pipeline.

Jest custom environment

To setup PollyJS with jest we need to use a custom environment jest-integration.environment.js, per this guidance.

// custom-environment
const SetupPollyJestEnvironment = require('setup-polly-jest/jest-environment-jsdom');

class JestIntegrationEnvironment extends SetupPollyJestEnvironment {
constructor(config, context) {
super(config, context);

// Setting up testPath global for pollyContext.ts
// Followed https://stackoverflow.com/questions/62995762/how-do-you-find-the-filename-and-path-of-a-running-test-in-jest
this.global.testPath = context.testPath;
}
}

module.exports = JestIntegrationEnvironment;

This extends that provided by gribnoysup/setup-polly-jest to setup a testPath global that is used in the PollyJS configuration.

PollyJS setup

PollyJS is configured within pollyContext.ts which is referenced in the jest configuration.

import { setupPolly } from 'setup-polly-jest';
import path from 'path';
import {PollyConfig} from '@pollyjs/core';
import {MODES} from '@pollyjs/utils';

function getPollyMode() : "record" | "replay" {
let mode: PollyConfig['mode'] = MODES.REPLAY;

switch (process.env['POLLY_MODE']) {
case 'record':
mode = MODES.RECORD;
break;
case 'replay':
mode = MODES.REPLAY;
break;
case 'offline':
mode = MODES.REPLAY;
break;
}

return mode;
}

function getDefaultRecordingDir() {
// This is setup using a custom jest environment
const testPath: string = (global as any).testPath;

return path.relative(
process.cwd(),
`${path.dirname(testPath)}/__recordings__`,
);
}

const pollyContext = setupPolly({
recordIfMissing: false,
mode: getPollyMode(),
// Having to use require imports due to https://github.com/gribnoysup/setup-polly-jest/issues/23
adapters: [require('@pollyjs/adapter-xhr')],
persister: require('@pollyjs/persister-fs'),
persisterOptions: {
fs: {
recordingsDir: getDefaultRecordingDir(),
},
// Improve diff readability for git. See https://netflix.github.io/pollyjs/#/configuration?id=disablesortingharentries
disableSortingHarEntries: true,
},
flushRequestsOnStop: true,
recordFailedRequests: true,
// Default level is warn. Useful to see PollyJs Recorded or Replayed logs
logLevel: 'info',
expiryStrategy: 'error',
expiresIn: '14d',
// Insulate the tests from differences in session data.
//
// See https://netflix.github.io/pollyjs/#/configuration?id=matchrequestsby for options.
matchRequestsBy: {
headers: false,
body: false,
},
});

beforeEach(() => {
// Grouping common requests so that they share a single recording.
//
// See https://netflix.github.io/pollyjs/#/server/route-handler?id=recordingname
//
// TODO Add common requests.
// pollyContext.polly.server.any('/api/example').recordingName('Example');
});

global.pollyContext = pollyContext;

This was put together using the Typescript example in PollyJS and spotify/polly-jest-preset.

I encourage you to read the PollyJS configuration documentation to understand all the options, but to summarise what this is doing:

  • It allows switching between record and replay modes based on an environment variable.
  • It persists recordings of network requests and responses to a __recordings__folder with the __tests__ directory (akin to jest snapshots).
  • It expires recordings every 14 days and causes the build to error when they do so.
  • It defines the request matching criteria to ignore headers and body contents.
  • It groups specified common requests to share a single recording.
  • It creates a global pollyContext that can be referenced in tests.

Jest configuration

jest-integration.config.js configures the jest environment for the integration tests, referencing some of the previously mentioned files.

// This is merged with default config as per https://github.com/just-jeb/angular-builders/tree/master/packages/jest#builder-options

// Test environment copied from: https://netflix.github.io/pollyjs/#/test-frameworks/jest-jasmine?id=supported-test-runners
module.exports = {
testEnvironment: "<rootDir>/integration-tests/jest-integration.environment.js",
testMatch: ["<rootDir>/integration-tests/__tests__/**/*.spec.ts"],
setupFilesAfterEnv: [
"<rootDir>/integration-tests/jest/setupFilesAfterEnv/pollyContext.ts",
]
};

utils/testUtils.ts

testUtil.ts contains helper functions such as starting the app and navigating to the initial page that are reused across integration tests.

import {createRoutingFactory, SpectatorRouting} from '@ngneat/spectator/jest';
import {TestBed} from '@angular/core/testing';
import {NgZone} from '@angular/core';

import {AppComponent} from '../../src/app/app.component';
import {AppModule} from '../../src/app/app.module';

const createComponent = createRoutingFactory({
component: AppComponent,
imports: [AppModule],
// Setting to false as per https://ngneat.github.io/spectator/docs/testing-with-routing#integration-testing-with-routertestingmodule
// to perform an integration test.
stubsEnabled: false,
detectChanges: false
});

function startApp() : SpectatorRouting<AppComponent> {
// Create app
return createComponent();
}

async function navigateToInitialPage(spectator: SpectatorRouting<AppComponent>, url: string) : Promise<void> {
// Navigate to page under test
const ngZone = TestBed.inject(NgZone);
await ngZone.run(async () => {
await spectator.router.navigateByUrl(url);
});

// Wait for any HTTP and UI updates
await global.pollyContext.polly.flush();
spectator.detectChanges();
}

export { startApp, navigateToInitialPage };

Putting it all together

Let’s put this into practice with an example test: heroes.spec.ts.

import {byRole} from '@ngneat/spectator/jest';

import {navigateToInitialPage, startApp} from '../utils/testUtils';

describe('Heroes', () => {
it('renders a list of heroes', async () => {
// Start app and navigate to heroes page
const spectator = startApp();
const url = `heroes`;
await navigateToInitialPage(spectator, url);

// Update view with heroes data
await global.pollyContext.polly.flush();
spectator.detectChanges();

// ASSERT
const heroes : HTMLLIElement[] = spectator.queryAll(byRole('listitem'));

const linkContents = heroes.map(li => {
const link = li.querySelector('a');

if (link === null || link.textContent === null) {
return "";
}

return link.textContent.trim();

});

const expected = [
'1 Leanne Graham',
'2 Ervin Howell',
'3 Clementine Bauch',
'4 Patricia Lebsack',
'5 Chelsey Dietrich',
'6 Mrs. Dennis Schulist',
'7 Kurtis Weissnat',
'8 Nicholas Runolfsdottir V',
'9 Glenna Reichert',
'10 Clementina DuBuque'
];

expect(linkContents).toEqual(expect.arrayContaining(expected));
});
});

This test:

  • Starts the app.
  • Navigates to /heroes .
  • Awaits for networks requests and the UI to update.
  • Asserts that the expected list is shown.

First, we run this test using test:integration:record to create recordings.

➜  angular_pollyjs_playground git:(main) yarn test:integration:record
yarn run v1.22.15
$ POLLY_MODE=record ng run angular.io-example:test-integration
Warning: 'no-cache' option has been declared with a 'no' prefix in the schema.Please file an issue with the author of this package.
Warning: 'noStackTrace' option has been declared with a 'no' prefix in the schema.Please file an issue with the author of this package.
Determining test suites to run...
ngcc-jest-processor: running ngcc
Request: GET https://jsonplaceholder.typicode.com/users
Response: Recorded ➞ GET https://jsonplaceholder.typicode.com/users 200 • 447ms
PASS integration-tests/__tests__/heroes.spec.ts (8.964 s)
Heroes
✓ renders a list of heroes (502 ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 15.446 s
Ran all test suites.
✨ Done in 32.81s.

The PollyJS logs helpfully show what requests and responses are being recorded. Now that we have some recordings, we can run test:integration to run in replay mode:

➜  angular_pollyjs_playground git:(main) ✗ yarn test:integration       
yarn run v1.22.15
$ ng run angular.io-example:test-integration
Warning: 'no-cache' option has been declared with a 'no' prefix in the schema.Please file an issue with the author of this package.
Warning: 'noStackTrace' option has been declared with a 'no' prefix in the schema.Please file an issue with the author of this package.
Determining test suites to run...
ngcc-jest-processor: running ngcc
Request: GET https://jsonplaceholder.typicode.com/users
Response: Replayed ➞ GET https://jsonplaceholder.typicode.com/users 200 • 2ms
PASS integration-tests/__tests__/heroes.spec.ts
Heroes
✓ renders a list of heroes (53 ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 2.087 s, estimated 9 s
Ran all test suites.
✨ Done in 4.55s.

That’s all folks :)

Gotchas

When doing this for real, there was a few things I came across that might be useful to know.

Servers with self signed certificates

If your tests run against a backend that uses self signed certificates, you may gets tests failing with errors like Error: self signed certificate .

To resolve this, I discovered this jest issue and did the following:

  • CopiedJSDOMEnvironment from jest (make sure to copy the right version) into the app.
  • Modified the JSDOM construction in the class constructor to specify strictSSL:false when creating a ResourceLoader.
  • Changed the jest-integration.environment.js to use this custom JSDOMEnvironment.

ngneat/spectator and ActivatedRoute

Normally to get the active route in Angular, we use ActivatedRoute; but when using ngneat/spectator’s createRoutingFactory (even with stubsEnabled: false) the value is a stub ActivatedRouteStub; you can see the code here.

This caused issues for the app under test as certain functionality isn’t implemented (accessing snapshotand firstchild properties).

A workaround is to use router.routerstate.root instead of ActivatedRoute , since this is what Angular uses internally (see router_module and provide_router).

Base URL for relative requests

The app under test used relative requests i.e. /api/example as it expects to run on the same domain as the API.

When running as an integration test, I found that requests were being made tohttps://github.com/just-jeb/angular-builders/api/example.

To resolve this, I had to set the base url in jest-integration.config.js by adding:

testEnvironmentOptions: {
url: "https://localhost",
},

--

--

Writer for

Principal Software Engineer @ Tripadvisor. Formerly at Spotify, Altitude Angel and Microsoft. All opinions are my own.