Skip to main content
Version: 14.1

Feature testing using Spectacular and Angular Testing Library

Spectacular doesn't contain an API for interacting with or inspecting our application's DOM and its URL path. Instead, we rely on Angular's verbose testing APIs. Alternatively, we can use the expressive and convenient APIs of Angular Testing Library.

On this page, we learn how to integrate Spectacular with Angular Testing Library by configuring render and using Spectacular's feature-aware services for an excellent testing experience.

Registering a routed feature module

The following snippet shows the common configuration needed for integrating Spectacular with Angular Testing library:

import {
SpectacularAppComponent,
SpectacularFeatureTestingModule,
} from '@ngworker/spectacular';
import { render } from '@testing-library/angular';
import {
CrisisCenterModule,
crisisCenterPath,
} from '@tour-of-heroes/crisis-center';

describe('Tour of Heroes: Crisis center', () => {
beforeEach(async () => {
await render(SpectacularAppComponent, {
excludeComponentDeclaration: true,
imports: [
SpectacularFeatureTestingModule.withFeature({
featurePath: crisisCenterPath,
routes: [
{ path: crisisCenterPath, loadChildren: () => CrisisCenterModule },
],
}),
],
});
});
});

Let's discuss each part of the configuration in detail.

Firstly, we use SpectacularAppComponent as our root component by passing it to Angular Testing Library's render function:

import {
SpectacularAppComponent,
SpectacularFeatureTestingModule,
} from '@ngworker/spectacular';
import { render } from '@testing-library/angular';
import {
CrisisCenterModule,
crisisCenterPath,
} from '@tour-of-heroes/crisis-center';

describe('Tour of Heroes: Crisis center', () => {
beforeEach(async () => {
await render(SpectacularAppComponent, {
excludeComponentDeclaration: true,
imports: [
SpectacularFeatureTestingModule.withFeature({
featurePath: crisisCenterPath,
routes: [
{ path: crisisCenterPath, loadChildren: () => CrisisCenterModule },
],
}),
],
});
});
});

SpectacularAppComponent is a simple component with a router outlet that will host all our feature's routes.

Next, we register our feature by calling SpectacularFeatureTestingModule.withFeature and adding the result to the imports option:

import {
SpectacularAppComponent,
SpectacularFeatureTestingModule,
} from '@ngworker/spectacular';
import { render } from '@testing-library/angular';
import {
CrisisCenterModule,
crisisCenterPath,
} from '@tour-of-heroes/crisis-center';

describe('Tour of Heroes: Crisis center', () => {
beforeEach(async () => {
await render(SpectacularAppComponent, {
excludeComponentDeclaration: true,
imports: [
SpectacularFeatureTestingModule.withFeature({
featurePath: crisisCenterPath,
routes: [
{ path: crisisCenterPath, loadChildren: () => CrisisCenterModule },
],
}),
],
});
});
});

We pass our routed feature module to the routes option, in our case CrisisCenterModule, as part of a route that wraps it in a loadChildren callback.

💡 Tip Lazy loading a feature module isn't a requirement in an integration test.

Additionally, we pass the route path that our feature module uses in our application to the featurePath option, in our case the value of the crisisCenterPath variable which is used between our application and our feature test suite.

The SpectacularAppComponent is declared by SpectacularFeatureTestingModule.withFeature so we set the excludeComponentDeclaration option to true to prevent it from being declared twice in our test suite:

import {
SpectacularAppComponent,
SpectacularFeatureTestingModule,
} from '@ngworker/spectacular';
import { render } from '@testing-library/angular';
import {
CrisisCenterModule,
crisisCenterPath,
} from '@tour-of-heroes/crisis-center';

describe('Tour of Heroes: Crisis center', () => {
beforeEach(async () => {
await render(SpectacularAppComponent, {
excludeComponentDeclaration: true,
imports: [
SpectacularFeatureTestingModule.withFeature({
featurePath: crisisCenterPath,
routes: [
{ path: crisisCenterPath, loadChildren: () => CrisisCenterModule },
],
}),
],
});
});
});

With our routed feature module registered, our feature tests have access to all routes configured by that Angular module.

Resolving feature-aware services

When we're using Angular Testing Library, we use render and SpectacularFeatureTestingModule.withFeature instead of createFeatureHarness.

Because of this, we don't get a reference to a SpectacularFeatureHarness.

To access Spectacular's feature-aware services, we resolve them using the injector of RenderResult#fixture.debugElement:

import {
SpectacularAppComponent,
SpectacularFeatureRouter,
SpectacularFeatureTestingModule,
} from '@ngworker/spectacular';
import { render, RenderResult } from '@testing-library/angular';
import {
CrisisCenterModule,
crisisCenterPath,
} from '@tour-of-heroes/crisis-center';

describe('Tour of Heroes: Crisis center', () => {
beforeEach(async () => {
const {
fixture: {
debugElement: { injector },
},
} = await render(SpectacularAppComponent, {
excludeComponentDeclaration: true,
imports: [
SpectacularFeatureTestingModule.withFeature({
featurePath: crisisCenterPath,
routes: [
{ path: crisisCenterPath, loadChildren: () => CrisisCenterModule },
],
}),
],
});
featureRouter = injector.get(SpectacularFeatureRouter);
});

let featureRouter: SpectacularFeatureRouter;
});

Alternatively, we use TestBed.inject to resolve Spectacular's feature-aware services:

import { TestBed } from '@angular/core/testing';
import {
SpectacularAppComponent,
SpectacularFeatureRouter,
SpectacularFeatureTestingModule,
} from '@ngworker/spectacular';
import { render, RenderResult } from '@testing-library/angular';
import {
CrisisCenterModule,
crisisCenterPath,
} from '@tour-of-heroes/crisis-center';

describe('Tour of Heroes: Crisis center', () => {
beforeEach(async () => {
await render(SpectacularAppComponent, {
excludeComponentDeclaration: true,
imports: [
SpectacularFeatureTestingModule.withFeature({
featurePath: crisisCenterPath,
routes: [
{ path: crisisCenterPath, loadChildren: () => CrisisCenterModule },
],
}),
],
});
featureRouter = TestBed.inject(SpectacularFeatureRouter);
});

let featureRouter: SpectacularFeatureRouter;
});

Either of these techniques can be used to access SpectacularFeatureLocation and SpectacularFeatureRouter.

When using Angular Testing Library, you can keep using RenderResult#navigate and your tests will behave as you're used to:

import {
SpectacularAppComponent,
SpectacularFeatureTestingModule,
} from '@ngworker/spectacular';
import { render, RenderResult, screen } from '@testing-library/angular';
import {
Crisis,
CrisisCenterModule,
crisisCenterPath,
} from '@tour-of-heroes/crisis-center';

describe('Tour of Heroes: Crisis center', () => {
beforeEach(async () => {
const { navigate:_ navigate } = await render(SpectacularAppComponent, {
excludeComponentDeclaration: true,
imports: [
SpectacularFeatureTestingModule.withFeature({
featurePath: crisisCenterPath,
routes: [
{ path: crisisCenterPath, loadChildren: () => CrisisCenterModule },
],
}),
],
});
navigate = _navigate;
});

let navigate: RenderResult<
SpectacularAppComponent,
SpectacularAppComponent
>['navigate'];

it('shows crisis detail when a valid ID is in the URL', async () => {
const crisis: Crisis = {
id: 1,
name: 'Dragon Burning Cities',
};

await navigate(crisisCenterPath + '/' + crisis.id);

expect(
await screen.findByRole('heading', { name: new RegExp(crisis.name) })
).not.toBeNull();
});
});

Alternatively, you can use Spectacular's feature-aware router, SpectacularFeatureRouter:

import {
SpectacularAppComponent,
SpectacularFeatureRouter,
SpectacularFeatureTestingModule,
} from '@ngworker/spectacular';
import { render, RenderResult, screen } from '@testing-library/angular';
import {
Crisis,
CrisisCenterModule,
crisisCenterPath,
} from '@tour-of-heroes/crisis-center';

describe('Tour of Heroes: Crisis center', () => {
beforeEach(async () => {
const {
fixture: {
debugElement: { injector },
},
} = await render(SpectacularAppComponent, {
excludeComponentDeclaration: true,
imports: [
SpectacularFeatureTestingModule.withFeature({
featurePath: crisisCenterPath,
routes: [
{ path: crisisCenterPath, loadChildren: () => CrisisCenterModule },
],
}),
],
});
featureRouter = injector.get(SpectacularFeatureRouter);
});

let featureRouter: SpectacularFeatureRouter;

it('shows crisis detail when a valid ID is in the URL', async () => {
const crisis: Crisis = {
id: 1,
name: 'Dragon Burning Cities',
};

await featureRouter.navigate(['~', crisis.id]);

expect(
await screen.findByRole('heading', { name: new RegExp(crisis.name) })
).not.toBeNull();
});
});

screen is the recommended starting point for content queries when using Angular Testing Library.

Asserting feature paths

When using Angular Testing Library, you can use Angular's Location#path service method to assert navigation to feature routes:

import { Location } from '@angular/common';
import {
SpectacularAppComponent,
SpectacularFeatureTestingModule,
} from '@ngworker/spectacular';
import { render, RenderResult, screen } from '@testing-library/angular';
import userEvent from '@testing-library/user-event';
import type { UserEvent } from '@testing-library/user-event/dist/types/setup';
import {
Crisis,
CrisisCenterModule,
crisisCenterPath,
} from '@tour-of-heroes/crisis-center';

describe('Tour of Heroes: Crisis center', () => {
beforeEach(async () => {
user = userEvent.setup();
const {
fixture: {
debugElement: { injector },
},
navigate: _navigate,
} = await render(SpectacularAppComponent, {
excludeComponentDeclaration: true,
imports: [
SpectacularFeatureTestingModule.withFeature({
featurePath: crisisCenterPath,
routes: [
{ path: crisisCenterPath, loadChildren: () => CrisisCenterModule },
],
}),
],
});
navigate = _navigate;
appLocation = injector.get(Location);
});

let appLocation: Location;
let navigate: RenderResult<
SpectacularAppComponent,
SpectacularAppComponent
>['navigate'];
let user: UserEvent;

it('shows crisis detail when a valid ID is in the URL', async () => {
const crisis: Crisis = {
id: 1,
name: 'Dragon Burning Cities',
};
await navigate(crisisCenterPath);

await user.click(await screen.findByText(crisis.name));

expect(appLocation.path()).toBe(crisisCenterPath + '/' + crisis.id);
});
});

Alternatively, you can use Spectacular's feature-aware location service, SpectacularFeatureLocation:

import {
SpectacularAppComponent,
SpectacularFeatureLocation,
SpectacularFeatureTestingModule,
} from '@ngworker/spectacular';
import { render, RenderResult, screen } from '@testing-library/angular';
import userEvent from '@testing-library/user-event';
import type { UserEvent } from '@testing-library/user-event/dist/types/setup';
import {
Crisis,
CrisisCenterModule,
crisisCenterPath,
} from '@tour-of-heroes/crisis-center';

describe('Tour of Heroes: Crisis center', () => {
beforeEach(async () => {
user = userEvent.setup();
const {
fixture: {
debugElement: { injector },
},
navigate: _navigate,
} = await render(SpectacularAppComponent, {
excludeComponentDeclaration: true,
imports: [
SpectacularFeatureTestingModule.withFeature({
featurePath: crisisCenterPath,
routes: [
{ path: crisisCenterPath, loadChildren: () => CrisisCenterModule },
],
}),
],
});
navigate = _navigate;
featureLocation = injector.get(SpectacularFeatureLocation);
});

let featureLocation: SpectacularFeatureLocation;
let navigate: RenderResult<
SpectacularAppComponent,
SpectacularAppComponent
>['navigate'];
let user: UserEvent;

it('shows crisis detail when a valid ID is in the URL', async () => {
const crisis: Crisis = {
id: 1,
name: 'Dragon Burning Cities',
};
await navigate(crisisCenterPath);

await user.click(await screen.findByText(crisis.name));

expect(featureLocation.path()).toBe('~/' + crisis.id);
});
});

userEvent is the recommended way to interact with the DOM when using Angular Testing Library.

Testing a complete user flow with Spectacular and Angular Testing Library

The following is an example of testing a complete user flow with Spectacular and Angular Testing Library:

import {
SpectacularAppComponent,
SpectacularFeatureLocation,
SpectacularFeatureRouter,
SpectacularFeatureTestingModule,
} from '@ngworker/spectacular';
import { render, screen } from '@testing-library/angular';
import userEvent from '@testing-library/user-event';
import type { UserEvent } from '@testing-library/user-event/dist/types/setup';
import {
CrisisCenterModule,
crisisCenterPath,
CrisisService,
} from '@tour-of-heroes/crisis-center';

describe('Tour of Heroes: Crisis center', () => {
beforeEach(async () => {
user = userEvent.setup();
const {
fixture: {
debugElement: { injector },
},
} = await render(SpectacularAppComponent, {
excludeComponentDeclaration: true,
imports: [
SpectacularFeatureTestingModule.withFeature({
featurePath: crisisCenterPath,
routes: [
{ path: crisisCenterPath, loadChildren: () => CrisisCenterModule },
],
}),
],
});
crisisService = injector.get(CrisisService);
featureLocation = injector.get(SpectacularFeatureLocation);
featureRouter = injector.get(SpectacularFeatureRouter);
});

let crisisService: CrisisService;
let featureLocation: SpectacularFeatureLocation;
let featureRouter: SpectacularFeatureRouter;
let user: UserEvent;

describe('Editing a crisis', () => {
it('navigates to the crisis center home with the crisis selected when the change is saved', async () => {
const [aCrisis] = crisisService.getCrises().value;
await featureRouter.navigate(['~', aCrisis.id]);
const newCrisisName = 'Global climate crisis';

await user.type(await screen.findByRole('textbox'), newCrisisName);
await user.click(await screen.findByRole('button', { name: 'Save' }));

expect(
await screen.findByText('Welcome to the Crisis Center')
).not.toBeNull();
expect(
await screen.findByText(new RegExp(aCrisis.name), {
selector: '.selected a',
})
).not.toBeNull();
expect(featureLocation.path()).toBe(`~/;id=${aCrisis.id};foo=foo`);
});
});
});

Compare this to a feature test suite using Spectacular with the Angular testbed and we see that the preceding test case contains about half the lines of code as the one using Spectacular without Angular Testing Library.

Compare this to a feature test suite using only the Angular testbed and we come to appreciate how much less noise is in our test setup and our code which interacts and inspects the DOM as well as the URL path.

Sharing an Angular Testing Library configuration

Consider sharing common Angular Testing Library configuration by using the configure function. Angular Testing Library's configure options support the following Angular-specific options:

  • defaultImports, an array of shared Angular module imports
  • excludeComponentDeclaration, a Boolean value that enables an imported module to declare the testing root component when set to true

As an example, we could add the following shared configuration to our main test file:

import { ComponentFixtureAutoDetect } from '@angular/core/testing';
import { configure } from '@testing-library/angular';

@NgModule({
providers: [{ provide: ComponentFixtureAutoDetect, useValue: true }],
})
class AutoDetectChangesModule {}

configure({
defaultImports: [AutoDetectChangesModule],
excludeComponentDeclaration: true,
});

In this case, we can leave out the excludeComponentDeclaration setting from Spectacular test suite setups in the relevant project. Additionally, we don't have to call RenderResult#fixture#autoDetectChanges after calling render.

Keep in mind that this affects all test suites using Angular Testing Library in the project using this main test file.