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. It contains a few companion services that wrap Angular's built-in navigation services, but adjusted to the Angular feature under test.
The feature test harness is used to test standalone feature routes, 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 or standalone routes. They 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 in 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, the crisisCenterRoutes
:
import { 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';
export const crisisCenterRoutes: Routes = [
{
path: '',
component: CrisisCenterComponent,
children: [
{
path: '',
component: CrisisListComponent,
children: [
{
path: ':id',
component: CrisisDetailComponent,
canDeactivate: [CanDeactivateGuard],
resolve: {
crisis: CrisisDetailResolverService,
},
},
{
path: '',
component: CrisisCenterHomeComponent,
},
],
},
],
},
];
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 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 under test as well as the route path used to load it in our application:
import {
createFeatureHarness,
SpectacularFeatureHarness,
} from '@ngworker/spectacular';
import {
crisisCenterPath,
crisisCenterRoutes,
CrisisService,
} from '@tour-of-heroes/crisis-center';
describe('Tour of Heroes: Crisis center integration tests', () => {
beforeEach(() => {
harness = createFeatureHarness({
featurePath: crisisCenterPath,
routes: [
{ path: crisisCenterPath, loadChildren: () => crisisCenterRoutes },
],
});
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 Spectacular
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.
While Spectacular does not offer APIs for interacting with or inspecting the DOM, it does, however, takes care of certain boilerplate code that shows up in common scenarios.
When comparing a feature test using Spectacular to a feature test using Angular testbed, 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 Testing Library or Angular component test harnesses.
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 using Spectacular or Angular testbed only, for comparison.
- Spectacular + Angular testbed
- Angular testbed
import { By } from '@angular/platform-browser';
import {
createFeatureHarness,
SpectacularFeatureHarness,
} from '@ngworker/spectacular';
import {
crisisCenterPath,
crisisCenterRoutes,
CrisisService,
} from '@tour-of-heroes/crisis-center';
describe('Tour of Heroes: Crisis center integration tests', () => {
beforeEach(() => {
harness = createFeatureHarness({
featurePath: crisisCenterPath,
routes: [
{ path: crisisCenterPath, loadChildren: () => crisisCenterRoutes },
],
});
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 { provideLocationMocks } from '@angular/common/testing';
import { Component } from '@angular/core';
import {
ComponentFixture,
ComponentFixtureAutoDetect,
TestBed,
} from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { provideRouter, Router } from '@angular/router';
import {
crisisCenterPath,
crisisCenterRoutes,
CrisisService,
} from '@tour-of-heroes/crisis-center';
@Component({
standalone: true,
selector: 'test-app',
imports: [],
template: '<router-outlet><router-outlet>',
})
class TestAppComponent {}
describe('Tour of Heroes: Crisis center integration tests', () => {
beforeEach(async () => {
TestBed.configureTestingModule({
providers: [
{ provide: ComponentFixtureAutoDetect, useValue: true },
provideRouter([
{ path: crisisCenterPath, loadChildren: () => crisisCenterRoutes },
]),
provideLocationMocks(),
],
});
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 only using the Angular testbed are highlighted.