Feature testing using Spectacular
Spectacular's feature testing API configures the Angular testing module and sets up a test harness for a routed Angular feature module. It contains a few companion services that wrap Angular's built-in navigation services, but adjusted to the Angular feature module under test.
The feature test harness is used to test routed feature modules and shell modules.
Feature tests
The purpose of feature tests is to test a part of our application as a user, that is by interacting with the DOM both to trigger side effects and assert their observable outcome from a user perspective.
Spectacular does not provide an API for interacting with the DOM. We can use Angular's testing APIs such as debug elements for this. Alternatively, we combine Spectacular with Angular Testing Library or Angular component harnesses.
The scope of a feature test is a routed Angular module. It can contain multiple routed components, multiple routing components, route guards, route resolvers, and multiple services in addition to the components and the directives, components, and pipes used by their component template.
A feature test uses real routing services and data structures such as:
- The
Router
service - The
Location
service ActivatedRoute
servicesActivatedRouteSnapshot
Route
RouterStateSnapshot
UrlTree
In a single test case, it's possible to navigate around the entire Angular feature. We can perform full user flows if it makes sense or we can assert one step at a time without having to worry about setting up complex test doubles for routing and navigation.
The feature testing harness provides convenience wrappers for the Location
and
Router
services, namely the
SpectacularFeatureLocation
and
SpectacularFeatureRouter
services,
respectively. They allow to navigate relatively to the root feature route and
query for the activated route path relative to the root feature route. The tilde
(~
) character denotes a feature-relative route path.
Example Angular feature
As an example, we're going to create integration tests for the crisis center feature from the Tour of Heroes routing tutorial.
The following code snippet shows its routed feature module, the
CrisisCenterModule
:
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { CommonModule } from '@angular/common';
import { CrisisCenterHomeComponent } from './crisis-center-home/crisis-center-home.component';
import { CrisisListComponent } from './crisis-list/crisis-list.component';
import { CrisisCenterComponent } from './crisis-center/crisis-center.component';
import { CrisisDetailComponent } from './crisis-detail/crisis-detail.component';
import { CrisisCenterRoutingModule } from './crisis-center-routing.module';
@NgModule({
imports: [CommonModule, FormsModule, CrisisCenterRoutingModule],
declarations: [
CrisisCenterComponent,
CrisisListComponent,
CrisisCenterHomeComponent,
CrisisDetailComponent,
],
})
export class CrisisCenterModule {}
Notice the routing module in the highlighted line which is listed in the following code block:
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { CanDeactivateGuard } from './can-deactivate.guard';
import { CrisisCenterHomeComponent } from './crisis-center-home/crisis-center-home.component';
import { CrisisCenterComponent } from './crisis-center/crisis-center.component';
import { CrisisDetailResolverService } from './crisis-detail-resolver.service';
import { CrisisDetailComponent } from './crisis-detail/crisis-detail.component';
import { CrisisListComponent } from './crisis-list/crisis-list.component';
const crisisCenterRoutes: Routes = [
{
path: '',
component: CrisisCenterComponent,
children: [
{
path: '',
component: CrisisListComponent,
children: [
{
path: ':id',
component: CrisisDetailComponent,
canDeactivate: [CanDeactivateGuard],
resolve: {
crisis: CrisisDetailResolverService,
},
},
{
path: '',
component: CrisisCenterHomeComponent,
},
],
},
],
},
];
@NgModule({
imports: [RouterModule.forChild(crisisCenterRoutes)],
exports: [RouterModule],
})
export class CrisisCenterRoutingModule {}
Our feature tests are going to focus on the crisis detail form. Notice the route guard and route resolver added to the crisis detail route in the highlighted lines. These are going to be activated as part of our test cases.
Setting up an Angular feature integration test with the Angular testbed
Let's first explore how we can set up a feature test by using the Angular testbed.
Declaring a test root component
First, we're going to declare a test root component with a router outlet as seen in the following snippet:
import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
@Component({
selector: 'test-app',
template: '<router-outlet><router-outlet>',
})
class TestAppComponent {}
describe('Tour of Heroes: Crisis center integration tests', () => {
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [TestAppComponent],
});
});
});
Bootstrapping a test root component
Now, we're going to bootstrap our test root component:
import { ComponentFixture, TestBed } from '@angular/core/testing';
// (...)
describe('Tour of Heroes: Crisis center integration tests', () => {
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [TestAppComponent],
});
rootFixture = TestBed.createComponent(TestAppComponent);
});
let rootFixture: ComponentFixture<TestAppComponent>;
});
Registering a routed feature module with the Angular testing module
Next, we'll add the routed feature module to our test setup:
import { TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import {
CrisisCenterModule,
crisisCenterPath,
} from '@tour-of-heroes/crisis-center';
describe('Tour of Heroes: Crisis center integration tests', () => {
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [TestAppComponent],
imports: [
RouterTestingModule.withRoutes([
{ path: crisisCenterPath, loadChildren: () => CrisisCenterModule },
]),
],
});
// (...)
});
});
RouterTestingModule
isolates Angular's routing APIs from browser APIs by
providing SpyLocation
for Angular's Location
service and
MockLocationStrategy
for Angular's LocationStrategy
service.
RouterTestingModule.withRoutes
accepts routes which are registered with the
Angular testing module in the same way that we would pass top-level routes to
RouterModule.forRoot
in an Angular application. The routes are attached to the
router outlet in our test root component.
We register the following route configuration:
{ path: crisisCenterPath, loadChildren: () => CrisisCenterModule },
Notice that we are registering the same route path for the feature module as we
are registering in our Angular application. crisisCenterPath
is a variable
which has been assigned to the value 'crisis-center'
.
In our Angular application, the crisis center feature is lazy-loaded which means that the route configuration would look something like the following snippet:
@NgModule({
imports: [
RouterModule.forRoot([
{
path: crisisCenterPath,
loadChildren: () =>
import('@tour-of-heroes/crisis-center').then(
m => m.CrisisCenterModule
),
},
// (...)
]),
],
})
export class AppRoutingModule {}
There is no practical difference between lazy-loaded and eagerly loaded feature
modules in unit and integration tests. When we return a feature module class--in
this case CrisisCenterModule
--from a loadChildren
callback, everything works
as intended when running our tests.
Resolving navigation and data services
Many feature tests make use of Angular's Location
and Router
services. In
the following code snippet, we resolve them and relevant data services using
TestBed.inject
:
import { Location } from '@angular/common';
import { TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { CrisisService } from '@tour-of-heroes/crisis-center';
// (...)
describe('Tour of Heroes: Crisis center integration tests', () => {
beforeEach(() => {
// (...)
location = TestBed.inject(Location);
router = TestBed.inject(Router);
crisisService = TestBed.inject(CrisisService);
});
// (...)
let crisisService: CrisisService;
let location: Location;
let router: Router;
});
Initializing feature navigation
By default, we will start our feature test cases by navigating to the default feature route. This is shown in the following code snippet which also illustrates how we enable automatic change detection:
import { ComponentFixtureAutoDetect, TestBed } from '@angular/core/testing';
import { crisisCenterPath } from '@tour-of-heroes/crisis-center';
// (...)
describe('Tour of Heroes: Crisis center integration tests', () => {
beforeEach(async () => {
TestBed.configureTestingModule({
// (...)
providers: [{ provide: ComponentFixtureAutoDetect, useValue: true }],
});
// (...)
await rootFixture.ngZone?.run(() => router.navigate([crisisCenterPath]));
});
// (...)
});
As seen in the last highlighted line, we navigate in the context of the NgZone
because otherwise warnings are emitted when running our tests. Once again, we
reuse the crisisCenterPath
variable only this time to navigate to the default
feature route which happens to activate the following components:
CrisisCenterComponent
CrisisListComponent
CrisisCenterHomeComponent
as seen in the routes registered by CrisisCenterRoutingModule
.
Reviewing a complete feature test setup using the Angular testbed
With all of the above, we now have the following test setup for our feature tests:
import { Location } from '@angular/common';
import { Component } from '@angular/core';
import {
ComponentFixture,
ComponentFixtureAutoDetect,
TestBed,
} from '@angular/core/testing';
import { Router } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import {
CrisisCenterModule,
crisisCenterPath,
CrisisService,
} from '@tour-of-heroes/crisis-center';
@Component({
selector: 'test-app',
template: '<router-outlet><router-outlet>',
})
class TestAppComponent {}
describe('Tour of Heroes: Crisis center integration tests', () => {
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [TestAppComponent], // [1]
imports: [
RouterTestingModule /* [2] */.withRoutes([
{ path: crisisCenterPath, loadChildren: () => CrisisCenterModule }, // [3]
]),
],
providers: [{ provide: ComponentFixtureAutoDetect, useValue: true }], // [4]
});
rootFixture = TestBed.createComponent(TestAppComponent); // [5]
location = TestBed.inject(Location); // [6]
router = TestBed.inject(Router); // [6]
crisisService = TestBed.inject(CrisisService); // [6]
await rootFixture.ngZone?.run(() => router.navigate([crisisCenterPath])); // [7]
});
let crisisService: CrisisService;
let location: Location;
let rootFixture: ComponentFixture<TestAppComponent>;
let router: Router;
});
We have managed to:
- Declare a test root component
- Isolate Angular's routing services from the browser APIs
- Register the routed feature module
- Enable automatic change detection
- Bootstrap the test root component
- Resolve navigation and data services
- Initialize feature navigation
That's quite an impressive setup. However, we need all of these setup steps for every Angular feature test, not only when testing the crisis center feature.
Now that we have a working setup, let's compare that to setting up a test for the same feature using Spectacular.
Setting up an Angular feature integration test with Spectacular
Spectacular's feature testing API takes care of tedious and error-prone test setup. Notice in the following code snippet how we pass the routed feature module under test as well as the route path used to load it in our application:
import {
createFeatureHarness,
SpectacularFeatureHarness,
} from '@ngworker/spectacular';
import {
CrisisCenterModule,
crisisCenterPath,
CrisisService,
} from '@tour-of-heroes/crisis-center';
describe('Tour of Heroes: Crisis center integration tests', () => {
beforeEach(() => {
harness = createFeatureHarness({
featurePath: crisisCenterPath,
routes: [
{ path: crisisCenterPath, loadChildren: () => CrisisCenterModule },
],
});
crisisService = harness.inject(CrisisService);
});
let crisisService: CrisisService;
let harness: SpectacularFeatureHarness;
});
The createFeatureHarness
function takes care of all the test setup steps we
reviewed in the previous section to the point that there's no need to import
from any Angular package.
Spectacular is straight to the point: We tell it the scope of what we're testing, in this case the crisis center feature. Spectacular takes care of the test setup behind the scenes so that we can focus on writing tests.
Testing a complete user flow with the Angular testbed
Our test is going to cover editing a crisis. Once the changed crisis name is saved, our application navigates to the crisis center home with the crisis selected.
The first thing we need to do is to navigate to the crisis edit form for the crisis we want to edit:
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 rootFixture.ngZone?.run(() =>
router.navigate([crisisCenterPath, aCrisis.id])
);
});
});
We get a crisis ID from the crisis service which we use to navigate to the crisis edit form.
Next, we're going to enter a new crisis name into the crisis name text box:
import { By } from '@angular/platform-browser';
// (...)
describe('Editing a crisis', () => {
it('navigates to the crisis center home with the crisis selected when the change is saved', async () => {
// (...)
const inputElement: HTMLInputElement = rootFixture.debugElement.query(
By.css('input')
).nativeElement;
const newCrisisName = 'Global climate crisis';
inputElement.value = newCrisisName;
inputElement.dispatchEvent(new Event('input'));
});
});
Now, let's save the change:
describe('Editing a crisis', () => {
it('navigates to the crisis center home with the crisis selected when the change is saved', async () => {
// (...)
const saveButtonElement: HTMLButtonElement = rootFixture.debugElement
.queryAll(By.css('button'))
.map(button => button.nativeElement)
.find(
(buttonElement: HTMLButtonElement) =>
buttonElement.textContent?.trim() === 'Save'
);
const newCrisisName = 'Global climate crisis';
inputElement.value = newCrisisName;
inputElement.dispatchEvent(new Event('input'));
saveButtonElement.click();
await rootFixture.whenStable();
});
});
We wait for the component fixture to stabilize because of multiple side effects being triggered, including navigation.
As our next step, we verify that we are at the crisis center home route:
describe('Editing a crisis', () => {
it('navigates to the crisis center home with the crisis selected when the change is saved', async () => {
// (...)
saveButtonElement.click();
await rootFixture.whenStable();
const welcomeText: HTMLElement = rootFixture.debugElement.query(
By.css('p')
).nativeElement;
expect(welcomeText.textContent).toBe('Welcome to the Crisis Center');
});
});
Our second assertion verified that the crisis we just updated is selected in the crisis list:
describe('Editing a crisis', () => {
it('navigates to the crisis center home with the crisis selected when the change is saved', async () => {
// (...)
const selectedCrisis: HTMLElement = rootFixture.debugElement.query(
By.css('.selected')
).nativeElement;
expect(welcomeText.textContent).toBe('Welcome to the Crisis Center');
expect(selectedCrisis.textContent?.trim()).toBe(
`${aCrisis.id}${newCrisisName}`
);
});
});
Finally, we verify that we are on the crisis center home with some route matrix parameters added to the URL:
describe('Editing a crisis', () => {
it('navigates to the crisis center home with the crisis selected when the change is saved', async () => {
// (...)
expect(welcomeText.textContent).toBe('Welcome to the Crisis Center');
expect(selectedCrisis.textContent?.trim()).toBe(
`${aCrisis.id}${newCrisisName}`
);
expect(location.path()).toBe(
`/${crisisCenterPath};id=${aCrisis.id};foo=foo`
);
});
});
That completes our multi-step feature test which exercises and verifies an entire user flow.
Next, let's review the test and focus in on a few details.
Reviewing a feature test using the Angular testbed
Spectacular doesn't offer an API for interacting with or inspecting the DOM. To make this easier, combine Spectacular with Angular Testing Library or Angular component harnesses.
Reviewing the full feature test, there are a few points worth noticing:
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 rootFixture.ngZone?./* [1] */ run(
() => router.navigate([crisisCenterPath, aCrisis.id]) // [2]
);
const inputElement: HTMLInputElement = rootFixture.debugElement.query(
By.css('input')
).nativeElement;
const saveButtonElement: HTMLButtonElement = rootFixture.debugElement
.queryAll(By.css('button'))
.map(button => button.nativeElement)
.find(
(buttonElement: HTMLButtonElement) =>
buttonElement.textContent?.trim() === 'Save'
);
const newCrisisName = 'Global climate crisis';
inputElement.value = newCrisisName;
inputElement.dispatchEvent(new Event('input'));
saveButtonElement.click();
await rootFixture.whenStable();
const welcomeText: HTMLElement = rootFixture.debugElement.query(
By.css('p')
).nativeElement;
const selectedCrisis: HTMLElement = rootFixture.debugElement.query(
By.css('.selected')
).nativeElement;
expect(welcomeText.textContent).toBe('Welcome to the Crisis Center');
expect(selectedCrisis.textContent?.trim()).toBe(
`${aCrisis.id}${newCrisisName}`
);
expect(location.path()).toBe(
`/${crisisCenterPath};id=${aCrisis.id};foo=foo` // [3]
);
});
});
- We have to wrap navigation in the Angular testing module's NgZone instance.
- We repeat the feature path when navigating to routes registered by the feature.
- We repeat the feature path when inspecting the application path.
These are examples of boilerplate that Spectacular takes care of. Let's see how in the next section.
Testing a complete user flow with Spectacular
As mentioned in the previous section, Spectacular does not offer APIs for interacting with or inspecting the DOM. However, it takes care of certain boilerplate code that shows up in common scenarios.
When reviewing the feature test converted to using Spectacular, we notice a few convenience APIs in use:
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 harness.router.navigate(['~', aCrisis.id]); // [1] [2]
const inputElement: HTMLInputElement =
harness.rootFixture.debugElement.query(By.css('input')).nativeElement;
const saveButtonElement: HTMLButtonElement =
harness.rootFixture.debugElement
.queryAll(By.css('button'))
.map(button => button.nativeElement)
.find(
(buttonElement: HTMLButtonElement) =>
buttonElement.textContent?.trim() === 'Save'
);
const newCrisisName = 'Global climate crisis';
inputElement.value = newCrisisName;
inputElement.dispatchEvent(new Event('input'));
saveButtonElement.click();
await harness.rootFixture.whenStable();
const welcomeText: HTMLElement = harness.rootFixture.debugElement.query(
By.css('p')
).nativeElement;
const selectedCrisis: HTMLElement = harness.rootFixture.debugElement.query(
By.css('.selected')
).nativeElement;
expect(welcomeText.textContent).toBe('Welcome to the Crisis Center');
expect(selectedCrisis.textContent?.trim()).toBe(
`${aCrisis.id}${newCrisisName}`
);
expect(harness.location.path()).toBe(`~/;id=${aCrisis.id};foo=foo`); // [3]
});
});
- We don't have to navigate in the context of an NgZone.
- When navigating, we use the feature-relative route symbol (
~
) instead of repeating the feature route path. - When inspecting the application path, we use the feature-relative route
symbol (
~
) instead of repeating the feature route path.
A feature harness offers feature-aware router and location services in its
router
and location
properties. They are synchronized with Angular's
corresponding services which they wrap.
Spectacular benefits
In this page, we saw how to set up a testing root component, register a routed feature module with the Angular testing module, initialize feature navigation, navigate to a feature route, interact with the DOM, and inspect the application path.
We first explored the intricate setup needed for a feature test when using the Angular testbed. Afterwards, we compared that to the lightweight setup offered by Spectacular's feature testing API.
Finally, we saw how Spectacular adds convenience APIs to remove common boilerplate from feature tests.
Spectacular offers:
- Lightweight, reusable test setup for tests scoped to a routed feature module
- Performing multi-step feature tests involving multiple routed components
- Feature-aware location and router services reducing feature test boilerplate
- Tests exercising an entire Angular feature without needing a host application
- Feature tests that are faster than end-to-end tests
- The flexibility to access or replace Angular services as needed
You might have noticed that Spectacular doesn't have APIs for interacting with or inspecting our application's DOM. However, Spectacular's feature testing API was built with compatibility in mind. Spectacular can be integrated with Angular component test harnesses or Angular Testing Library.
Visit the other pages in this section to learn about other use cases supported by Spectacular's feature testing API.
Appendix A: Complete feature test suites
The following shows complete feature test suites:
- Spectacular + Angular testbed
- Angular testbed
import { By } from '@angular/platform-browser';
import {
createFeatureHarness,
SpectacularFeatureHarness,
} from '@ngworker/spectacular';
import {
CrisisCenterModule,
crisisCenterPath,
CrisisService,
} from '@tour-of-heroes/crisis-center';
describe('Tour of Heroes: Crisis center integration tests', () => {
beforeEach(() => {
harness = createFeatureHarness({
featurePath: crisisCenterPath,
routes: [
{ path: crisisCenterPath, loadChildren: () => CrisisCenterModule },
],
});
crisisService = harness.inject(CrisisService);
});
let crisisService: CrisisService;
let harness: SpectacularFeatureHarness;
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 harness.router.navigate(['~', aCrisis.id]);
const inputElement: HTMLInputElement =
harness.rootFixture.debugElement.query(By.css('input')).nativeElement;
const saveButtonElement: HTMLButtonElement =
harness.rootFixture.debugElement
.queryAll(By.css('button'))
.map(button => button.nativeElement)
.find(
(buttonElement: HTMLButtonElement) =>
buttonElement.textContent?.trim() === 'Save'
);
const newCrisisName = 'Global climate crisis';
inputElement.value = newCrisisName;
inputElement.dispatchEvent(new Event('input'));
saveButtonElement.click();
await harness.rootFixture.whenStable();
const welcomeText: HTMLElement = harness.rootFixture.debugElement.query(
By.css('p')
).nativeElement;
const selectedCrisis: HTMLElement =
harness.rootFixture.debugElement.query(
By.css('.selected')
).nativeElement;
expect(welcomeText.textContent).toBe('Welcome to the Crisis Center');
expect(selectedCrisis.textContent?.trim()).toBe(
`${aCrisis.id}${newCrisisName}`
);
expect(harness.location.path()).toBe(`~/;id=${aCrisis.id};foo=foo`);
});
});
});
import { Location } from '@angular/common';
import { Component } from '@angular/core';
import {
ComponentFixture,
ComponentFixtureAutoDetect,
TestBed,
} from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { Router } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import {
CrisisCenterModule,
crisisCenterPath,
CrisisService,
} from '@tour-of-heroes/crisis-center';
@Component({
selector: 'test-app',
template: '<router-outlet><router-outlet>',
})
class TestAppComponent {}
describe('Tour of Heroes: Crisis center integration tests', () => {
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [TestAppComponent],
imports: [
RouterTestingModule.withRoutes([
{ path: crisisCenterPath, loadChildren: () => CrisisCenterModule },
]),
],
providers: [{ provide: ComponentFixtureAutoDetect, useValue: true }],
});
rootFixture = TestBed.createComponent(TestAppComponent);
location = TestBed.inject(Location);
router = TestBed.inject(Router);
crisisService = TestBed.inject(CrisisService);
await rootFixture.ngZone?.run(() => router.navigate([crisisCenterPath]));
});
let crisisService: CrisisService;
let location: Location;
let rootFixture: ComponentFixture<TestAppComponent>;
let router: Router;
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 rootFixture.ngZone?.run(() =>
router.navigate([crisisCenterPath, aCrisis.id])
);
const inputElement: HTMLInputElement = rootFixture.debugElement.query(
By.css('input')
).nativeElement;
const saveButtonElement: HTMLButtonElement = rootFixture.debugElement
.queryAll(By.css('button'))
.map(button => button.nativeElement)
.find(
(buttonElement: HTMLButtonElement) =>
buttonElement.textContent?.trim() === 'Save'
);
const newCrisisName = 'Global climate crisis';
inputElement.value = newCrisisName;
inputElement.dispatchEvent(new Event('input'));
saveButtonElement.click();
await rootFixture.whenStable();
const welcomeText: HTMLElement = rootFixture.debugElement.query(
By.css('p')
).nativeElement;
const selectedCrisis: HTMLElement = rootFixture.debugElement.query(
By.css('.selected')
).nativeElement;
expect(welcomeText.textContent).toBe('Welcome to the Crisis Center');
expect(selectedCrisis.textContent?.trim()).toBe(
`${aCrisis.id}${newCrisisName}`
);
expect(location.path()).toBe(
`/${crisisCenterPath};id=${aCrisis.id};foo=foo`
);
});
});
});
Significant differences between using Spectacular with the Angular testbed compared to just using the Angular testbed are highlighted.