Feature testing using Angular testbed
This guide covers how to create a feature test without Spectacular to compare the Angular testbed feature testing story to the Spectacular Feature testing API experience.
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 the Angular testbed
Let's first explore how we can set up a feature test by using the Angular testbed.
Creating a test root component
First, we're going to create 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';
import { RouterOutlet } from '@angular/router';
@Component({
standalone: true,
selector: 'test-app',
imports: [RouterOutlet],
template: '<router-outlet><router-outlet>',
})
class TestAppComponent {}
describe('Tour of Heroes: Crisis center integration tests', () => {
beforeEach(() => {
TestBed.configureTestingModule({});
});
});
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({});
rootFixture = TestBed.createComponent(TestAppComponent);
});
let rootFixture: ComponentFixture<TestAppComponent>;
});
Registering a routed feature with the Angular testing module
Next, we'll add the routed feature to our test setup:
import { provideLocationMocks } from '@angular/common/testing';
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import {
crisisCenterPath,
crisisCenterRoutes,
} from '@tour-of-heroes/crisis-center';
describe('Tour of Heroes: Crisis center integration tests', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideRouter([
{ path: crisisCenterPath, loadChildren: () => crisisCenterRoutes },
]),
provideLocationMocks(),
],
});
// (...)
});
});
provideLocationMocks
isolates Angular's routing APIs from browser APIs by
providing SpyLocation
for Angular's Location
service and
MockLocationStrategy
for Angular's LocationStrategy
service.
provideRouter
accepts routes which are registered with the Angular testing
module in the same way that we would pass top-level routes to provideRouter
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: () => crisisCenterRoutes },
Notice that we are registering the same route path for the feature 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:
export const appConfig: ApplicationConfig = {
providers: [
provideRouter([
{
path: crisisCenterPath,
loadChildren: () =>
import('@tour-of-heroes/crisis-center').then(
m => m.crisisCenterRoutes
),
},
// (...)
]),
],
}:
There is no practical difference between lazy-loaded and eagerly loaded features
in unit and integration tests. When we return a feature route array--in this
case crisisCenterRoutes
--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 configured in crisisCenterRoutes
.
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 { provideLocationMocks } from '@angular/common/testing';
import { Component } from '@angular/core';
import {
ComponentFixture,
ComponentFixtureAutoDetect,
TestBed,
} from '@angular/core/testing';
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 {} // [1]
describe('Tour of Heroes: Crisis center integration tests', () => {
beforeEach(async () => {
TestBed.configureTestingModule({
providers: [
{ provide: ComponentFixtureAutoDetect, useValue: true }, // [4]
provideRouter([
{ path: crisisCenterPath, loadChildren: () => crisisCenterRoutes }, // [3]
]),
provideLocationMocks(), // [2]
],
});
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:
- Create a test root component
- Isolate Angular's routing services from the browser APIs
- Register the routed feature
- 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, we could compare that to setting up a test for the same feature using Spectacular.
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
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 as demonstrated in Testing a complete user flow with Spectacular.
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.