
Integration tests in Angular using PollyJS
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:
- 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
andreplay
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:
- Copied
JSDOMEnvironment
from jest (make sure to copy the right version) into the app. - Modified the
JSDOM
construction in the class constructor to specifystrictSSL:false
when creating aResourceLoader.
- Changed the
jest-integration.environment.js
to use this customJSDOMEnvironment.
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 snapshot
and 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",
},