In-depth Guides
Testing

Component testing scenarios

This guide explores common component testing use cases.

Component binding

In the example application, the BannerComponent presents static title text in the HTML template.

After a few changes, the BannerComponent presents a dynamic title by binding to the component's title property like this.

app/banner/banner.component.ts

      
import {Component, signal} from '@angular/core';
@Component({
standalone: true,
selector: 'app-banner',
template: '<h1>{{title()}}</h1>',
styles: ['h1 { color: green; font-size: 350%}'],
})
export class BannerComponent {
title = signal('Test Tour of Heroes');
}

As minimal as this is, you decide to add a test to confirm that component actually displays the right content where you think it should.

Query for the <h1>

You'll write a sequence of tests that inspect the value of the <h1> element that wraps the title property interpolation binding.

You update the beforeEach to find that element with a standard HTML querySelector and assign it to the h1 variable.

app/banner/banner.component.spec.ts (setup)

      
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {BannerComponent} from './banner.component';
describe('BannerComponent (inline template)', () => {
let component: BannerComponent;
let fixture: ComponentFixture<BannerComponent>;
let h1: HTMLElement;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [BannerComponent],
});
fixture = TestBed.createComponent(BannerComponent);
component = fixture.componentInstance; // BannerComponent test instance
h1 = fixture.nativeElement.querySelector('h1');
});
it('no title in the DOM after createComponent()', () => {
expect(h1.textContent).toEqual('');
});
it('should display original title', () => {
fixture.detectChanges();
expect(h1.textContent).toContain(component.title);
});
it('should display original title after detectChanges()', () => {
fixture.detectChanges();
expect(h1.textContent).toContain(component.title);
});
it('should display a different test title', () => {
component.title = 'Test Title';
fixture.detectChanges();
expect(h1.textContent).toContain('Test Title');
});
});

createComponent() does not bind data

For your first test you'd like to see that the screen displays the default title. Your instinct is to write a test that immediately inspects the <h1> like this:

      
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {BannerComponent} from './banner.component';
describe('BannerComponent (inline template)', () => {
let component: BannerComponent;
let fixture: ComponentFixture<BannerComponent>;
let h1: HTMLElement;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [BannerComponent],
});
fixture = TestBed.createComponent(BannerComponent);
component = fixture.componentInstance; // BannerComponent test instance
h1 = fixture.nativeElement.querySelector('h1');
});
it('no title in the DOM after createComponent()', () => {
expect(h1.textContent).toEqual('');
});
it('should display original title', () => {
fixture.detectChanges();
expect(h1.textContent).toContain(component.title);
});
it('should display original title after detectChanges()', () => {
fixture.detectChanges();
expect(h1.textContent).toContain(component.title);
});
it('should display a different test title', () => {
component.title = 'Test Title';
fixture.detectChanges();
expect(h1.textContent).toContain('Test Title');
});
});

That test fails with the message:

      
expected '' to contain 'Test Tour of Heroes'.

Binding happens when Angular performs change detection.

In production, change detection kicks in automatically when Angular creates a component or the user enters a keystroke, for example.

The TestBed.createComponent does not trigger change detection by default; a fact confirmed in the revised test:

      
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {BannerComponent} from './banner.component';
describe('BannerComponent (inline template)', () => {
let component: BannerComponent;
let fixture: ComponentFixture<BannerComponent>;
let h1: HTMLElement;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [BannerComponent],
});
fixture = TestBed.createComponent(BannerComponent);
component = fixture.componentInstance; // BannerComponent test instance
h1 = fixture.nativeElement.querySelector('h1');
});
it('no title in the DOM after createComponent()', () => {
expect(h1.textContent).toEqual('');
});
it('should display original title', () => {
fixture.detectChanges();
expect(h1.textContent).toContain(component.title);
});
it('should display original title after detectChanges()', () => {
fixture.detectChanges();
expect(h1.textContent).toContain(component.title);
});
it('should display a different test title', () => {
component.title = 'Test Title';
fixture.detectChanges();
expect(h1.textContent).toContain('Test Title');
});
});

detectChanges()

You can tell the TestBed to perform data binding by calling fixture.detectChanges(). Only then does the <h1> have the expected title.

      
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {BannerComponent} from './banner.component';
describe('BannerComponent (inline template)', () => {
let component: BannerComponent;
let fixture: ComponentFixture<BannerComponent>;
let h1: HTMLElement;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [BannerComponent],
});
fixture = TestBed.createComponent(BannerComponent);
component = fixture.componentInstance; // BannerComponent test instance
h1 = fixture.nativeElement.querySelector('h1');
});
it('no title in the DOM after createComponent()', () => {
expect(h1.textContent).toEqual('');
});
it('should display original title', () => {
fixture.detectChanges();
expect(h1.textContent).toContain(component.title);
});
it('should display original title after detectChanges()', () => {
fixture.detectChanges();
expect(h1.textContent).toContain(component.title);
});
it('should display a different test title', () => {
component.title = 'Test Title';
fixture.detectChanges();
expect(h1.textContent).toContain('Test Title');
});
});

Delayed change detection is intentional and useful. It gives the tester an opportunity to inspect and change the state of the component before Angular initiates data binding and calls lifecycle hooks.

Here's another test that changes the component's title property before calling fixture.detectChanges().

      
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {BannerComponent} from './banner.component';
describe('BannerComponent (inline template)', () => {
let component: BannerComponent;
let fixture: ComponentFixture<BannerComponent>;
let h1: HTMLElement;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [BannerComponent],
});
fixture = TestBed.createComponent(BannerComponent);
component = fixture.componentInstance; // BannerComponent test instance
h1 = fixture.nativeElement.querySelector('h1');
});
it('no title in the DOM after createComponent()', () => {
expect(h1.textContent).toEqual('');
});
it('should display original title', () => {
fixture.detectChanges();
expect(h1.textContent).toContain(component.title);
});
it('should display original title after detectChanges()', () => {
fixture.detectChanges();
expect(h1.textContent).toContain(component.title);
});
it('should display a different test title', () => {
component.title = 'Test Title';
fixture.detectChanges();
expect(h1.textContent).toContain('Test Title');
});
});

Automatic change detection

The BannerComponent tests frequently call detectChanges. Many testers prefer that the Angular test environment run change detection automatically like it does in production.

That's possible by configuring the TestBed with the ComponentFixtureAutoDetect provider. First import it from the testing utility library:

app/banner/banner.component.detect-changes.spec.ts (import)

      
import {ComponentFixtureAutoDetect} from '@angular/core/testing';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {BannerComponent} from './banner.component';
describe('BannerComponent (AutoChangeDetect)', () => {
let comp: BannerComponent;
let fixture: ComponentFixture<BannerComponent>;
let h1: HTMLElement;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [{provide: ComponentFixtureAutoDetect, useValue: true}],
});
fixture = TestBed.createComponent(BannerComponent);
comp = fixture.componentInstance;
h1 = fixture.nativeElement.querySelector('h1');
});
it('should display original title', () => {
// Hooray! No `fixture.detectChanges()` needed
expect(h1.textContent).toContain(comp.title);
});
it('should still see original title after comp.title change', async () => {
const oldTitle = comp.title;
const newTitle = 'Test Title';
comp.title.set(newTitle);
// Displayed title is old because Angular didn't yet run change detection
expect(h1.textContent).toContain(oldTitle);
await fixture.whenStable();
expect(h1.textContent).toContain(newTitle);
});
it('should display updated title after detectChanges', () => {
comp.title.set('Test Title');
fixture.detectChanges(); // detect changes explicitly
expect(h1.textContent).toContain(comp.title);
});
});

Then add it to the providers array of the testing module configuration:

app/banner/banner.component.detect-changes.spec.ts (AutoDetect)

      
import {ComponentFixtureAutoDetect} from '@angular/core/testing';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {BannerComponent} from './banner.component';
describe('BannerComponent (AutoChangeDetect)', () => {
let comp: BannerComponent;
let fixture: ComponentFixture<BannerComponent>;
let h1: HTMLElement;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [{provide: ComponentFixtureAutoDetect, useValue: true}],
});
fixture = TestBed.createComponent(BannerComponent);
comp = fixture.componentInstance;
h1 = fixture.nativeElement.querySelector('h1');
});
it('should display original title', () => {
// Hooray! No `fixture.detectChanges()` needed
expect(h1.textContent).toContain(comp.title);
});
it('should still see original title after comp.title change', async () => {
const oldTitle = comp.title;
const newTitle = 'Test Title';
comp.title.set(newTitle);
// Displayed title is old because Angular didn't yet run change detection
expect(h1.textContent).toContain(oldTitle);
await fixture.whenStable();
expect(h1.textContent).toContain(newTitle);
});
it('should display updated title after detectChanges', () => {
comp.title.set('Test Title');
fixture.detectChanges(); // detect changes explicitly
expect(h1.textContent).toContain(comp.title);
});
});

HELPFUL: You can also use the fixture.autoDetectChanges() function instead if you only want to enable automatic change detection after making updates to the state of the fixture's component. In addition, automatic change detection is on by default when using provideExperimentalZonelessChangeDetection and turning it off is not recommended.

Here are three tests that illustrate how automatic change detection works.

app/banner/banner.component.detect-changes.spec.ts (AutoDetect Tests)

      
import {ComponentFixtureAutoDetect} from '@angular/core/testing';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {BannerComponent} from './banner.component';
describe('BannerComponent (AutoChangeDetect)', () => {
let comp: BannerComponent;
let fixture: ComponentFixture<BannerComponent>;
let h1: HTMLElement;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [{provide: ComponentFixtureAutoDetect, useValue: true}],
});
fixture = TestBed.createComponent(BannerComponent);
comp = fixture.componentInstance;
h1 = fixture.nativeElement.querySelector('h1');
});
it('should display original title', () => {
// Hooray! No `fixture.detectChanges()` needed
expect(h1.textContent).toContain(comp.title);
});
it('should still see original title after comp.title change', async () => {
const oldTitle = comp.title;
const newTitle = 'Test Title';
comp.title.set(newTitle);
// Displayed title is old because Angular didn't yet run change detection
expect(h1.textContent).toContain(oldTitle);
await fixture.whenStable();
expect(h1.textContent).toContain(newTitle);
});
it('should display updated title after detectChanges', () => {
comp.title.set('Test Title');
fixture.detectChanges(); // detect changes explicitly
expect(h1.textContent).toContain(comp.title);
});
});

The first test shows the benefit of automatic change detection.

The second and third test reveal an important limitation. The Angular testing environment does not run change detection synchronously when updates happen inside the test case that changed the component's title. The test must call await fixture.whenStable to wait for another of change detection.

HELPFUL: Angular does not know about direct updates to values that are not signals. The easiest way to ensure that change detection will be scheduled is to use signals for values read in the template.

Change an input value with dispatchEvent()

To simulate user input, find the input element and set its value property.

But there is an essential, intermediate step.

Angular doesn't know that you set the input element's value property. It won't read that property until you raise the element's input event by calling dispatchEvent().

The following example demonstrates the proper sequence.

app/hero/hero-detail.component.spec.ts (pipe test)

      
import {HttpClient, HttpHandler, provideHttpClient} from '@angular/common/http';
import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing';
import {fakeAsync, TestBed, tick} from '@angular/core/testing';
import {provideRouter, Router} from '@angular/router';
import {RouterTestingHarness} from '@angular/router/testing';
import {asyncData, click} from '../../testing';
import {Hero} from '../model/hero';
import {sharedImports} from '../shared/shared';
import {HeroDetailComponent} from './hero-detail.component';
import {HeroDetailService} from './hero-detail.service';
import {HeroListComponent} from './hero-list.component';
////// Testing Vars //////
let component: HeroDetailComponent;
let harness: RouterTestingHarness;
let page: Page;
////// Tests //////
describe('HeroDetailComponent', () => {
describe('with HeroModule setup', heroModuleSetup);
describe('when override its provided HeroDetailService', overrideSetup);
describe('with FormsModule setup', formsModuleSetup);
describe('with SharedModule setup', sharedModuleSetup);
});
///////////////////
const testHero = getTestHeroes()[0];
function overrideSetup() {
class HeroDetailServiceSpy {
testHero: Hero = {...testHero};
/* emit cloned test hero */
getHero = jasmine
.createSpy('getHero')
.and.callFake(() => asyncData(Object.assign({}, this.testHero)));
/* emit clone of test hero, with changes merged in */
saveHero = jasmine
.createSpy('saveHero')
.and.callFake((hero: Hero) => asyncData(Object.assign(this.testHero, hero)));
}
beforeEach(async () => {
await TestBed.configureTestingModule(
Object.assign({}, appConfig, {
imports: [HeroDetailComponent, HeroListComponent],
providers: [
provideRouter([
{path: 'heroes', component: HeroListComponent},
{path: 'heroes/:id', component: HeroDetailComponent},
]),
HttpClient,
HttpHandler,
// HeroDetailService at this level is IRRELEVANT!
{provide: HeroDetailService, useValue: {}},
],
}),
)
.overrideComponent(HeroDetailComponent, {
set: {providers: [{provide: HeroDetailService, useClass: HeroDetailServiceSpy}]},
})
.compileComponents();
});
let hdsSpy: HeroDetailServiceSpy;
beforeEach(async () => {
harness = await RouterTestingHarness.create();
component = await harness.navigateByUrl(`/heroes/${testHero.id}`, HeroDetailComponent);
page = new Page();
// get the component's injected HeroDetailServiceSpy
hdsSpy = harness.routeDebugElement!.injector.get(HeroDetailService) as any;
harness.detectChanges();
});
it('should have called `getHero`', () => {
expect(hdsSpy.getHero.calls.count())
.withContext('getHero called once')
.toBe(1, 'getHero called once');
});
it("should display stub hero's name", () => {
expect(page.nameDisplay.textContent).toBe(hdsSpy.testHero.name);
});
it('should save stub hero change', fakeAsync(() => {
const origName = hdsSpy.testHero.name;
const newName = 'New Name';
page.nameInput.value = newName;
page.nameInput.dispatchEvent(new Event('input')); // tell Angular
expect(component.hero.name).withContext('component hero has new name').toBe(newName);
expect(hdsSpy.testHero.name).withContext('service hero unchanged before save').toBe(origName);
click(page.saveBtn);
expect(hdsSpy.saveHero.calls.count()).withContext('saveHero called once').toBe(1);
tick(); // wait for async save to complete
expect(hdsSpy.testHero.name).withContext('service hero has new name after save').toBe(newName);
expect(TestBed.inject(Router).url).toEqual('/heroes');
}));
}
////////////////////
import {getTestHeroes} from '../model/testing/test-hero.service';
const firstHero = getTestHeroes()[0];
function heroModuleSetup() {
beforeEach(async () => {
await TestBed.configureTestingModule(
Object.assign({}, appConfig, {
imports: [HeroDetailComponent, HeroListComponent],
providers: [
provideRouter([
{path: 'heroes/:id', component: HeroDetailComponent},
{path: 'heroes', component: HeroListComponent},
]),
provideHttpClient(),
provideHttpClientTesting(),
],
}),
).compileComponents();
});
describe('when navigate to existing hero', () => {
let expectedHero: Hero;
beforeEach(async () => {
expectedHero = firstHero;
await createComponent(expectedHero.id);
});
it("should display that hero's name", () => {
expect(page.nameDisplay.textContent).toBe(expectedHero.name);
});
it('should navigate when click cancel', () => {
click(page.cancelBtn);
expect(TestBed.inject(Router).url).toEqual(`/heroes/${expectedHero.id}`);
});
it('should save when click save but not navigate immediately', () => {
click(page.saveBtn);
expect(TestBed.inject(HttpTestingController).expectOne({method: 'PUT', url: 'api/heroes'}));
expect(TestBed.inject(Router).url).toEqual('/heroes/41');
});
it('should navigate when click save and save resolves', fakeAsync(() => {
click(page.saveBtn);
tick(); // wait for async save to complete
expect(TestBed.inject(Router).url).toEqual('/heroes/41');
}));
it('should convert hero name to Title Case', async () => {
harness.fixture.autoDetectChanges();
// get the name's input and display elements from the DOM
const hostElement: HTMLElement = harness.routeNativeElement!;
const nameInput: HTMLInputElement = hostElement.querySelector('input')!;
const nameDisplay: HTMLElement = hostElement.querySelector('span')!;
// simulate user entering a new name into the input box
nameInput.value = 'quick BROWN fOx';
// Dispatch a DOM event so that Angular learns of input value change.
nameInput.dispatchEvent(new Event('input'));
// Wait for Angular to update the display binding through the title pipe
await harness.fixture.whenStable();
expect(nameDisplay.textContent).toBe('Quick Brown Fox');
});
});
describe('when navigate to non-existent hero id', () => {
beforeEach(async () => {
await createComponent(999);
});
it('should try to navigate back to hero list', () => {
expect(TestBed.inject(Router).url).toEqual('/heroes');
});
});
}
/////////////////////
import {FormsModule} from '@angular/forms';
import {TitleCasePipe} from '../shared/title-case.pipe';
import {appConfig} from '../app.config';
function formsModuleSetup() {
beforeEach(async () => {
await TestBed.configureTestingModule(
Object.assign({}, appConfig, {
imports: [FormsModule, HeroDetailComponent, TitleCasePipe],
providers: [
provideHttpClient(),
provideHttpClientTesting(),
provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]),
],
}),
).compileComponents();
});
it("should display 1st hero's name", async () => {
const expectedHero = firstHero;
await createComponent(expectedHero.id).then(() => {
expect(page.nameDisplay.textContent).toBe(expectedHero.name);
});
});
}
///////////////////////
function sharedModuleSetup() {
beforeEach(async () => {
await TestBed.configureTestingModule(
Object.assign({}, appConfig, {
imports: [HeroDetailComponent, sharedImports],
providers: [
provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]),
provideHttpClient(),
provideHttpClientTesting(),
],
}),
).compileComponents();
});
it("should display 1st hero's name", async () => {
const expectedHero = firstHero;
await createComponent(expectedHero.id).then(() => {
expect(page.nameDisplay.textContent).toBe(expectedHero.name);
});
});
}
/////////// Helpers /////
/** Create the HeroDetailComponent, initialize it, set test variables */
async function createComponent(id: number) {
harness = await RouterTestingHarness.create();
component = await harness.navigateByUrl(`/heroes/${id}`, HeroDetailComponent);
page = new Page();
const request = TestBed.inject(HttpTestingController).expectOne(`api/heroes/?id=${id}`);
const hero = getTestHeroes().find((h) => h.id === Number(id));
request.flush(hero ? [hero] : []);
harness.detectChanges();
}
class Page {
// getter properties wait to query the DOM until called.
get buttons() {
return this.queryAll<HTMLButtonElement>('button');
}
get saveBtn() {
return this.buttons[0];
}
get cancelBtn() {
return this.buttons[1];
}
get nameDisplay() {
return this.query<HTMLElement>('span');
}
get nameInput() {
return this.query<HTMLInputElement>('input');
}
//// query helpers ////
private query<T>(selector: string): T {
return harness.routeNativeElement!.querySelector(selector)! as T;
}
private queryAll<T>(selector: string): T[] {
return harness.routeNativeElement!.querySelectorAll(selector) as any as T[];
}
}

Component with external files

The preceding BannerComponent is defined with an inline template and inline css, specified in the @Component.template and @Component.styles properties respectively.

Many components specify external templates and external css with the @Component.templateUrl and @Component.styleUrls properties respectively, as the following variant of BannerComponent does.

app/banner/banner-external.component.ts (metadata)

      
import {Component} from '@angular/core';
@Component({
standalone: true,
selector: 'app-banner',
templateUrl: './banner-external.component.html',
styleUrls: ['./banner-external.component.css'],
})
export class BannerComponent {
title = 'Test Tour of Heroes';
}

This syntax tells the Angular compiler to read the external files during component compilation.

That's not a problem when you run the CLI ng test command because it compiles the application before running the tests.

However, if you run the tests in a non-CLI environment, tests of this component might fail. For example, if you run the BannerComponent tests in a web coding environment such as plunker, you'll see a message like this one:

      
Error: This test module uses the component BannerComponent
which is using a "templateUrl" or "styleUrls", but they were never compiled.
Please call "TestBed.compileComponents" before your test.

You get this test failure message when the runtime environment compiles the source code during the tests themselves.

To correct the problem, call compileComponents() as explained in the following Calling compileComponents section.

Component with a dependency

Components often have service dependencies.

The WelcomeComponent displays a welcome message to the logged-in user. It knows who the user is based on a property of the injected UserService:

app/welcome/welcome.component.ts

      
import {Component, OnInit, signal} from '@angular/core';
import {UserService} from '../model/user.service';
@Component({
standalone: true,
selector: 'app-welcome',
template: '<h3 class="welcome"><i>{{welcome()}}</i></h3>',
})
export class WelcomeComponent implements OnInit {
welcome = signal('');
constructor(private userService: UserService) {}
ngOnInit(): void {
this.welcome.set(
this.userService.isLoggedIn() ? 'Welcome, ' + this.userService.user().name : 'Please log in.',
);
}
}

The WelcomeComponent has decision logic that interacts with the service, logic that makes this component worth testing.

Provide service test doubles

A component-under-test doesn't have to be provided with real services.

Injecting the real UserService could be difficult. The real service might ask the user for login credentials and attempt to reach an authentication server. These behaviors can be hard to intercept. Be aware that using test doubles makes the test behave differently from production so use them sparingly.

Get injected services

The tests need access to the UserService injected into the WelcomeComponent.

Angular has a hierarchical injection system. There can be injectors at multiple levels, from the root injector created by the TestBed down through the component tree.

The safest way to get the injected service, the way that always works, is to get it from the injector of the component-under-test. The component injector is a property of the fixture's DebugElement.

WelcomeComponent's injector

      
import {ComponentFixture, inject, TestBed} from '@angular/core/testing';
import {UserService} from '../model/user.service';
import {WelcomeComponent} from './welcome.component';
class MockUserService {
isLoggedIn = true;
user = {name: 'Test User'};
}
describe('WelcomeComponent', () => {
let comp: WelcomeComponent;
let fixture: ComponentFixture<WelcomeComponent>;
let componentUserService: UserService; // the actually injected service
let userService: UserService; // the TestBed injected service
let el: HTMLElement; // the DOM element with the welcome message
beforeEach(() => {
fixture = TestBed.createComponent(WelcomeComponent);
fixture.autoDetectChanges();
comp = fixture.componentInstance;
// UserService actually injected into the component
userService = fixture.debugElement.injector.get(UserService);
componentUserService = userService;
// UserService from the root injector
userService = TestBed.inject(UserService);
// get the "welcome" element by CSS selector (e.g., by class name)
el = fixture.nativeElement.querySelector('.welcome');
});
it('should welcome the user', async () => {
await fixture.whenStable();
const content = el.textContent;
expect(content).withContext('"Welcome ..."').toContain('Welcome');
expect(content).withContext('expected name').toContain('Test User');
});
it('should welcome "Bubba"', async () => {
userService.user.set({name: 'Bubba'}); // welcome message hasn't been shown yet
await fixture.whenStable();
expect(el.textContent).toContain('Bubba');
});
it('should request login if not logged in', async () => {
userService.isLoggedIn.set(false); // welcome message hasn't been shown yet
await fixture.whenStable();
const content = el.textContent;
expect(content).withContext('not welcomed').not.toContain('Welcome');
expect(content)
.withContext('"log in"')
.toMatch(/log in/i);
});
it("should inject the component's UserService instance", inject(
[UserService],
(service: UserService) => {
expect(service).toBe(componentUserService);
},
));
it('TestBed and Component UserService should be the same', () => {
expect(userService).toBe(componentUserService);
});
});

HELPFUL: This is usually not necessary. Services are often provided in the root or the TestBed overrides and can be retrieved more easily with TestBed.inject() (see below).

TestBed.inject()

This is easier to remember and less verbose than retrieving a service using the fixture's DebugElement.

In this test suite, the only provider of UserService is the root testing module, so it is safe to call TestBed.inject() as follows:

TestBed injector

      
import {ComponentFixture, inject, TestBed} from '@angular/core/testing';
import {UserService} from '../model/user.service';
import {WelcomeComponent} from './welcome.component';
class MockUserService {
isLoggedIn = true;
user = {name: 'Test User'};
}
describe('WelcomeComponent', () => {
let comp: WelcomeComponent;
let fixture: ComponentFixture<WelcomeComponent>;
let componentUserService: UserService; // the actually injected service
let userService: UserService; // the TestBed injected service
let el: HTMLElement; // the DOM element with the welcome message
beforeEach(() => {
fixture = TestBed.createComponent(WelcomeComponent);
fixture.autoDetectChanges();
comp = fixture.componentInstance;
// UserService actually injected into the component
userService = fixture.debugElement.injector.get(UserService);
componentUserService = userService;
// UserService from the root injector
userService = TestBed.inject(UserService);
// get the "welcome" element by CSS selector (e.g., by class name)
el = fixture.nativeElement.querySelector('.welcome');
});
it('should welcome the user', async () => {
await fixture.whenStable();
const content = el.textContent;
expect(content).withContext('"Welcome ..."').toContain('Welcome');
expect(content).withContext('expected name').toContain('Test User');
});
it('should welcome "Bubba"', async () => {
userService.user.set({name: 'Bubba'}); // welcome message hasn't been shown yet
await fixture.whenStable();
expect(el.textContent).toContain('Bubba');
});
it('should request login if not logged in', async () => {
userService.isLoggedIn.set(false); // welcome message hasn't been shown yet
await fixture.whenStable();
const content = el.textContent;
expect(content).withContext('not welcomed').not.toContain('Welcome');
expect(content)
.withContext('"log in"')
.toMatch(/log in/i);
});
it("should inject the component's UserService instance", inject(
[UserService],
(service: UserService) => {
expect(service).toBe(componentUserService);
},
));
it('TestBed and Component UserService should be the same', () => {
expect(userService).toBe(componentUserService);
});
});

HELPFUL: For a use case in which TestBed.inject() does not work, see the Override component providers section that explains when and why you must get the service from the component's injector instead.

Final setup and tests

Here's the complete beforeEach(), using TestBed.inject():

app/welcome/welcome.component.spec.ts

      
import {ComponentFixture, inject, TestBed} from '@angular/core/testing';
import {UserService} from '../model/user.service';
import {WelcomeComponent} from './welcome.component';
class MockUserService {
isLoggedIn = true;
user = {name: 'Test User'};
}
describe('WelcomeComponent', () => {
let comp: WelcomeComponent;
let fixture: ComponentFixture<WelcomeComponent>;
let componentUserService: UserService; // the actually injected service
let userService: UserService; // the TestBed injected service
let el: HTMLElement; // the DOM element with the welcome message
beforeEach(() => {
fixture = TestBed.createComponent(WelcomeComponent);
fixture.autoDetectChanges();
comp = fixture.componentInstance;
// UserService actually injected into the component
userService = fixture.debugElement.injector.get(UserService);
componentUserService = userService;
// UserService from the root injector
userService = TestBed.inject(UserService);
// get the "welcome" element by CSS selector (e.g., by class name)
el = fixture.nativeElement.querySelector('.welcome');
});
it('should welcome the user', async () => {
await fixture.whenStable();
const content = el.textContent;
expect(content).withContext('"Welcome ..."').toContain('Welcome');
expect(content).withContext('expected name').toContain('Test User');
});
it('should welcome "Bubba"', async () => {
userService.user.set({name: 'Bubba'}); // welcome message hasn't been shown yet
await fixture.whenStable();
expect(el.textContent).toContain('Bubba');
});
it('should request login if not logged in', async () => {
userService.isLoggedIn.set(false); // welcome message hasn't been shown yet
await fixture.whenStable();
const content = el.textContent;
expect(content).withContext('not welcomed').not.toContain('Welcome');
expect(content)
.withContext('"log in"')
.toMatch(/log in/i);
});
it("should inject the component's UserService instance", inject(
[UserService],
(service: UserService) => {
expect(service).toBe(componentUserService);
},
));
it('TestBed and Component UserService should be the same', () => {
expect(userService).toBe(componentUserService);
});
});

And here are some tests:

app/welcome/welcome.component.spec.ts

      
import {ComponentFixture, inject, TestBed} from '@angular/core/testing';
import {UserService} from '../model/user.service';
import {WelcomeComponent} from './welcome.component';
class MockUserService {
isLoggedIn = true;
user = {name: 'Test User'};
}
describe('WelcomeComponent', () => {
let comp: WelcomeComponent;
let fixture: ComponentFixture<WelcomeComponent>;
let componentUserService: UserService; // the actually injected service
let userService: UserService; // the TestBed injected service
let el: HTMLElement; // the DOM element with the welcome message
beforeEach(() => {
fixture = TestBed.createComponent(WelcomeComponent);
fixture.autoDetectChanges();
comp = fixture.componentInstance;
// UserService actually injected into the component
userService = fixture.debugElement.injector.get(UserService);
componentUserService = userService;
// UserService from the root injector
userService = TestBed.inject(UserService);
// get the "welcome" element by CSS selector (e.g., by class name)
el = fixture.nativeElement.querySelector('.welcome');
});
it('should welcome the user', async () => {
await fixture.whenStable();
const content = el.textContent;
expect(content).withContext('"Welcome ..."').toContain('Welcome');
expect(content).withContext('expected name').toContain('Test User');
});
it('should welcome "Bubba"', async () => {
userService.user.set({name: 'Bubba'}); // welcome message hasn't been shown yet
await fixture.whenStable();
expect(el.textContent).toContain('Bubba');
});
it('should request login if not logged in', async () => {
userService.isLoggedIn.set(false); // welcome message hasn't been shown yet
await fixture.whenStable();
const content = el.textContent;
expect(content).withContext('not welcomed').not.toContain('Welcome');
expect(content)
.withContext('"log in"')
.toMatch(/log in/i);
});
it("should inject the component's UserService instance", inject(
[UserService],
(service: UserService) => {
expect(service).toBe(componentUserService);
},
));
it('TestBed and Component UserService should be the same', () => {
expect(userService).toBe(componentUserService);
});
});

The first is a sanity test; it confirms that the UserService is called and working.

HELPFUL: The withContext function (for example, 'expected name') is an optional failure label. If the expectation fails, Jasmine appends this label to the expectation failure message. In a spec with multiple expectations, it can help clarify what went wrong and which expectation failed.

The remaining tests confirm the logic of the component when the service returns different values. The second test validates the effect of changing the user name. The third test checks that the component displays the proper message when there is no logged-in user.

Component with async service

In this sample, the AboutComponent template hosts a TwainComponent. The TwainComponent displays Mark Twain quotes.

app/twain/twain.component.ts (template)

      
import {Component, OnInit, signal} from '@angular/core';
import {AsyncPipe} from '@angular/common';
import {sharedImports} from '../shared/shared';
import {Observable, of} from 'rxjs';
import {catchError, startWith} from 'rxjs/operators';
import {TwainService} from './twain.service';
@Component({
standalone: true,
selector: 'twain-quote',
template: ` <p class="twain">
<i>{{ quote | async }}</i>
</p>
<button type="button" (click)="getQuote()">Next quote</button>
@if (errorMessage()) {
<p class="error">{{ errorMessage() }}</p>
}`,
styles: ['.twain { font-style: italic; } .error { color: red; }'],
imports: [AsyncPipe, sharedImports],
})
export class TwainComponent implements OnInit {
errorMessage = signal('');
quote?: Observable<string>;
constructor(private twainService: TwainService) {}
ngOnInit(): void {
this.getQuote();
}
getQuote() {
this.errorMessage.set('');
this.quote = this.twainService.getQuote().pipe(
startWith('...'),
catchError((err: any) => {
this.errorMessage.set(err.message || err.toString());
return of('...'); // reset message to placeholder
}),
);
}
}

HELPFUL: The value of the component's quote property passes through an AsyncPipe. That means the property returns either a Promise or an Observable.

In this example, the TwainComponent.getQuote() method tells you that the quote property returns an Observable.

app/twain/twain.component.ts (getQuote)

      
import {Component, OnInit, signal} from '@angular/core';
import {AsyncPipe} from '@angular/common';
import {sharedImports} from '../shared/shared';
import {Observable, of} from 'rxjs';
import {catchError, startWith} from 'rxjs/operators';
import {TwainService} from './twain.service';
@Component({
standalone: true,
selector: 'twain-quote',
template: ` <p class="twain">
<i>{{ quote | async }}</i>
</p>
<button type="button" (click)="getQuote()">Next quote</button>
@if (errorMessage()) {
<p class="error">{{ errorMessage() }}</p>
}`,
styles: ['.twain { font-style: italic; } .error { color: red; }'],
imports: [AsyncPipe, sharedImports],
})
export class TwainComponent implements OnInit {
errorMessage = signal('');
quote?: Observable<string>;
constructor(private twainService: TwainService) {}
ngOnInit(): void {
this.getQuote();
}
getQuote() {
this.errorMessage.set('');
this.quote = this.twainService.getQuote().pipe(
startWith('...'),
catchError((err: any) => {
this.errorMessage.set(err.message || err.toString());
return of('...'); // reset message to placeholder
}),
);
}
}

The TwainComponent gets quotes from an injected TwainService. The component starts the returned Observable with a placeholder value ('...'), before the service can return its first quote.

The catchError intercepts service errors, prepares an error message, and returns the placeholder value on the success channel.

These are all features you'll want to test.

Testing with a spy

When testing a component, only the service's public API should matter. In general, tests themselves should not make calls to remote servers. They should emulate such calls. The setup in this app/twain/twain.component.spec.ts shows one way to do that:

app/twain/twain.component.spec.ts (setup)

      
import {fakeAsync, ComponentFixture, TestBed, tick, waitForAsync} from '@angular/core/testing';
import {asyncData, asyncError} from '../../testing';
import {Subject, defer, of, throwError} from 'rxjs';
import {last} from 'rxjs/operators';
import {TwainComponent} from './twain.component';
import {TwainService} from './twain.service';
describe('TwainComponent', () => {
let component: TwainComponent;
let fixture: ComponentFixture<TwainComponent>;
let getQuoteSpy: jasmine.Spy;
let quoteEl: HTMLElement;
let testQuote: string;
// Helper function to get the error message element value
// An *ngIf keeps it out of the DOM until there is an error
const errorMessage = () => {
const el = fixture.nativeElement.querySelector('.error');
return el ? el.textContent : null;
};
beforeEach(() => {
TestBed.configureTestingModule({
imports: [TwainComponent],
providers: [TwainService],
});
testQuote = 'Test Quote';
// Create a fake TwainService object with a `getQuote()` spy
const twainService = TestBed.inject(TwainService);
// Make the spy return a synchronous Observable with the test data
getQuoteSpy = spyOn(twainService, 'getQuote').and.returnValue(of(testQuote));
fixture = TestBed.createComponent(TwainComponent);
fixture.autoDetectChanges();
component = fixture.componentInstance;
quoteEl = fixture.nativeElement.querySelector('.twain');
});
describe('when test with synchronous observable', () => {
it('should not show quote before OnInit', () => {
expect(quoteEl.textContent).withContext('nothing displayed').toBe('');
expect(errorMessage()).withContext('should not show error element').toBeNull();
expect(getQuoteSpy.calls.any()).withContext('getQuote not yet called').toBe(false);
});
// The quote would not be immediately available if the service were truly async.
it('should show quote after component initialized', async () => {
await fixture.whenStable(); // onInit()
// sync spy result shows testQuote immediately after init
expect(quoteEl.textContent).toBe(testQuote);
expect(getQuoteSpy.calls.any()).withContext('getQuote called').toBe(true);
});
// The error would not be immediately available if the service were truly async.
// Use `fakeAsync` because the component error calls `setTimeout`
it('should display error when TwainService fails', fakeAsync(() => {
// tell spy to return an error observable after a timeout
getQuoteSpy.and.returnValue(
defer(() => {
return new Promise((resolve, reject) => {
setTimeout(() => {
reject('TwainService test failure');
});
});
}),
);
fixture.detectChanges(); // onInit()
// sync spy errors immediately after init
tick(); // flush the setTimeout()
fixture.detectChanges(); // update errorMessage within setTimeout()
expect(errorMessage())
.withContext('should display error')
.toMatch(/test failure/);
expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');
}));
});
describe('when test with asynchronous observable', () => {
beforeEach(() => {
// Simulate delayed observable values with the `asyncData()` helper
getQuoteSpy.and.returnValue(asyncData(testQuote));
});
it('should not show quote before OnInit', () => {
expect(quoteEl.textContent).withContext('nothing displayed').toBe('');
expect(errorMessage()).withContext('should not show error element').toBeNull();
expect(getQuoteSpy.calls.any()).withContext('getQuote not yet called').toBe(false);
});
it('should still not show quote after component initialized', () => {
fixture.detectChanges();
// getQuote service is async => still has not returned with quote
// so should show the start value, '...'
expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');
expect(errorMessage()).withContext('should not show error').toBeNull();
expect(getQuoteSpy.calls.any()).withContext('getQuote called').toBe(true);
});
it('should show quote after getQuote (fakeAsync)', fakeAsync(() => {
fixture.detectChanges(); // ngOnInit()
expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');
tick(); // flush the observable to get the quote
fixture.detectChanges(); // update view
expect(quoteEl.textContent).withContext('should show quote').toBe(testQuote);
expect(errorMessage()).withContext('should not show error').toBeNull();
}));
it('should show quote after getQuote (async)', async () => {
fixture.detectChanges(); // ngOnInit()
expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');
await fixture.whenStable();
// wait for async getQuote
fixture.detectChanges(); // update view with quote
expect(quoteEl.textContent).toBe(testQuote);
expect(errorMessage()).withContext('should not show error').toBeNull();
});
it('should display error when TwainService fails', fakeAsync(() => {
// tell spy to return an async error observable
getQuoteSpy.and.returnValue(asyncError<string>('TwainService test failure'));
fixture.detectChanges();
tick(); // component shows error after a setTimeout()
fixture.detectChanges(); // update error message
expect(errorMessage())
.withContext('should display error')
.toMatch(/test failure/);
expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');
}));
});
});

Focus on the spy.

      
import {fakeAsync, ComponentFixture, TestBed, tick, waitForAsync} from '@angular/core/testing';
import {asyncData, asyncError} from '../../testing';
import {Subject, defer, of, throwError} from 'rxjs';
import {last} from 'rxjs/operators';
import {TwainComponent} from './twain.component';
import {TwainService} from './twain.service';
describe('TwainComponent', () => {
let component: TwainComponent;
let fixture: ComponentFixture<TwainComponent>;
let getQuoteSpy: jasmine.Spy;
let quoteEl: HTMLElement;
let testQuote: string;
// Helper function to get the error message element value
// An *ngIf keeps it out of the DOM until there is an error
const errorMessage = () => {
const el = fixture.nativeElement.querySelector('.error');
return el ? el.textContent : null;
};
beforeEach(() => {
TestBed.configureTestingModule({
imports: [TwainComponent],
providers: [TwainService],
});
testQuote = 'Test Quote';
// Create a fake TwainService object with a `getQuote()` spy
const twainService = TestBed.inject(TwainService);
// Make the spy return a synchronous Observable with the test data
getQuoteSpy = spyOn(twainService, 'getQuote').and.returnValue(of(testQuote));
fixture = TestBed.createComponent(TwainComponent);
fixture.autoDetectChanges();
component = fixture.componentInstance;
quoteEl = fixture.nativeElement.querySelector('.twain');
});
describe('when test with synchronous observable', () => {
it('should not show quote before OnInit', () => {
expect(quoteEl.textContent).withContext('nothing displayed').toBe('');
expect(errorMessage()).withContext('should not show error element').toBeNull();
expect(getQuoteSpy.calls.any()).withContext('getQuote not yet called').toBe(false);
});
// The quote would not be immediately available if the service were truly async.
it('should show quote after component initialized', async () => {
await fixture.whenStable(); // onInit()
// sync spy result shows testQuote immediately after init
expect(quoteEl.textContent).toBe(testQuote);
expect(getQuoteSpy.calls.any()).withContext('getQuote called').toBe(true);
});
// The error would not be immediately available if the service were truly async.
// Use `fakeAsync` because the component error calls `setTimeout`
it('should display error when TwainService fails', fakeAsync(() => {
// tell spy to return an error observable after a timeout
getQuoteSpy.and.returnValue(
defer(() => {
return new Promise((resolve, reject) => {
setTimeout(() => {
reject('TwainService test failure');
});
});
}),
);
fixture.detectChanges(); // onInit()
// sync spy errors immediately after init
tick(); // flush the setTimeout()
fixture.detectChanges(); // update errorMessage within setTimeout()
expect(errorMessage())
.withContext('should display error')
.toMatch(/test failure/);
expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');
}));
});
describe('when test with asynchronous observable', () => {
beforeEach(() => {
// Simulate delayed observable values with the `asyncData()` helper
getQuoteSpy.and.returnValue(asyncData(testQuote));
});
it('should not show quote before OnInit', () => {
expect(quoteEl.textContent).withContext('nothing displayed').toBe('');
expect(errorMessage()).withContext('should not show error element').toBeNull();
expect(getQuoteSpy.calls.any()).withContext('getQuote not yet called').toBe(false);
});
it('should still not show quote after component initialized', () => {
fixture.detectChanges();
// getQuote service is async => still has not returned with quote
// so should show the start value, '...'
expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');
expect(errorMessage()).withContext('should not show error').toBeNull();
expect(getQuoteSpy.calls.any()).withContext('getQuote called').toBe(true);
});
it('should show quote after getQuote (fakeAsync)', fakeAsync(() => {
fixture.detectChanges(); // ngOnInit()
expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');
tick(); // flush the observable to get the quote
fixture.detectChanges(); // update view
expect(quoteEl.textContent).withContext('should show quote').toBe(testQuote);
expect(errorMessage()).withContext('should not show error').toBeNull();
}));
it('should show quote after getQuote (async)', async () => {
fixture.detectChanges(); // ngOnInit()
expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');
await fixture.whenStable();
// wait for async getQuote
fixture.detectChanges(); // update view with quote
expect(quoteEl.textContent).toBe(testQuote);
expect(errorMessage()).withContext('should not show error').toBeNull();
});
it('should display error when TwainService fails', fakeAsync(() => {
// tell spy to return an async error observable
getQuoteSpy.and.returnValue(asyncError<string>('TwainService test failure'));
fixture.detectChanges();
tick(); // component shows error after a setTimeout()
fixture.detectChanges(); // update error message
expect(errorMessage())
.withContext('should display error')
.toMatch(/test failure/);
expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');
}));
});
});

The spy is designed such that any call to getQuote receives an observable with a test quote. Unlike the real getQuote() method, this spy bypasses the server and returns a synchronous observable whose value is available immediately.

You can write many useful tests with this spy, even though its Observable is synchronous.

HELPFUL: It is best to limit the usage of spies to only what is necessary for the test. Creating mocks or spies for more than what's necessary can be brittle. As the component and injectable evolves, the unrelated tests can fail because they no longer mock enough behaviors that would otherwise not affect the test.

Async test with fakeAsync()

To use fakeAsync() functionality, you must import zone.js/testing in your test setup file. If you created your project with the Angular CLI, zone-testing is already imported in src/test.ts.

The following test confirms the expected behavior when the service returns an ErrorObservable.

      
import {fakeAsync, ComponentFixture, TestBed, tick, waitForAsync} from '@angular/core/testing';
import {asyncData, asyncError} from '../../testing';
import {Subject, defer, of, throwError} from 'rxjs';
import {last} from 'rxjs/operators';
import {TwainComponent} from './twain.component';
import {TwainService} from './twain.service';
describe('TwainComponent', () => {
let component: TwainComponent;
let fixture: ComponentFixture<TwainComponent>;
let getQuoteSpy: jasmine.Spy;
let quoteEl: HTMLElement;
let testQuote: string;
// Helper function to get the error message element value
// An *ngIf keeps it out of the DOM until there is an error
const errorMessage = () => {
const el = fixture.nativeElement.querySelector('.error');
return el ? el.textContent : null;
};
beforeEach(() => {
TestBed.configureTestingModule({
imports: [TwainComponent],
providers: [TwainService],
});
testQuote = 'Test Quote';
// Create a fake TwainService object with a `getQuote()` spy
const twainService = TestBed.inject(TwainService);
// Make the spy return a synchronous Observable with the test data
getQuoteSpy = spyOn(twainService, 'getQuote').and.returnValue(of(testQuote));
fixture = TestBed.createComponent(TwainComponent);
fixture.autoDetectChanges();
component = fixture.componentInstance;
quoteEl = fixture.nativeElement.querySelector('.twain');
});
describe('when test with synchronous observable', () => {
it('should not show quote before OnInit', () => {
expect(quoteEl.textContent).withContext('nothing displayed').toBe('');
expect(errorMessage()).withContext('should not show error element').toBeNull();
expect(getQuoteSpy.calls.any()).withContext('getQuote not yet called').toBe(false);
});
// The quote would not be immediately available if the service were truly async.
it('should show quote after component initialized', async () => {
await fixture.whenStable(); // onInit()
// sync spy result shows testQuote immediately after init
expect(quoteEl.textContent).toBe(testQuote);
expect(getQuoteSpy.calls.any()).withContext('getQuote called').toBe(true);
});
// The error would not be immediately available if the service were truly async.
// Use `fakeAsync` because the component error calls `setTimeout`
it('should display error when TwainService fails', fakeAsync(() => {
// tell spy to return an error observable after a timeout
getQuoteSpy.and.returnValue(
defer(() => {
return new Promise((resolve, reject) => {
setTimeout(() => {
reject('TwainService test failure');
});
});
}),
);
fixture.detectChanges(); // onInit()
// sync spy errors immediately after init
tick(); // flush the setTimeout()
fixture.detectChanges(); // update errorMessage within setTimeout()
expect(errorMessage())
.withContext('should display error')
.toMatch(/test failure/);
expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');
}));
});
describe('when test with asynchronous observable', () => {
beforeEach(() => {
// Simulate delayed observable values with the `asyncData()` helper
getQuoteSpy.and.returnValue(asyncData(testQuote));
});
it('should not show quote before OnInit', () => {
expect(quoteEl.textContent).withContext('nothing displayed').toBe('');
expect(errorMessage()).withContext('should not show error element').toBeNull();
expect(getQuoteSpy.calls.any()).withContext('getQuote not yet called').toBe(false);
});
it('should still not show quote after component initialized', () => {
fixture.detectChanges();
// getQuote service is async => still has not returned with quote
// so should show the start value, '...'
expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');
expect(errorMessage()).withContext('should not show error').toBeNull();
expect(getQuoteSpy.calls.any()).withContext('getQuote called').toBe(true);
});
it('should show quote after getQuote (fakeAsync)', fakeAsync(() => {
fixture.detectChanges(); // ngOnInit()
expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');
tick(); // flush the observable to get the quote
fixture.detectChanges(); // update view
expect(quoteEl.textContent).withContext('should show quote').toBe(testQuote);
expect(errorMessage()).withContext('should not show error').toBeNull();
}));
it('should show quote after getQuote (async)', async () => {
fixture.detectChanges(); // ngOnInit()
expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');
await fixture.whenStable();
// wait for async getQuote
fixture.detectChanges(); // update view with quote
expect(quoteEl.textContent).toBe(testQuote);
expect(errorMessage()).withContext('should not show error').toBeNull();
});
it('should display error when TwainService fails', fakeAsync(() => {
// tell spy to return an async error observable
getQuoteSpy.and.returnValue(asyncError<string>('TwainService test failure'));
fixture.detectChanges();
tick(); // component shows error after a setTimeout()
fixture.detectChanges(); // update error message
expect(errorMessage())
.withContext('should display error')
.toMatch(/test failure/);
expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');
}));
});
});

HELPFUL: The it() function receives an argument of the following form.

      
fakeAsync(() => { /*test body*/ })

The fakeAsync() function enables a linear coding style by running the test body in a special fakeAsync test zone. The test body appears to be synchronous. There is no nested syntax (like a Promise.then()) to disrupt the flow of control.

HELPFUL: Limitation: The fakeAsync() function won't work if the test body makes an XMLHttpRequest (XHR) call. XHR calls within a test are rare, but if you need to call XHR, see the waitForAsync() section.

IMPORTANT: Be aware that asynchronous tasks that happen inside the fakeAsync zone need to be manually executed with flush or tick. If you attempt to wait for them to complete (i.e. using fixture.whenStable) without using the fakeAsync test helpers to advance time, your test will likely fail. See below for more information.

The tick() function

You do have to call tick() to advance the virtual clock.

Calling tick() simulates the passage of time until all pending asynchronous activities finish. In this case, it waits for the observable's setTimeout().

The tick() function accepts millis and tickOptions as parameters. The millis parameter specifies how much the virtual clock advances and defaults to 0 if not provided. For example, if you have a setTimeout(fn, 100) in a fakeAsync() test, you need to use tick(100) to trigger the fn callback. The optional tickOptions parameter has a property named processNewMacroTasksSynchronously. The processNewMacroTasksSynchronously property represents whether to invoke new generated macro tasks when ticking and defaults to true.

      
import {fakeAsync, tick, waitForAsync} from '@angular/core/testing';
import {interval, of} from 'rxjs';
import {delay, take} from 'rxjs/operators';
describe('Angular async helper', () => {
describe('async', () => {
let actuallyDone = false;
beforeEach(() => {
actuallyDone = false;
});
afterEach(() => {
expect(actuallyDone).withContext('actuallyDone should be true').toBe(true);
});
it('should run normal test', () => {
actuallyDone = true;
});
it('should run normal async test', (done: DoneFn) => {
setTimeout(() => {
actuallyDone = true;
done();
}, 0);
});
it('should run async test with task', waitForAsync(() => {
setTimeout(() => {
actuallyDone = true;
}, 0);
}));
it('should run async test with task', waitForAsync(() => {
const id = setInterval(() => {
actuallyDone = true;
clearInterval(id);
}, 100);
}));
it('should run async test with successful promise', waitForAsync(() => {
const p = new Promise((resolve) => {
setTimeout(resolve, 10);
});
p.then(() => {
actuallyDone = true;
});
}));
it('should run async test with failed promise', waitForAsync(() => {
const p = new Promise((resolve, reject) => {
setTimeout(reject, 10);
});
p.catch(() => {
actuallyDone = true;
});
}));
// Use done. Can also use async or fakeAsync.
it('should run async test with successful delayed Observable', (done: DoneFn) => {
const source = of(true).pipe(delay(10));
source.subscribe({
next: (val) => (actuallyDone = true),
error: (err) => fail(err),
complete: done,
});
});
it('should run async test with successful delayed Observable', waitForAsync(() => {
const source = of(true).pipe(delay(10));
source.subscribe({
next: (val) => (actuallyDone = true),
error: (err) => fail(err),
});
}));
it('should run async test with successful delayed Observable', fakeAsync(() => {
const source = of(true).pipe(delay(10));
source.subscribe({
next: (val) => (actuallyDone = true),
error: (err) => fail(err),
});
tick(10);
}));
});
describe('fakeAsync', () => {
it('should run timeout callback with delay after call tick with millis', fakeAsync(() => {
let called = false;
setTimeout(() => {
called = true;
}, 100);
tick(100);
expect(called).toBe(true);
}));
it('should run new macro task callback with delay after call tick with millis', fakeAsync(() => {
function nestedTimer(cb: () => any): void {
setTimeout(() => setTimeout(() => cb()));
}
const callback = jasmine.createSpy('callback');
nestedTimer(callback);
expect(callback).not.toHaveBeenCalled();
tick(0);
// the nested timeout will also be triggered
expect(callback).toHaveBeenCalled();
}));
it('should not run new macro task callback with delay after call tick with millis', fakeAsync(() => {
function nestedTimer(cb: () => any): void {
setTimeout(() => setTimeout(() => cb()));
}
const callback = jasmine.createSpy('callback');
nestedTimer(callback);
expect(callback).not.toHaveBeenCalled();
tick(0, {processNewMacroTasksSynchronously: false});
// the nested timeout will not be triggered
expect(callback).not.toHaveBeenCalled();
tick(0);
expect(callback).toHaveBeenCalled();
}));
it('should get Date diff correctly in fakeAsync', fakeAsync(() => {
const start = Date.now();
tick(100);
const end = Date.now();
expect(end - start).toBe(100);
}));
it('should get Date diff correctly in fakeAsync with rxjs scheduler', fakeAsync(() => {
// need to add `import 'zone.js/plugins/zone-patch-rxjs-fake-async'
// to patch rxjs scheduler
let result = '';
of('hello')
.pipe(delay(1000))
.subscribe((v) => {
result = v;
});
expect(result).toBe('');
tick(1000);
expect(result).toBe('hello');
const start = new Date().getTime();
let dateDiff = 0;
interval(1000)
.pipe(take(2))
.subscribe(() => (dateDiff = new Date().getTime() - start));
tick(1000);
expect(dateDiff).toBe(1000);
tick(1000);
expect(dateDiff).toBe(2000);
}));
});
describe('use jasmine.clock()', () => {
// need to config __zone_symbol__fakeAsyncPatchLock flag
// before loading zone.js/testing
beforeEach(() => {
jasmine.clock().install();
});
afterEach(() => {
jasmine.clock().uninstall();
});
it('should auto enter fakeAsync', () => {
// is in fakeAsync now, don't need to call fakeAsync(testFn)
let called = false;
setTimeout(() => {
called = true;
}, 100);
jasmine.clock().tick(100);
expect(called).toBe(true);
});
});
describe('test jsonp', () => {
function jsonp(url: string, callback: () => void) {
// do a jsonp call which is not zone aware
}
// need to config __zone_symbol__supportWaitUnResolvedChainedPromise flag
// before loading zone.js/testing
it('should wait until promise.then is called', waitForAsync(() => {
let finished = false;
new Promise<void>((res) => {
jsonp('localhost:8080/jsonp', () => {
// success callback and resolve the promise
finished = true;
res();
});
}).then(() => {
// async will wait until promise.then is called
// if __zone_symbol__supportWaitUnResolvedChainedPromise is set
expect(finished).toBe(true);
});
}));
});
});

The tick() function is one of the Angular testing utilities that you import with TestBed. It's a companion to fakeAsync() and you can only call it within a fakeAsync() body.

tickOptions

In this example, you have a new macro task, the nested setTimeout function. By default, when the tick is setTimeout, outside and nested will both be triggered.

      
import {fakeAsync, tick, waitForAsync} from '@angular/core/testing';
import {interval, of} from 'rxjs';
import {delay, take} from 'rxjs/operators';
describe('Angular async helper', () => {
describe('async', () => {
let actuallyDone = false;
beforeEach(() => {
actuallyDone = false;
});
afterEach(() => {
expect(actuallyDone).withContext('actuallyDone should be true').toBe(true);
});
it('should run normal test', () => {
actuallyDone = true;
});
it('should run normal async test', (done: DoneFn) => {
setTimeout(() => {
actuallyDone = true;
done();
}, 0);
});
it('should run async test with task', waitForAsync(() => {
setTimeout(() => {
actuallyDone = true;
}, 0);
}));
it('should run async test with task', waitForAsync(() => {
const id = setInterval(() => {
actuallyDone = true;
clearInterval(id);
}, 100);
}));
it('should run async test with successful promise', waitForAsync(() => {
const p = new Promise((resolve) => {
setTimeout(resolve, 10);
});
p.then(() => {
actuallyDone = true;
});
}));
it('should run async test with failed promise', waitForAsync(() => {
const p = new Promise((resolve, reject) => {
setTimeout(reject, 10);
});
p.catch(() => {
actuallyDone = true;
});
}));
// Use done. Can also use async or fakeAsync.
it('should run async test with successful delayed Observable', (done: DoneFn) => {
const source = of(true).pipe(delay(10));
source.subscribe({
next: (val) => (actuallyDone = true),
error: (err) => fail(err),
complete: done,
});
});
it('should run async test with successful delayed Observable', waitForAsync(() => {
const source = of(true).pipe(delay(10));
source.subscribe({
next: (val) => (actuallyDone = true),
error: (err) => fail(err),
});
}));
it('should run async test with successful delayed Observable', fakeAsync(() => {
const source = of(true).pipe(delay(10));
source.subscribe({
next: (val) => (actuallyDone = true),
error: (err) => fail(err),
});
tick(10);
}));
});
describe('fakeAsync', () => {
it('should run timeout callback with delay after call tick with millis', fakeAsync(() => {
let called = false;
setTimeout(() => {
called = true;
}, 100);
tick(100);
expect(called).toBe(true);
}));
it('should run new macro task callback with delay after call tick with millis', fakeAsync(() => {
function nestedTimer(cb: () => any): void {
setTimeout(() => setTimeout(() => cb()));
}
const callback = jasmine.createSpy('callback');
nestedTimer(callback);
expect(callback).not.toHaveBeenCalled();
tick(0);
// the nested timeout will also be triggered
expect(callback).toHaveBeenCalled();
}));
it('should not run new macro task callback with delay after call tick with millis', fakeAsync(() => {
function nestedTimer(cb: () => any): void {
setTimeout(() => setTimeout(() => cb()));
}
const callback = jasmine.createSpy('callback');
nestedTimer(callback);
expect(callback).not.toHaveBeenCalled();
tick(0, {processNewMacroTasksSynchronously: false});
// the nested timeout will not be triggered
expect(callback).not.toHaveBeenCalled();
tick(0);
expect(callback).toHaveBeenCalled();
}));
it('should get Date diff correctly in fakeAsync', fakeAsync(() => {
const start = Date.now();
tick(100);
const end = Date.now();
expect(end - start).toBe(100);
}));
it('should get Date diff correctly in fakeAsync with rxjs scheduler', fakeAsync(() => {
// need to add `import 'zone.js/plugins/zone-patch-rxjs-fake-async'
// to patch rxjs scheduler
let result = '';
of('hello')
.pipe(delay(1000))
.subscribe((v) => {
result = v;
});
expect(result).toBe('');
tick(1000);
expect(result).toBe('hello');
const start = new Date().getTime();
let dateDiff = 0;
interval(1000)
.pipe(take(2))
.subscribe(() => (dateDiff = new Date().getTime() - start));
tick(1000);
expect(dateDiff).toBe(1000);
tick(1000);
expect(dateDiff).toBe(2000);
}));
});
describe('use jasmine.clock()', () => {
// need to config __zone_symbol__fakeAsyncPatchLock flag
// before loading zone.js/testing
beforeEach(() => {
jasmine.clock().install();
});
afterEach(() => {
jasmine.clock().uninstall();
});
it('should auto enter fakeAsync', () => {
// is in fakeAsync now, don't need to call fakeAsync(testFn)
let called = false;
setTimeout(() => {
called = true;
}, 100);
jasmine.clock().tick(100);
expect(called).toBe(true);
});
});
describe('test jsonp', () => {
function jsonp(url: string, callback: () => void) {
// do a jsonp call which is not zone aware
}
// need to config __zone_symbol__supportWaitUnResolvedChainedPromise flag
// before loading zone.js/testing
it('should wait until promise.then is called', waitForAsync(() => {
let finished = false;
new Promise<void>((res) => {
jsonp('localhost:8080/jsonp', () => {
// success callback and resolve the promise
finished = true;
res();
});
}).then(() => {
// async will wait until promise.then is called
// if __zone_symbol__supportWaitUnResolvedChainedPromise is set
expect(finished).toBe(true);
});
}));
});
});

In some case, you don't want to trigger the new macro task when ticking. You can use tick(millis, {processNewMacroTasksSynchronously: false}) to not invoke a new macro task.

      
import {fakeAsync, tick, waitForAsync} from '@angular/core/testing';
import {interval, of} from 'rxjs';
import {delay, take} from 'rxjs/operators';
describe('Angular async helper', () => {
describe('async', () => {
let actuallyDone = false;
beforeEach(() => {
actuallyDone = false;
});
afterEach(() => {
expect(actuallyDone).withContext('actuallyDone should be true').toBe(true);
});
it('should run normal test', () => {
actuallyDone = true;
});
it('should run normal async test', (done: DoneFn) => {
setTimeout(() => {
actuallyDone = true;
done();
}, 0);
});
it('should run async test with task', waitForAsync(() => {
setTimeout(() => {
actuallyDone = true;
}, 0);
}));
it('should run async test with task', waitForAsync(() => {
const id = setInterval(() => {
actuallyDone = true;
clearInterval(id);
}, 100);
}));
it('should run async test with successful promise', waitForAsync(() => {
const p = new Promise((resolve) => {
setTimeout(resolve, 10);
});
p.then(() => {
actuallyDone = true;
});
}));
it('should run async test with failed promise', waitForAsync(() => {
const p = new Promise((resolve, reject) => {
setTimeout(reject, 10);
});
p.catch(() => {
actuallyDone = true;
});
}));
// Use done. Can also use async or fakeAsync.
it('should run async test with successful delayed Observable', (done: DoneFn) => {
const source = of(true).pipe(delay(10));
source.subscribe({
next: (val) => (actuallyDone = true),
error: (err) => fail(err),
complete: done,
});
});
it('should run async test with successful delayed Observable', waitForAsync(() => {
const source = of(true).pipe(delay(10));
source.subscribe({
next: (val) => (actuallyDone = true),
error: (err) => fail(err),
});
}));
it('should run async test with successful delayed Observable', fakeAsync(() => {
const source = of(true).pipe(delay(10));
source.subscribe({
next: (val) => (actuallyDone = true),
error: (err) => fail(err),
});
tick(10);
}));
});
describe('fakeAsync', () => {
it('should run timeout callback with delay after call tick with millis', fakeAsync(() => {
let called = false;
setTimeout(() => {
called = true;
}, 100);
tick(100);
expect(called).toBe(true);
}));
it('should run new macro task callback with delay after call tick with millis', fakeAsync(() => {
function nestedTimer(cb: () => any): void {
setTimeout(() => setTimeout(() => cb()));
}
const callback = jasmine.createSpy('callback');
nestedTimer(callback);
expect(callback).not.toHaveBeenCalled();
tick(0);
// the nested timeout will also be triggered
expect(callback).toHaveBeenCalled();
}));
it('should not run new macro task callback with delay after call tick with millis', fakeAsync(() => {
function nestedTimer(cb: () => any): void {
setTimeout(() => setTimeout(() => cb()));
}
const callback = jasmine.createSpy('callback');
nestedTimer(callback);
expect(callback).not.toHaveBeenCalled();
tick(0, {processNewMacroTasksSynchronously: false});
// the nested timeout will not be triggered
expect(callback).not.toHaveBeenCalled();
tick(0);
expect(callback).toHaveBeenCalled();
}));
it('should get Date diff correctly in fakeAsync', fakeAsync(() => {
const start = Date.now();
tick(100);
const end = Date.now();
expect(end - start).toBe(100);
}));
it('should get Date diff correctly in fakeAsync with rxjs scheduler', fakeAsync(() => {
// need to add `import 'zone.js/plugins/zone-patch-rxjs-fake-async'
// to patch rxjs scheduler
let result = '';
of('hello')
.pipe(delay(1000))
.subscribe((v) => {
result = v;
});
expect(result).toBe('');
tick(1000);
expect(result).toBe('hello');
const start = new Date().getTime();
let dateDiff = 0;
interval(1000)
.pipe(take(2))
.subscribe(() => (dateDiff = new Date().getTime() - start));
tick(1000);
expect(dateDiff).toBe(1000);
tick(1000);
expect(dateDiff).toBe(2000);
}));
});
describe('use jasmine.clock()', () => {
// need to config __zone_symbol__fakeAsyncPatchLock flag
// before loading zone.js/testing
beforeEach(() => {
jasmine.clock().install();
});
afterEach(() => {
jasmine.clock().uninstall();
});
it('should auto enter fakeAsync', () => {
// is in fakeAsync now, don't need to call fakeAsync(testFn)
let called = false;
setTimeout(() => {
called = true;
}, 100);
jasmine.clock().tick(100);
expect(called).toBe(true);
});
});
describe('test jsonp', () => {
function jsonp(url: string, callback: () => void) {
// do a jsonp call which is not zone aware
}
// need to config __zone_symbol__supportWaitUnResolvedChainedPromise flag
// before loading zone.js/testing
it('should wait until promise.then is called', waitForAsync(() => {
let finished = false;
new Promise<void>((res) => {
jsonp('localhost:8080/jsonp', () => {
// success callback and resolve the promise
finished = true;
res();
});
}).then(() => {
// async will wait until promise.then is called
// if __zone_symbol__supportWaitUnResolvedChainedPromise is set
expect(finished).toBe(true);
});
}));
});
});

Comparing dates inside fakeAsync()

fakeAsync() simulates passage of time, which lets you calculate the difference between dates inside fakeAsync().

      
import {fakeAsync, tick, waitForAsync} from '@angular/core/testing';
import {interval, of} from 'rxjs';
import {delay, take} from 'rxjs/operators';
describe('Angular async helper', () => {
describe('async', () => {
let actuallyDone = false;
beforeEach(() => {
actuallyDone = false;
});
afterEach(() => {
expect(actuallyDone).withContext('actuallyDone should be true').toBe(true);
});
it('should run normal test', () => {
actuallyDone = true;
});
it('should run normal async test', (done: DoneFn) => {
setTimeout(() => {
actuallyDone = true;
done();
}, 0);
});
it('should run async test with task', waitForAsync(() => {
setTimeout(() => {
actuallyDone = true;
}, 0);
}));
it('should run async test with task', waitForAsync(() => {
const id = setInterval(() => {
actuallyDone = true;
clearInterval(id);
}, 100);
}));
it('should run async test with successful promise', waitForAsync(() => {
const p = new Promise((resolve) => {
setTimeout(resolve, 10);
});
p.then(() => {
actuallyDone = true;
});
}));
it('should run async test with failed promise', waitForAsync(() => {
const p = new Promise((resolve, reject) => {
setTimeout(reject, 10);
});
p.catch(() => {
actuallyDone = true;
});
}));
// Use done. Can also use async or fakeAsync.
it('should run async test with successful delayed Observable', (done: DoneFn) => {
const source = of(true).pipe(delay(10));
source.subscribe({
next: (val) => (actuallyDone = true),
error: (err) => fail(err),
complete: done,
});
});
it('should run async test with successful delayed Observable', waitForAsync(() => {
const source = of(true).pipe(delay(10));
source.subscribe({
next: (val) => (actuallyDone = true),
error: (err) => fail(err),
});
}));
it('should run async test with successful delayed Observable', fakeAsync(() => {
const source = of(true).pipe(delay(10));
source.subscribe({
next: (val) => (actuallyDone = true),
error: (err) => fail(err),
});
tick(10);
}));
});
describe('fakeAsync', () => {
it('should run timeout callback with delay after call tick with millis', fakeAsync(() => {
let called = false;
setTimeout(() => {
called = true;
}, 100);
tick(100);
expect(called).toBe(true);
}));
it('should run new macro task callback with delay after call tick with millis', fakeAsync(() => {
function nestedTimer(cb: () => any): void {
setTimeout(() => setTimeout(() => cb()));
}
const callback = jasmine.createSpy('callback');
nestedTimer(callback);
expect(callback).not.toHaveBeenCalled();
tick(0);
// the nested timeout will also be triggered
expect(callback).toHaveBeenCalled();
}));
it('should not run new macro task callback with delay after call tick with millis', fakeAsync(() => {
function nestedTimer(cb: () => any): void {
setTimeout(() => setTimeout(() => cb()));
}
const callback = jasmine.createSpy('callback');
nestedTimer(callback);
expect(callback).not.toHaveBeenCalled();
tick(0, {processNewMacroTasksSynchronously: false});
// the nested timeout will not be triggered
expect(callback).not.toHaveBeenCalled();
tick(0);
expect(callback).toHaveBeenCalled();
}));
it('should get Date diff correctly in fakeAsync', fakeAsync(() => {
const start = Date.now();
tick(100);
const end = Date.now();
expect(end - start).toBe(100);
}));
it('should get Date diff correctly in fakeAsync with rxjs scheduler', fakeAsync(() => {
// need to add `import 'zone.js/plugins/zone-patch-rxjs-fake-async'
// to patch rxjs scheduler
let result = '';
of('hello')
.pipe(delay(1000))
.subscribe((v) => {
result = v;
});
expect(result).toBe('');
tick(1000);
expect(result).toBe('hello');
const start = new Date().getTime();
let dateDiff = 0;
interval(1000)
.pipe(take(2))
.subscribe(() => (dateDiff = new Date().getTime() - start));
tick(1000);
expect(dateDiff).toBe(1000);
tick(1000);
expect(dateDiff).toBe(2000);
}));
});
describe('use jasmine.clock()', () => {
// need to config __zone_symbol__fakeAsyncPatchLock flag
// before loading zone.js/testing
beforeEach(() => {
jasmine.clock().install();
});
afterEach(() => {
jasmine.clock().uninstall();
});
it('should auto enter fakeAsync', () => {
// is in fakeAsync now, don't need to call fakeAsync(testFn)
let called = false;
setTimeout(() => {
called = true;
}, 100);
jasmine.clock().tick(100);
expect(called).toBe(true);
});
});
describe('test jsonp', () => {
function jsonp(url: string, callback: () => void) {
// do a jsonp call which is not zone aware
}
// need to config __zone_symbol__supportWaitUnResolvedChainedPromise flag
// before loading zone.js/testing
it('should wait until promise.then is called', waitForAsync(() => {
let finished = false;
new Promise<void>((res) => {
jsonp('localhost:8080/jsonp', () => {
// success callback and resolve the promise
finished = true;
res();
});
}).then(() => {
// async will wait until promise.then is called
// if __zone_symbol__supportWaitUnResolvedChainedPromise is set
expect(finished).toBe(true);
});
}));
});
});

jasmine.clock with fakeAsync()

Jasmine also provides a clock feature to mock dates. Angular automatically runs tests that are run after jasmine.clock().install() is called inside a fakeAsync() method until jasmine.clock().uninstall() is called. fakeAsync() is not needed and throws an error if nested.

By default, this feature is disabled. To enable it, set a global flag before importing zone-testing.

If you use the Angular CLI, configure this flag in src/test.ts.

      
[window as any]('__zone_symbol__fakeAsyncPatchLock') = true;
import 'zone.js/testing';
      
import {fakeAsync, tick, waitForAsync} from '@angular/core/testing';
import {interval, of} from 'rxjs';
import {delay, take} from 'rxjs/operators';
describe('Angular async helper', () => {
describe('async', () => {
let actuallyDone = false;
beforeEach(() => {
actuallyDone = false;
});
afterEach(() => {
expect(actuallyDone).withContext('actuallyDone should be true').toBe(true);
});
it('should run normal test', () => {
actuallyDone = true;
});
it('should run normal async test', (done: DoneFn) => {
setTimeout(() => {
actuallyDone = true;
done();
}, 0);
});
it('should run async test with task', waitForAsync(() => {
setTimeout(() => {
actuallyDone = true;
}, 0);
}));
it('should run async test with task', waitForAsync(() => {
const id = setInterval(() => {
actuallyDone = true;
clearInterval(id);
}, 100);
}));
it('should run async test with successful promise', waitForAsync(() => {
const p = new Promise((resolve) => {
setTimeout(resolve, 10);
});
p.then(() => {
actuallyDone = true;
});
}));
it('should run async test with failed promise', waitForAsync(() => {
const p = new Promise((resolve, reject) => {
setTimeout(reject, 10);
});
p.catch(() => {
actuallyDone = true;
});
}));
// Use done. Can also use async or fakeAsync.
it('should run async test with successful delayed Observable', (done: DoneFn) => {
const source = of(true).pipe(delay(10));
source.subscribe({
next: (val) => (actuallyDone = true),
error: (err) => fail(err),
complete: done,
});
});
it('should run async test with successful delayed Observable', waitForAsync(() => {
const source = of(true).pipe(delay(10));
source.subscribe({
next: (val) => (actuallyDone = true),
error: (err) => fail(err),
});
}));
it('should run async test with successful delayed Observable', fakeAsync(() => {
const source = of(true).pipe(delay(10));
source.subscribe({
next: (val) => (actuallyDone = true),
error: (err) => fail(err),
});
tick(10);
}));
});
describe('fakeAsync', () => {
it('should run timeout callback with delay after call tick with millis', fakeAsync(() => {
let called = false;
setTimeout(() => {
called = true;
}, 100);
tick(100);
expect(called).toBe(true);
}));
it('should run new macro task callback with delay after call tick with millis', fakeAsync(() => {
function nestedTimer(cb: () => any): void {
setTimeout(() => setTimeout(() => cb()));
}
const callback = jasmine.createSpy('callback');
nestedTimer(callback);
expect(callback).not.toHaveBeenCalled();
tick(0);
// the nested timeout will also be triggered
expect(callback).toHaveBeenCalled();
}));
it('should not run new macro task callback with delay after call tick with millis', fakeAsync(() => {
function nestedTimer(cb: () => any): void {
setTimeout(() => setTimeout(() => cb()));
}
const callback = jasmine.createSpy('callback');
nestedTimer(callback);
expect(callback).not.toHaveBeenCalled();
tick(0, {processNewMacroTasksSynchronously: false});
// the nested timeout will not be triggered
expect(callback).not.toHaveBeenCalled();
tick(0);
expect(callback).toHaveBeenCalled();
}));
it('should get Date diff correctly in fakeAsync', fakeAsync(() => {
const start = Date.now();
tick(100);
const end = Date.now();
expect(end - start).toBe(100);
}));
it('should get Date diff correctly in fakeAsync with rxjs scheduler', fakeAsync(() => {
// need to add `import 'zone.js/plugins/zone-patch-rxjs-fake-async'
// to patch rxjs scheduler
let result = '';
of('hello')
.pipe(delay(1000))
.subscribe((v) => {
result = v;
});
expect(result).toBe('');
tick(1000);
expect(result).toBe('hello');
const start = new Date().getTime();
let dateDiff = 0;
interval(1000)
.pipe(take(2))
.subscribe(() => (dateDiff = new Date().getTime() - start));
tick(1000);
expect(dateDiff).toBe(1000);
tick(1000);
expect(dateDiff).toBe(2000);
}));
});
describe('use jasmine.clock()', () => {
// need to config __zone_symbol__fakeAsyncPatchLock flag
// before loading zone.js/testing
beforeEach(() => {
jasmine.clock().install();
});
afterEach(() => {
jasmine.clock().uninstall();
});
it('should auto enter fakeAsync', () => {
// is in fakeAsync now, don't need to call fakeAsync(testFn)
let called = false;
setTimeout(() => {
called = true;
}, 100);
jasmine.clock().tick(100);
expect(called).toBe(true);
});
});
describe('test jsonp', () => {
function jsonp(url: string, callback: () => void) {
// do a jsonp call which is not zone aware
}
// need to config __zone_symbol__supportWaitUnResolvedChainedPromise flag
// before loading zone.js/testing
it('should wait until promise.then is called', waitForAsync(() => {
let finished = false;
new Promise<void>((res) => {
jsonp('localhost:8080/jsonp', () => {
// success callback and resolve the promise
finished = true;
res();
});
}).then(() => {
// async will wait until promise.then is called
// if __zone_symbol__supportWaitUnResolvedChainedPromise is set
expect(finished).toBe(true);
});
}));
});
});

Using the RxJS scheduler inside fakeAsync()

You can also use RxJS scheduler in fakeAsync() just like using setTimeout() or setInterval(), but you need to import zone.js/plugins/zone-patch-rxjs-fake-async to patch RxJS scheduler.

      
import {fakeAsync, tick, waitForAsync} from '@angular/core/testing';
import {interval, of} from 'rxjs';
import {delay, take} from 'rxjs/operators';
describe('Angular async helper', () => {
describe('async', () => {
let actuallyDone = false;
beforeEach(() => {
actuallyDone = false;
});
afterEach(() => {
expect(actuallyDone).withContext('actuallyDone should be true').toBe(true);
});
it('should run normal test', () => {
actuallyDone = true;
});
it('should run normal async test', (done: DoneFn) => {
setTimeout(() => {
actuallyDone = true;
done();
}, 0);
});
it('should run async test with task', waitForAsync(() => {
setTimeout(() => {
actuallyDone = true;
}, 0);
}));
it('should run async test with task', waitForAsync(() => {
const id = setInterval(() => {
actuallyDone = true;
clearInterval(id);
}, 100);
}));
it('should run async test with successful promise', waitForAsync(() => {
const p = new Promise((resolve) => {
setTimeout(resolve, 10);
});
p.then(() => {
actuallyDone = true;
});
}));
it('should run async test with failed promise', waitForAsync(() => {
const p = new Promise((resolve, reject) => {
setTimeout(reject, 10);
});
p.catch(() => {
actuallyDone = true;
});
}));
// Use done. Can also use async or fakeAsync.
it('should run async test with successful delayed Observable', (done: DoneFn) => {
const source = of(true).pipe(delay(10));
source.subscribe({
next: (val) => (actuallyDone = true),
error: (err) => fail(err),
complete: done,
});
});
it('should run async test with successful delayed Observable', waitForAsync(() => {
const source = of(true).pipe(delay(10));
source.subscribe({
next: (val) => (actuallyDone = true),
error: (err) => fail(err),
});
}));
it('should run async test with successful delayed Observable', fakeAsync(() => {
const source = of(true).pipe(delay(10));
source.subscribe({
next: (val) => (actuallyDone = true),
error: (err) => fail(err),
});
tick(10);
}));
});
describe('fakeAsync', () => {
it('should run timeout callback with delay after call tick with millis', fakeAsync(() => {
let called = false;
setTimeout(() => {
called = true;
}, 100);
tick(100);
expect(called).toBe(true);
}));
it('should run new macro task callback with delay after call tick with millis', fakeAsync(() => {
function nestedTimer(cb: () => any): void {
setTimeout(() => setTimeout(() => cb()));
}
const callback = jasmine.createSpy('callback');
nestedTimer(callback);
expect(callback).not.toHaveBeenCalled();
tick(0);
// the nested timeout will also be triggered
expect(callback).toHaveBeenCalled();
}));
it('should not run new macro task callback with delay after call tick with millis', fakeAsync(() => {
function nestedTimer(cb: () => any): void {
setTimeout(() => setTimeout(() => cb()));
}
const callback = jasmine.createSpy('callback');
nestedTimer(callback);
expect(callback).not.toHaveBeenCalled();
tick(0, {processNewMacroTasksSynchronously: false});
// the nested timeout will not be triggered
expect(callback).not.toHaveBeenCalled();
tick(0);
expect(callback).toHaveBeenCalled();
}));
it('should get Date diff correctly in fakeAsync', fakeAsync(() => {
const start = Date.now();
tick(100);
const end = Date.now();
expect(end - start).toBe(100);
}));
it('should get Date diff correctly in fakeAsync with rxjs scheduler', fakeAsync(() => {
// need to add `import 'zone.js/plugins/zone-patch-rxjs-fake-async'
// to patch rxjs scheduler
let result = '';
of('hello')
.pipe(delay(1000))
.subscribe((v) => {
result = v;
});
expect(result).toBe('');
tick(1000);
expect(result).toBe('hello');
const start = new Date().getTime();
let dateDiff = 0;
interval(1000)
.pipe(take(2))
.subscribe(() => (dateDiff = new Date().getTime() - start));
tick(1000);
expect(dateDiff).toBe(1000);
tick(1000);
expect(dateDiff).toBe(2000);
}));
});
describe('use jasmine.clock()', () => {
// need to config __zone_symbol__fakeAsyncPatchLock flag
// before loading zone.js/testing
beforeEach(() => {
jasmine.clock().install();
});
afterEach(() => {
jasmine.clock().uninstall();
});
it('should auto enter fakeAsync', () => {
// is in fakeAsync now, don't need to call fakeAsync(testFn)
let called = false;
setTimeout(() => {
called = true;
}, 100);
jasmine.clock().tick(100);
expect(called).toBe(true);
});
});
describe('test jsonp', () => {
function jsonp(url: string, callback: () => void) {
// do a jsonp call which is not zone aware
}
// need to config __zone_symbol__supportWaitUnResolvedChainedPromise flag
// before loading zone.js/testing
it('should wait until promise.then is called', waitForAsync(() => {
let finished = false;
new Promise<void>((res) => {
jsonp('localhost:8080/jsonp', () => {
// success callback and resolve the promise
finished = true;
res();
});
}).then(() => {
// async will wait until promise.then is called
// if __zone_symbol__supportWaitUnResolvedChainedPromise is set
expect(finished).toBe(true);
});
}));
});
});

Support more macroTasks

By default, fakeAsync() supports the following macro tasks.

  • setTimeout
  • setInterval
  • requestAnimationFrame
  • webkitRequestAnimationFrame
  • mozRequestAnimationFrame

If you run other macro tasks such as HTMLCanvasElement.toBlob(), an "Unknown macroTask scheduled in fake async test" error is thrown.

src/app/shared/canvas.component.spec.ts (failing)

      
import {fakeAsync, TestBed, tick} from '@angular/core/testing';
import {CanvasComponent} from './canvas.component';
describe('CanvasComponent', () => {
beforeEach(() => {
(window as any).__zone_symbol__FakeAsyncTestMacroTask = [
{
source: 'HTMLCanvasElement.toBlob',
callbackArgs: [{size: 200}],
},
];
});
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CanvasComponent],
}).compileComponents();
});
it('should be able to generate blob data from canvas', fakeAsync(() => {
const fixture = TestBed.createComponent(CanvasComponent);
const canvasComp = fixture.componentInstance;
fixture.detectChanges();
expect(canvasComp.blobSize).toBe(0);
tick();
expect(canvasComp.blobSize).toBeGreaterThan(0);
}));
});

src/app/shared/canvas.component.ts

      
// Import patch to make async `HTMLCanvasElement` methods (such as `.toBlob()`) Zone.js-aware.
// Either import in `polyfills.ts` (if used in more than one places in the app) or in the component
// file using `HTMLCanvasElement` (if it is only used in a single file).
import 'zone.js/plugins/zone-patch-canvas';
import {Component, AfterViewInit, ViewChild, ElementRef} from '@angular/core';
@Component({
standalone: true,
selector: 'sample-canvas',
template: '<canvas #sampleCanvas width="200" height="200"></canvas>',
})
export class CanvasComponent implements AfterViewInit {
blobSize = 0;
@ViewChild('sampleCanvas') sampleCanvas!: ElementRef;
ngAfterViewInit() {
const canvas: HTMLCanvasElement = this.sampleCanvas.nativeElement;
const context = canvas.getContext('2d')!;
context.clearRect(0, 0, 200, 200);
context.fillStyle = '#FF1122';
context.fillRect(0, 0, 200, 200);
canvas.toBlob((blob) => {
this.blobSize = blob?.size ?? 0;
});
}
}

If you want to support such a case, you need to define the macro task you want to support in beforeEach(). For example:

src/app/shared/canvas.component.spec.ts (excerpt)

      
import {fakeAsync, TestBed, tick} from '@angular/core/testing';
import {CanvasComponent} from './canvas.component';
describe('CanvasComponent', () => {
beforeEach(() => {
(window as any).__zone_symbol__FakeAsyncTestMacroTask = [
{
source: 'HTMLCanvasElement.toBlob',
callbackArgs: [{size: 200}],
},
];
});
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CanvasComponent],
}).compileComponents();
});
it('should be able to generate blob data from canvas', fakeAsync(() => {
const fixture = TestBed.createComponent(CanvasComponent);
const canvasComp = fixture.componentInstance;
fixture.detectChanges();
expect(canvasComp.blobSize).toBe(0);
tick();
expect(canvasComp.blobSize).toBeGreaterThan(0);
}));
});

HELPFUL: In order to make the <canvas> element Zone.js-aware in your app, you need to import the zone-patch-canvas patch (either in polyfills.ts or in the specific file that uses <canvas>):

src/polyfills.ts or src/app/shared/canvas.component.ts

      
// Import patch to make async `HTMLCanvasElement` methods (such as `.toBlob()`) Zone.js-aware.
// Either import in `polyfills.ts` (if used in more than one places in the app) or in the component
// file using `HTMLCanvasElement` (if it is only used in a single file).
import 'zone.js/plugins/zone-patch-canvas';
import {Component, AfterViewInit, ViewChild, ElementRef} from '@angular/core';
@Component({
standalone: true,
selector: 'sample-canvas',
template: '<canvas #sampleCanvas width="200" height="200"></canvas>',
})
export class CanvasComponent implements AfterViewInit {
blobSize = 0;
@ViewChild('sampleCanvas') sampleCanvas!: ElementRef;
ngAfterViewInit() {
const canvas: HTMLCanvasElement = this.sampleCanvas.nativeElement;
const context = canvas.getContext('2d')!;
context.clearRect(0, 0, 200, 200);
context.fillStyle = '#FF1122';
context.fillRect(0, 0, 200, 200);
canvas.toBlob((blob) => {
this.blobSize = blob?.size ?? 0;
});
}
}

Async observables

You might be satisfied with the test coverage of these tests.

However, you might be troubled by the fact that the real service doesn't quite behave this way. The real service sends requests to a remote server. A server takes time to respond and the response certainly won't be available immediately as in the previous two tests.

Your tests will reflect the real world more faithfully if you return an asynchronous observable from the getQuote() spy like this.

      
import {fakeAsync, ComponentFixture, TestBed, tick, waitForAsync} from '@angular/core/testing';
import {asyncData, asyncError} from '../../testing';
import {Subject, defer, of, throwError} from 'rxjs';
import {last} from 'rxjs/operators';
import {TwainComponent} from './twain.component';
import {TwainService} from './twain.service';
describe('TwainComponent', () => {
let component: TwainComponent;
let fixture: ComponentFixture<TwainComponent>;
let getQuoteSpy: jasmine.Spy;
let quoteEl: HTMLElement;
let testQuote: string;
// Helper function to get the error message element value
// An *ngIf keeps it out of the DOM until there is an error
const errorMessage = () => {
const el = fixture.nativeElement.querySelector('.error');
return el ? el.textContent : null;
};
beforeEach(() => {
TestBed.configureTestingModule({
imports: [TwainComponent],
providers: [TwainService],
});
testQuote = 'Test Quote';
// Create a fake TwainService object with a `getQuote()` spy
const twainService = TestBed.inject(TwainService);
// Make the spy return a synchronous Observable with the test data
getQuoteSpy = spyOn(twainService, 'getQuote').and.returnValue(of(testQuote));
fixture = TestBed.createComponent(TwainComponent);
fixture.autoDetectChanges();
component = fixture.componentInstance;
quoteEl = fixture.nativeElement.querySelector('.twain');
});
describe('when test with synchronous observable', () => {
it('should not show quote before OnInit', () => {
expect(quoteEl.textContent).withContext('nothing displayed').toBe('');
expect(errorMessage()).withContext('should not show error element').toBeNull();
expect(getQuoteSpy.calls.any()).withContext('getQuote not yet called').toBe(false);
});
// The quote would not be immediately available if the service were truly async.
it('should show quote after component initialized', async () => {
await fixture.whenStable(); // onInit()
// sync spy result shows testQuote immediately after init
expect(quoteEl.textContent).toBe(testQuote);
expect(getQuoteSpy.calls.any()).withContext('getQuote called').toBe(true);
});
// The error would not be immediately available if the service were truly async.
// Use `fakeAsync` because the component error calls `setTimeout`
it('should display error when TwainService fails', fakeAsync(() => {
// tell spy to return an error observable after a timeout
getQuoteSpy.and.returnValue(
defer(() => {
return new Promise((resolve, reject) => {
setTimeout(() => {
reject('TwainService test failure');
});
});
}),
);
fixture.detectChanges(); // onInit()
// sync spy errors immediately after init
tick(); // flush the setTimeout()
fixture.detectChanges(); // update errorMessage within setTimeout()
expect(errorMessage())
.withContext('should display error')
.toMatch(/test failure/);
expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');
}));
});
describe('when test with asynchronous observable', () => {
beforeEach(() => {
// Simulate delayed observable values with the `asyncData()` helper
getQuoteSpy.and.returnValue(asyncData(testQuote));
});
it('should not show quote before OnInit', () => {
expect(quoteEl.textContent).withContext('nothing displayed').toBe('');
expect(errorMessage()).withContext('should not show error element').toBeNull();
expect(getQuoteSpy.calls.any()).withContext('getQuote not yet called').toBe(false);
});
it('should still not show quote after component initialized', () => {
fixture.detectChanges();
// getQuote service is async => still has not returned with quote
// so should show the start value, '...'
expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');
expect(errorMessage()).withContext('should not show error').toBeNull();
expect(getQuoteSpy.calls.any()).withContext('getQuote called').toBe(true);
});
it('should show quote after getQuote (fakeAsync)', fakeAsync(() => {
fixture.detectChanges(); // ngOnInit()
expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');
tick(); // flush the observable to get the quote
fixture.detectChanges(); // update view
expect(quoteEl.textContent).withContext('should show quote').toBe(testQuote);
expect(errorMessage()).withContext('should not show error').toBeNull();
}));
it('should show quote after getQuote (async)', async () => {
fixture.detectChanges(); // ngOnInit()
expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');
await fixture.whenStable();
// wait for async getQuote
fixture.detectChanges(); // update view with quote
expect(quoteEl.textContent).toBe(testQuote);
expect(errorMessage()).withContext('should not show error').toBeNull();
});
it('should display error when TwainService fails', fakeAsync(() => {
// tell spy to return an async error observable
getQuoteSpy.and.returnValue(asyncError<string>('TwainService test failure'));
fixture.detectChanges();
tick(); // component shows error after a setTimeout()
fixture.detectChanges(); // update error message
expect(errorMessage())
.withContext('should display error')
.toMatch(/test failure/);
expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');
}));
});
});

Async observable helpers

The async observable was produced by an asyncData helper. The asyncData helper is a utility function that you'll have to write yourself, or copy this one from the sample code.

testing/async-observable-helpers.ts

      
/*
* Mock async observables that return asynchronously.
* The observable either emits once and completes or errors.
*
* Must call `tick()` when test with `fakeAsync()`.
*
* THE FOLLOWING DON'T WORK
* Using `of().delay()` triggers TestBed errors;
* see https://github.com/angular/angular/issues/10127 .
*
* Using `asap` scheduler - as in `of(value, asap)` - doesn't work either.
*/
import {defer} from 'rxjs';
/**
* Create async observable that emits-once and completes
* after a JS engine turn
*/
export function asyncData<T>(data: T) {
return defer(() => Promise.resolve(data));
}
/**
* Create async observable error that errors
* after a JS engine turn
*/
export function asyncError<T>(errorObject: any) {
return defer(() => Promise.reject(errorObject));
}

This helper's observable emits the data value in the next turn of the JavaScript engine.

The RxJS defer() operator returns an observable. It takes a factory function that returns either a promise or an observable. When something subscribes to defer's observable, it adds the subscriber to a new observable created with that factory.

The defer() operator transforms the Promise.resolve() into a new observable that, like HttpClient, emits once and completes. Subscribers are unsubscribed after they receive the data value.

There's a similar helper for producing an async error.

      
/*
* Mock async observables that return asynchronously.
* The observable either emits once and completes or errors.
*
* Must call `tick()` when test with `fakeAsync()`.
*
* THE FOLLOWING DON'T WORK
* Using `of().delay()` triggers TestBed errors;
* see https://github.com/angular/angular/issues/10127 .
*
* Using `asap` scheduler - as in `of(value, asap)` - doesn't work either.
*/
import {defer} from 'rxjs';
/**
* Create async observable that emits-once and completes
* after a JS engine turn
*/
export function asyncData<T>(data: T) {
return defer(() => Promise.resolve(data));
}
/**
* Create async observable error that errors
* after a JS engine turn
*/
export function asyncError<T>(errorObject: any) {
return defer(() => Promise.reject(errorObject));
}

More async tests

Now that the getQuote() spy is returning async observables, most of your tests will have to be async as well.

Here's a fakeAsync() test that demonstrates the data flow you'd expect in the real world.

      
import {fakeAsync, ComponentFixture, TestBed, tick, waitForAsync} from '@angular/core/testing';
import {asyncData, asyncError} from '../../testing';
import {Subject, defer, of, throwError} from 'rxjs';
import {last} from 'rxjs/operators';
import {TwainComponent} from './twain.component';
import {TwainService} from './twain.service';
describe('TwainComponent', () => {
let component: TwainComponent;
let fixture: ComponentFixture<TwainComponent>;
let getQuoteSpy: jasmine.Spy;
let quoteEl: HTMLElement;
let testQuote: string;
// Helper function to get the error message element value
// An *ngIf keeps it out of the DOM until there is an error
const errorMessage = () => {
const el = fixture.nativeElement.querySelector('.error');
return el ? el.textContent : null;
};
beforeEach(() => {
TestBed.configureTestingModule({
imports: [TwainComponent],
providers: [TwainService],
});
testQuote = 'Test Quote';
// Create a fake TwainService object with a `getQuote()` spy
const twainService = TestBed.inject(TwainService);
// Make the spy return a synchronous Observable with the test data
getQuoteSpy = spyOn(twainService, 'getQuote').and.returnValue(of(testQuote));
fixture = TestBed.createComponent(TwainComponent);
fixture.autoDetectChanges();
component = fixture.componentInstance;
quoteEl = fixture.nativeElement.querySelector('.twain');
});
describe('when test with synchronous observable', () => {
it('should not show quote before OnInit', () => {
expect(quoteEl.textContent).withContext('nothing displayed').toBe('');
expect(errorMessage()).withContext('should not show error element').toBeNull();
expect(getQuoteSpy.calls.any()).withContext('getQuote not yet called').toBe(false);
});
// The quote would not be immediately available if the service were truly async.
it('should show quote after component initialized', async () => {
await fixture.whenStable(); // onInit()
// sync spy result shows testQuote immediately after init
expect(quoteEl.textContent).toBe(testQuote);
expect(getQuoteSpy.calls.any()).withContext('getQuote called').toBe(true);
});
// The error would not be immediately available if the service were truly async.
// Use `fakeAsync` because the component error calls `setTimeout`
it('should display error when TwainService fails', fakeAsync(() => {
// tell spy to return an error observable after a timeout
getQuoteSpy.and.returnValue(
defer(() => {
return new Promise((resolve, reject) => {
setTimeout(() => {
reject('TwainService test failure');
});
});
}),
);
fixture.detectChanges(); // onInit()
// sync spy errors immediately after init
tick(); // flush the setTimeout()
fixture.detectChanges(); // update errorMessage within setTimeout()
expect(errorMessage())
.withContext('should display error')
.toMatch(/test failure/);
expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');
}));
});
describe('when test with asynchronous observable', () => {
beforeEach(() => {
// Simulate delayed observable values with the `asyncData()` helper
getQuoteSpy.and.returnValue(asyncData(testQuote));
});
it('should not show quote before OnInit', () => {
expect(quoteEl.textContent).withContext('nothing displayed').toBe('');
expect(errorMessage()).withContext('should not show error element').toBeNull();
expect(getQuoteSpy.calls.any()).withContext('getQuote not yet called').toBe(false);
});
it('should still not show quote after component initialized', () => {
fixture.detectChanges();
// getQuote service is async => still has not returned with quote
// so should show the start value, '...'
expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');
expect(errorMessage()).withContext('should not show error').toBeNull();
expect(getQuoteSpy.calls.any()).withContext('getQuote called').toBe(true);
});
it('should show quote after getQuote (fakeAsync)', fakeAsync(() => {
fixture.detectChanges(); // ngOnInit()
expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');
tick(); // flush the observable to get the quote
fixture.detectChanges(); // update view
expect(quoteEl.textContent).withContext('should show quote').toBe(testQuote);
expect(errorMessage()).withContext('should not show error').toBeNull();
}));
it('should show quote after getQuote (async)', async () => {
fixture.detectChanges(); // ngOnInit()
expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');
await fixture.whenStable();
// wait for async getQuote
fixture.detectChanges(); // update view with quote
expect(quoteEl.textContent).toBe(testQuote);
expect(errorMessage()).withContext('should not show error').toBeNull();
});
it('should display error when TwainService fails', fakeAsync(() => {
// tell spy to return an async error observable
getQuoteSpy.and.returnValue(asyncError<string>('TwainService test failure'));
fixture.detectChanges();
tick(); // component shows error after a setTimeout()
fixture.detectChanges(); // update error message
expect(errorMessage())
.withContext('should display error')
.toMatch(/test failure/);
expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');
}));
});
});

Notice that the quote element displays the placeholder value ('...') after ngOnInit(). The first quote hasn't arrived yet.

To flush the first quote from the observable, you call tick(). Then call detectChanges() to tell Angular to update the screen.

Then you can assert that the quote element displays the expected text.

Async test without fakeAsync()

Here's the previous fakeAsync() test, re-written with the async.

      
import {fakeAsync, ComponentFixture, TestBed, tick, waitForAsync} from '@angular/core/testing';
import {asyncData, asyncError} from '../../testing';
import {Subject, defer, of, throwError} from 'rxjs';
import {last} from 'rxjs/operators';
import {TwainComponent} from './twain.component';
import {TwainService} from './twain.service';
describe('TwainComponent', () => {
let component: TwainComponent;
let fixture: ComponentFixture<TwainComponent>;
let getQuoteSpy: jasmine.Spy;
let quoteEl: HTMLElement;
let testQuote: string;
// Helper function to get the error message element value
// An *ngIf keeps it out of the DOM until there is an error
const errorMessage = () => {
const el = fixture.nativeElement.querySelector('.error');
return el ? el.textContent : null;
};
beforeEach(() => {
TestBed.configureTestingModule({
imports: [TwainComponent],
providers: [TwainService],
});
testQuote = 'Test Quote';
// Create a fake TwainService object with a `getQuote()` spy
const twainService = TestBed.inject(TwainService);
// Make the spy return a synchronous Observable with the test data
getQuoteSpy = spyOn(twainService, 'getQuote').and.returnValue(of(testQuote));
fixture = TestBed.createComponent(TwainComponent);
fixture.autoDetectChanges();
component = fixture.componentInstance;
quoteEl = fixture.nativeElement.querySelector('.twain');
});
describe('when test with synchronous observable', () => {
it('should not show quote before OnInit', () => {
expect(quoteEl.textContent).withContext('nothing displayed').toBe('');
expect(errorMessage()).withContext('should not show error element').toBeNull();
expect(getQuoteSpy.calls.any()).withContext('getQuote not yet called').toBe(false);
});
// The quote would not be immediately available if the service were truly async.
it('should show quote after component initialized', async () => {
await fixture.whenStable(); // onInit()
// sync spy result shows testQuote immediately after init
expect(quoteEl.textContent).toBe(testQuote);
expect(getQuoteSpy.calls.any()).withContext('getQuote called').toBe(true);
});
// The error would not be immediately available if the service were truly async.
// Use `fakeAsync` because the component error calls `setTimeout`
it('should display error when TwainService fails', fakeAsync(() => {
// tell spy to return an error observable after a timeout
getQuoteSpy.and.returnValue(
defer(() => {
return new Promise((resolve, reject) => {
setTimeout(() => {
reject('TwainService test failure');
});
});
}),
);
fixture.detectChanges(); // onInit()
// sync spy errors immediately after init
tick(); // flush the setTimeout()
fixture.detectChanges(); // update errorMessage within setTimeout()
expect(errorMessage())
.withContext('should display error')
.toMatch(/test failure/);
expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');
}));
});
describe('when test with asynchronous observable', () => {
beforeEach(() => {
// Simulate delayed observable values with the `asyncData()` helper
getQuoteSpy.and.returnValue(asyncData(testQuote));
});
it('should not show quote before OnInit', () => {
expect(quoteEl.textContent).withContext('nothing displayed').toBe('');
expect(errorMessage()).withContext('should not show error element').toBeNull();
expect(getQuoteSpy.calls.any()).withContext('getQuote not yet called').toBe(false);
});
it('should still not show quote after component initialized', () => {
fixture.detectChanges();
// getQuote service is async => still has not returned with quote
// so should show the start value, '...'
expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');
expect(errorMessage()).withContext('should not show error').toBeNull();
expect(getQuoteSpy.calls.any()).withContext('getQuote called').toBe(true);
});
it('should show quote after getQuote (fakeAsync)', fakeAsync(() => {
fixture.detectChanges(); // ngOnInit()
expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');
tick(); // flush the observable to get the quote
fixture.detectChanges(); // update view
expect(quoteEl.textContent).withContext('should show quote').toBe(testQuote);
expect(errorMessage()).withContext('should not show error').toBeNull();
}));
it('should show quote after getQuote (async)', async () => {
fixture.detectChanges(); // ngOnInit()
expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');
await fixture.whenStable();
// wait for async getQuote
fixture.detectChanges(); // update view with quote
expect(quoteEl.textContent).toBe(testQuote);
expect(errorMessage()).withContext('should not show error').toBeNull();
});
it('should display error when TwainService fails', fakeAsync(() => {
// tell spy to return an async error observable
getQuoteSpy.and.returnValue(asyncError<string>('TwainService test failure'));
fixture.detectChanges();
tick(); // component shows error after a setTimeout()
fixture.detectChanges(); // update error message
expect(errorMessage())
.withContext('should display error')
.toMatch(/test failure/);
expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');
}));
});
});

whenStable

The test must wait for the getQuote() observable to emit the next quote. Instead of calling tick(), it calls fixture.whenStable().

The fixture.whenStable() returns a promise that resolves when the JavaScript engine's task queue becomes empty. In this example, the task queue becomes empty when the observable emits the first quote.

Component with inputs and outputs

A component with inputs and outputs typically appears inside the view template of a host component. The host uses a property binding to set the input property and an event binding to listen to events raised by the output property.

The testing goal is to verify that such bindings work as expected. The tests should set input values and listen for output events.

The DashboardHeroComponent is a tiny example of a component in this role. It displays an individual hero provided by the DashboardComponent. Clicking that hero tells the DashboardComponent that the user has selected the hero.

The DashboardHeroComponent is embedded in the DashboardComponent template like this:

app/dashboard/dashboard.component.html (excerpt)

      
<h2 highlight>{{ title }}</h2>
<div class="grid grid-pad">
@for (hero of heroes; track hero) {
<dashboard-hero
class="col-1-4"
[hero]="hero"
(selected)="gotoDetail($event)"
>
</dashboard-hero>
}
</div>

The DashboardHeroComponent appears in an *ngFor repeater, which sets each component's hero input property to the looping value and listens for the component's selected event.

Here's the component's full definition:

app/dashboard/dashboard-hero.component.ts (component)

      
import {Component, input, output} from '@angular/core';
import {UpperCasePipe} from '@angular/common';
import {Hero} from '../model/hero';
@Component({
standalone: true,
selector: 'dashboard-hero',
template: `
<button type="button" (click)="click()" class="hero">
{{ hero().name | uppercase }}
</button>
`,
styleUrls: ['./dashboard-hero.component.css'],
imports: [UpperCasePipe],
})
export class DashboardHeroComponent {
hero = input.required<Hero>();
selected = output<Hero>();
click() {
this.selected.emit(this.hero());
}
}

While testing a component this simple has little intrinsic value, it's worth knowing how. Use one of these approaches:

  • Test it as used by DashboardComponent
  • Test it as a standalone component
  • Test it as used by a substitute for DashboardComponent

The immediate goal is to test the DashboardHeroComponent, not the DashboardComponent, so, try the second and third options.

Test DashboardHeroComponent standalone

Here's the meat of the spec file setup.

app/dashboard/dashboard-hero.component.spec.ts (setup)

      
import {DebugElement} from '@angular/core';
import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';
import {By} from '@angular/platform-browser';
import {first} from 'rxjs/operators';
import {addMatchers, click} from '../../testing';
import {appProviders} from '../app.config';
import {Hero} from '../model/hero';
import {DashboardHeroComponent} from './dashboard-hero.component';
beforeEach(addMatchers);
describe('DashboardHeroComponent when tested directly', () => {
let comp: DashboardHeroComponent;
let expectedHero: Hero;
let fixture: ComponentFixture<DashboardHeroComponent>;
let heroDe: DebugElement;
let heroEl: HTMLElement;
beforeEach(() => {
TestBed.configureTestingModule({
providers: appProviders,
});
});
beforeEach(async () => {
fixture = TestBed.createComponent(DashboardHeroComponent);
fixture.autoDetectChanges();
comp = fixture.componentInstance;
// find the hero's DebugElement and element
heroDe = fixture.debugElement.query(By.css('.hero'));
heroEl = heroDe.nativeElement;
// mock the hero supplied by the parent component
expectedHero = {id: 42, name: 'Test Name'};
// simulate the parent setting the input property with that hero
fixture.componentRef.setInput('hero', expectedHero);
// wait for initial data binding
await fixture.whenStable();
});
it('should display hero name in uppercase', () => {
const expectedPipedName = expectedHero.name.toUpperCase();
expect(heroEl.textContent).toContain(expectedPipedName);
});
it('should raise selected event when clicked (triggerEventHandler)', () => {
let selectedHero: Hero | undefined;
comp.selected.subscribe((hero: Hero) => (selectedHero = hero));
heroDe.triggerEventHandler('click');
expect(selectedHero).toBe(expectedHero);
});
it('should raise selected event when clicked (element.click)', () => {
let selectedHero: Hero | undefined;
comp.selected.subscribe((hero: Hero) => (selectedHero = hero));
heroEl.click();
expect(selectedHero).toBe(expectedHero);
});
it('should raise selected event when clicked (click helper with DebugElement)', () => {
let selectedHero: Hero | undefined;
comp.selected.subscribe((hero: Hero) => (selectedHero = hero));
click(heroDe); // click helper with DebugElement
expect(selectedHero).toBe(expectedHero);
});
it('should raise selected event when clicked (click helper with native element)', () => {
let selectedHero: Hero | undefined;
comp.selected.subscribe((hero: Hero) => (selectedHero = hero));
click(heroEl); // click helper with native element
expect(selectedHero).toBe(expectedHero);
});
});
//////////////////
describe('DashboardHeroComponent when inside a test host', () => {
let testHost: TestHostComponent;
let fixture: ComponentFixture<TestHostComponent>;
let heroEl: HTMLElement;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
providers: appProviders,
imports: [DashboardHeroComponent, TestHostComponent],
})
.compileComponents();
}));
beforeEach(() => {
// create TestHostComponent instead of DashboardHeroComponent
fixture = TestBed.createComponent(TestHostComponent);
testHost = fixture.componentInstance;
heroEl = fixture.nativeElement.querySelector('.hero');
fixture.detectChanges(); // trigger initial data binding
});
it('should display hero name', () => {
const expectedPipedName = testHost.hero.name.toUpperCase();
expect(heroEl.textContent).toContain(expectedPipedName);
});
it('should raise selected event when clicked', () => {
click(heroEl);
// selected hero should be the same data bound hero
expect(testHost.selectedHero).toBe(testHost.hero);
});
});
////// Test Host Component //////
import {Component} from '@angular/core';
@Component({
standalone: true,
imports: [DashboardHeroComponent],
template: ` <dashboard-hero [hero]="hero" (selected)="onSelected($event)"> </dashboard-hero>`,
})
class TestHostComponent {
hero: Hero = {id: 42, name: 'Test Name'};
selectedHero: Hero | undefined;
onSelected(hero: Hero) {
this.selectedHero = hero;
}
}

Notice how the setup code assigns a test hero (expectedHero) to the component's hero property, emulating the way the DashboardComponent would set it using the property binding in its repeater.

The following test verifies that the hero name is propagated to the template using a binding.

      
import {DebugElement} from '@angular/core';
import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';
import {By} from '@angular/platform-browser';
import {first} from 'rxjs/operators';
import {addMatchers, click} from '../../testing';
import {appProviders} from '../app.config';
import {Hero} from '../model/hero';
import {DashboardHeroComponent} from './dashboard-hero.component';
beforeEach(addMatchers);
describe('DashboardHeroComponent when tested directly', () => {
let comp: DashboardHeroComponent;
let expectedHero: Hero;
let fixture: ComponentFixture<DashboardHeroComponent>;
let heroDe: DebugElement;
let heroEl: HTMLElement;
beforeEach(() => {
TestBed.configureTestingModule({
providers: appProviders,
});
});
beforeEach(async () => {
fixture = TestBed.createComponent(DashboardHeroComponent);
fixture.autoDetectChanges();
comp = fixture.componentInstance;
// find the hero's DebugElement and element
heroDe = fixture.debugElement.query(By.css('.hero'));
heroEl = heroDe.nativeElement;
// mock the hero supplied by the parent component
expectedHero = {id: 42, name: 'Test Name'};
// simulate the parent setting the input property with that hero
fixture.componentRef.setInput('hero', expectedHero);
// wait for initial data binding
await fixture.whenStable();
});
it('should display hero name in uppercase', () => {
const expectedPipedName = expectedHero.name.toUpperCase();
expect(heroEl.textContent).toContain(expectedPipedName);
});
it('should raise selected event when clicked (triggerEventHandler)', () => {
let selectedHero: Hero | undefined;
comp.selected.subscribe((hero: Hero) => (selectedHero = hero));
heroDe.triggerEventHandler('click');
expect(selectedHero).toBe(expectedHero);
});
it('should raise selected event when clicked (element.click)', () => {
let selectedHero: Hero | undefined;
comp.selected.subscribe((hero: Hero) => (selectedHero = hero));
heroEl.click();
expect(selectedHero).toBe(expectedHero);
});
it('should raise selected event when clicked (click helper with DebugElement)', () => {
let selectedHero: Hero | undefined;
comp.selected.subscribe((hero: Hero) => (selectedHero = hero));
click(heroDe); // click helper with DebugElement
expect(selectedHero).toBe(expectedHero);
});
it('should raise selected event when clicked (click helper with native element)', () => {
let selectedHero: Hero | undefined;
comp.selected.subscribe((hero: Hero) => (selectedHero = hero));
click(heroEl); // click helper with native element
expect(selectedHero).toBe(expectedHero);
});
});
//////////////////
describe('DashboardHeroComponent when inside a test host', () => {
let testHost: TestHostComponent;
let fixture: ComponentFixture<TestHostComponent>;
let heroEl: HTMLElement;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
providers: appProviders,
imports: [DashboardHeroComponent, TestHostComponent],
})
.compileComponents();
}));
beforeEach(() => {
// create TestHostComponent instead of DashboardHeroComponent
fixture = TestBed.createComponent(TestHostComponent);
testHost = fixture.componentInstance;
heroEl = fixture.nativeElement.querySelector('.hero');
fixture.detectChanges(); // trigger initial data binding
});
it('should display hero name', () => {
const expectedPipedName = testHost.hero.name.toUpperCase();
expect(heroEl.textContent).toContain(expectedPipedName);
});
it('should raise selected event when clicked', () => {
click(heroEl);
// selected hero should be the same data bound hero
expect(testHost.selectedHero).toBe(testHost.hero);
});
});
////// Test Host Component //////
import {Component} from '@angular/core';
@Component({
standalone: true,
imports: [DashboardHeroComponent],
template: ` <dashboard-hero [hero]="hero" (selected)="onSelected($event)"> </dashboard-hero>`,
})
class TestHostComponent {
hero: Hero = {id: 42, name: 'Test Name'};
selectedHero: Hero | undefined;
onSelected(hero: Hero) {
this.selectedHero = hero;
}
}

Because the template passes the hero name through the Angular UpperCasePipe, the test must match the element value with the upper-cased name.

Clicking

Clicking the hero should raise a selected event that the host component (DashboardComponent presumably) can hear:

      
import {DebugElement} from '@angular/core';
import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';
import {By} from '@angular/platform-browser';
import {first} from 'rxjs/operators';
import {addMatchers, click} from '../../testing';
import {appProviders} from '../app.config';
import {Hero} from '../model/hero';
import {DashboardHeroComponent} from './dashboard-hero.component';
beforeEach(addMatchers);
describe('DashboardHeroComponent when tested directly', () => {
let comp: DashboardHeroComponent;
let expectedHero: Hero;
let fixture: ComponentFixture<DashboardHeroComponent>;
let heroDe: DebugElement;
let heroEl: HTMLElement;
beforeEach(() => {
TestBed.configureTestingModule({
providers: appProviders,
});
});
beforeEach(async () => {
fixture = TestBed.createComponent(DashboardHeroComponent);
fixture.autoDetectChanges();
comp = fixture.componentInstance;
// find the hero's DebugElement and element
heroDe = fixture.debugElement.query(By.css('.hero'));
heroEl = heroDe.nativeElement;
// mock the hero supplied by the parent component
expectedHero = {id: 42, name: 'Test Name'};
// simulate the parent setting the input property with that hero
fixture.componentRef.setInput('hero', expectedHero);
// wait for initial data binding
await fixture.whenStable();
});
it('should display hero name in uppercase', () => {
const expectedPipedName = expectedHero.name.toUpperCase();
expect(heroEl.textContent).toContain(expectedPipedName);
});
it('should raise selected event when clicked (triggerEventHandler)', () => {
let selectedHero: Hero | undefined;
comp.selected.subscribe((hero: Hero) => (selectedHero = hero));
heroDe.triggerEventHandler('click');
expect(selectedHero).toBe(expectedHero);
});
it('should raise selected event when clicked (element.click)', () => {
let selectedHero: Hero | undefined;
comp.selected.subscribe((hero: Hero) => (selectedHero = hero));
heroEl.click();
expect(selectedHero).toBe(expectedHero);
});
it('should raise selected event when clicked (click helper with DebugElement)', () => {
let selectedHero: Hero | undefined;
comp.selected.subscribe((hero: Hero) => (selectedHero = hero));
click(heroDe); // click helper with DebugElement
expect(selectedHero).toBe(expectedHero);
});
it('should raise selected event when clicked (click helper with native element)', () => {
let selectedHero: Hero | undefined;
comp.selected.subscribe((hero: Hero) => (selectedHero = hero));
click(heroEl); // click helper with native element
expect(selectedHero).toBe(expectedHero);
});
});
//////////////////
describe('DashboardHeroComponent when inside a test host', () => {
let testHost: TestHostComponent;
let fixture: ComponentFixture<TestHostComponent>;
let heroEl: HTMLElement;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
providers: appProviders,
imports: [DashboardHeroComponent, TestHostComponent],
})
.compileComponents();
}));
beforeEach(() => {
// create TestHostComponent instead of DashboardHeroComponent
fixture = TestBed.createComponent(TestHostComponent);
testHost = fixture.componentInstance;
heroEl = fixture.nativeElement.querySelector('.hero');
fixture.detectChanges(); // trigger initial data binding
});
it('should display hero name', () => {
const expectedPipedName = testHost.hero.name.toUpperCase();
expect(heroEl.textContent).toContain(expectedPipedName);
});
it('should raise selected event when clicked', () => {
click(heroEl);
// selected hero should be the same data bound hero
expect(testHost.selectedHero).toBe(testHost.hero);
});
});
////// Test Host Component //////
import {Component} from '@angular/core';
@Component({
standalone: true,
imports: [DashboardHeroComponent],
template: ` <dashboard-hero [hero]="hero" (selected)="onSelected($event)"> </dashboard-hero>`,
})
class TestHostComponent {
hero: Hero = {id: 42, name: 'Test Name'};
selectedHero: Hero | undefined;
onSelected(hero: Hero) {
this.selectedHero = hero;
}
}

The component's selected property returns an EventEmitter, which looks like an RxJS synchronous Observable to consumers. The test subscribes to it explicitly just as the host component does implicitly.

If the component behaves as expected, clicking the hero's element should tell the component's selected property to emit the hero object.

The test detects that event through its subscription to selected.

triggerEventHandler

The heroDe in the previous test is a DebugElement that represents the hero <div>.

It has Angular properties and methods that abstract interaction with the native element. This test calls the DebugElement.triggerEventHandler with the "click" event name. The "click" event binding responds by calling DashboardHeroComponent.click().

The Angular DebugElement.triggerEventHandler can raise any data-bound event by its event name. The second parameter is the event object passed to the handler.

The test triggered a "click" event.

      
import {DebugElement} from '@angular/core';
import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';
import {By} from '@angular/platform-browser';
import {first} from 'rxjs/operators';
import {addMatchers, click} from '../../testing';
import {appProviders} from '../app.config';
import {Hero} from '../model/hero';
import {DashboardHeroComponent} from './dashboard-hero.component';
beforeEach(addMatchers);
describe('DashboardHeroComponent when tested directly', () => {
let comp: DashboardHeroComponent;
let expectedHero: Hero;
let fixture: ComponentFixture<DashboardHeroComponent>;
let heroDe: DebugElement;
let heroEl: HTMLElement;
beforeEach(() => {
TestBed.configureTestingModule({
providers: appProviders,
});
});
beforeEach(async () => {
fixture = TestBed.createComponent(DashboardHeroComponent);
fixture.autoDetectChanges();
comp = fixture.componentInstance;
// find the hero's DebugElement and element
heroDe = fixture.debugElement.query(By.css('.hero'));
heroEl = heroDe.nativeElement;
// mock the hero supplied by the parent component
expectedHero = {id: 42, name: 'Test Name'};
// simulate the parent setting the input property with that hero
fixture.componentRef.setInput('hero', expectedHero);
// wait for initial data binding
await fixture.whenStable();
});
it('should display hero name in uppercase', () => {
const expectedPipedName = expectedHero.name.toUpperCase();
expect(heroEl.textContent).toContain(expectedPipedName);
});
it('should raise selected event when clicked (triggerEventHandler)', () => {
let selectedHero: Hero | undefined;
comp.selected.subscribe((hero: Hero) => (selectedHero = hero));
heroDe.triggerEventHandler('click');
expect(selectedHero).toBe(expectedHero);
});
it('should raise selected event when clicked (element.click)', () => {
let selectedHero: Hero | undefined;
comp.selected.subscribe((hero: Hero) => (selectedHero = hero));
heroEl.click();
expect(selectedHero).toBe(expectedHero);
});
it('should raise selected event when clicked (click helper with DebugElement)', () => {
let selectedHero: Hero | undefined;
comp.selected.subscribe((hero: Hero) => (selectedHero = hero));
click(heroDe); // click helper with DebugElement
expect(selectedHero).toBe(expectedHero);
});
it('should raise selected event when clicked (click helper with native element)', () => {
let selectedHero: Hero | undefined;
comp.selected.subscribe((hero: Hero) => (selectedHero = hero));
click(heroEl); // click helper with native element
expect(selectedHero).toBe(expectedHero);
});
});
//////////////////
describe('DashboardHeroComponent when inside a test host', () => {
let testHost: TestHostComponent;
let fixture: ComponentFixture<TestHostComponent>;
let heroEl: HTMLElement;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
providers: appProviders,
imports: [DashboardHeroComponent, TestHostComponent],
})
.compileComponents();
}));
beforeEach(() => {
// create TestHostComponent instead of DashboardHeroComponent
fixture = TestBed.createComponent(TestHostComponent);
testHost = fixture.componentInstance;
heroEl = fixture.nativeElement.querySelector('.hero');
fixture.detectChanges(); // trigger initial data binding
});
it('should display hero name', () => {
const expectedPipedName = testHost.hero.name.toUpperCase();
expect(heroEl.textContent).toContain(expectedPipedName);
});
it('should raise selected event when clicked', () => {
click(heroEl);
// selected hero should be the same data bound hero
expect(testHost.selectedHero).toBe(testHost.hero);
});
});
////// Test Host Component //////
import {Component} from '@angular/core';
@Component({
standalone: true,
imports: [DashboardHeroComponent],
template: ` <dashboard-hero [hero]="hero" (selected)="onSelected($event)"> </dashboard-hero>`,
})
class TestHostComponent {
hero: Hero = {id: 42, name: 'Test Name'};
selectedHero: Hero | undefined;
onSelected(hero: Hero) {
this.selectedHero = hero;
}
}

In this case, the test correctly assumes that the runtime event handler, the component's click() method, doesn't care about the event object.

HELPFUL: Other handlers are less forgiving. For example, the RouterLink directive expects an object with a button property that identifies which mouse button, if any, was pressed during the click. The RouterLink directive throws an error if the event object is missing.

Click the element

The following test alternative calls the native element's own click() method, which is perfectly fine for this component.

      
import {DebugElement} from '@angular/core';
import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';
import {By} from '@angular/platform-browser';
import {first} from 'rxjs/operators';
import {addMatchers, click} from '../../testing';
import {appProviders} from '../app.config';
import {Hero} from '../model/hero';
import {DashboardHeroComponent} from './dashboard-hero.component';
beforeEach(addMatchers);
describe('DashboardHeroComponent when tested directly', () => {
let comp: DashboardHeroComponent;
let expectedHero: Hero;
let fixture: ComponentFixture<DashboardHeroComponent>;
let heroDe: DebugElement;
let heroEl: HTMLElement;
beforeEach(() => {
TestBed.configureTestingModule({
providers: appProviders,
});
});
beforeEach(async () => {
fixture = TestBed.createComponent(DashboardHeroComponent);
fixture.autoDetectChanges();
comp = fixture.componentInstance;
// find the hero's DebugElement and element
heroDe = fixture.debugElement.query(By.css('.hero'));
heroEl = heroDe.nativeElement;
// mock the hero supplied by the parent component
expectedHero = {id: 42, name: 'Test Name'};
// simulate the parent setting the input property with that hero
fixture.componentRef.setInput('hero', expectedHero);
// wait for initial data binding
await fixture.whenStable();
});
it('should display hero name in uppercase', () => {
const expectedPipedName = expectedHero.name.toUpperCase();
expect(heroEl.textContent).toContain(expectedPipedName);
});
it('should raise selected event when clicked (triggerEventHandler)', () => {
let selectedHero: Hero | undefined;
comp.selected.subscribe((hero: Hero) => (selectedHero = hero));
heroDe.triggerEventHandler('click');
expect(selectedHero).toBe(expectedHero);
});
it('should raise selected event when clicked (element.click)', () => {
let selectedHero: Hero | undefined;
comp.selected.subscribe((hero: Hero) => (selectedHero = hero));
heroEl.click();
expect(selectedHero).toBe(expectedHero);
});
it('should raise selected event when clicked (click helper with DebugElement)', () => {
let selectedHero: Hero | undefined;
comp.selected.subscribe((hero: Hero) => (selectedHero = hero));
click(heroDe); // click helper with DebugElement
expect(selectedHero).toBe(expectedHero);
});
it('should raise selected event when clicked (click helper with native element)', () => {
let selectedHero: Hero | undefined;
comp.selected.subscribe((hero: Hero) => (selectedHero = hero));
click(heroEl); // click helper with native element
expect(selectedHero).toBe(expectedHero);
});
});
//////////////////
describe('DashboardHeroComponent when inside a test host', () => {
let testHost: TestHostComponent;
let fixture: ComponentFixture<TestHostComponent>;
let heroEl: HTMLElement;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
providers: appProviders,
imports: [DashboardHeroComponent, TestHostComponent],
})
.compileComponents();
}));
beforeEach(() => {
// create TestHostComponent instead of DashboardHeroComponent
fixture = TestBed.createComponent(TestHostComponent);
testHost = fixture.componentInstance;
heroEl = fixture.nativeElement.querySelector('.hero');
fixture.detectChanges(); // trigger initial data binding
});
it('should display hero name', () => {
const expectedPipedName = testHost.hero.name.toUpperCase();
expect(heroEl.textContent).toContain(expectedPipedName);
});
it('should raise selected event when clicked', () => {
click(heroEl);
// selected hero should be the same data bound hero
expect(testHost.selectedHero).toBe(testHost.hero);
});
});
////// Test Host Component //////
import {Component} from '@angular/core';
@Component({
standalone: true,
imports: [DashboardHeroComponent],
template: ` <dashboard-hero [hero]="hero" (selected)="onSelected($event)"> </dashboard-hero>`,
})
class TestHostComponent {
hero: Hero = {id: 42, name: 'Test Name'};
selectedHero: Hero | undefined;
onSelected(hero: Hero) {
this.selectedHero = hero;
}
}

click() helper

Clicking a button, an anchor, or an arbitrary HTML element is a common test task.

Make that consistent and straightforward by encapsulating the click-triggering process in a helper such as the following click() function:

testing/index.ts (click helper)

      
import {DebugElement} from '@angular/core';
import {ComponentFixture, tick} from '@angular/core/testing';
export * from './async-observable-helpers';
export * from './jasmine-matchers';
///// Short utilities /////
/** Wait a tick, then detect changes */
export function advance(f: ComponentFixture<any>): void {
tick();
f.detectChanges();
}
// See https://developer.mozilla.org/docs/Web/API/MouseEvent/button
/** Button events to pass to `DebugElement.triggerEventHandler` for RouterLink event handler */
export const ButtonClickEvents = {
left: {button: 0},
right: {button: 2},
};
/** Simulate element click. Defaults to mouse left-button click event. */
export function click(
el: DebugElement | HTMLElement,
eventObj: any = ButtonClickEvents.left,
): void {
if (el instanceof HTMLElement) {
el.click();
} else {
el.triggerEventHandler('click', eventObj);
}
}

The first parameter is the element-to-click. If you want, pass a custom event object as the second parameter. The default is a partial left-button mouse event object accepted by many handlers including the RouterLink directive.

IMPORTANT: The click() helper function is not one of the Angular testing utilities. It's a function defined in this guide's sample code. All of the sample tests use it. If you like it, add it to your own collection of helpers.

Here's the previous test, rewritten using the click helper.

app/dashboard/dashboard-hero.component.spec.ts (test with click helper)

      
import {DebugElement} from '@angular/core';
import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';
import {By} from '@angular/platform-browser';
import {first} from 'rxjs/operators';
import {addMatchers, click} from '../../testing';
import {appProviders} from '../app.config';
import {Hero} from '../model/hero';
import {DashboardHeroComponent} from './dashboard-hero.component';
beforeEach(addMatchers);
describe('DashboardHeroComponent when tested directly', () => {
let comp: DashboardHeroComponent;
let expectedHero: Hero;
let fixture: ComponentFixture<DashboardHeroComponent>;
let heroDe: DebugElement;
let heroEl: HTMLElement;
beforeEach(() => {
TestBed.configureTestingModule({
providers: appProviders,
});
});
beforeEach(async () => {
fixture = TestBed.createComponent(DashboardHeroComponent);
fixture.autoDetectChanges();
comp = fixture.componentInstance;
// find the hero's DebugElement and element
heroDe = fixture.debugElement.query(By.css('.hero'));
heroEl = heroDe.nativeElement;
// mock the hero supplied by the parent component
expectedHero = {id: 42, name: 'Test Name'};
// simulate the parent setting the input property with that hero
fixture.componentRef.setInput('hero', expectedHero);
// wait for initial data binding
await fixture.whenStable();
});
it('should display hero name in uppercase', () => {
const expectedPipedName = expectedHero.name.toUpperCase();
expect(heroEl.textContent).toContain(expectedPipedName);
});
it('should raise selected event when clicked (triggerEventHandler)', () => {
let selectedHero: Hero | undefined;
comp.selected.subscribe((hero: Hero) => (selectedHero = hero));
heroDe.triggerEventHandler('click');
expect(selectedHero).toBe(expectedHero);
});
it('should raise selected event when clicked (element.click)', () => {
let selectedHero: Hero | undefined;
comp.selected.subscribe((hero: Hero) => (selectedHero = hero));
heroEl.click();
expect(selectedHero).toBe(expectedHero);
});
it('should raise selected event when clicked (click helper with DebugElement)', () => {
let selectedHero: Hero | undefined;
comp.selected.subscribe((hero: Hero) => (selectedHero = hero));
click(heroDe); // click helper with DebugElement
expect(selectedHero).toBe(expectedHero);
});
it('should raise selected event when clicked (click helper with native element)', () => {
let selectedHero: Hero | undefined;
comp.selected.subscribe((hero: Hero) => (selectedHero = hero));
click(heroEl); // click helper with native element
expect(selectedHero).toBe(expectedHero);
});
});
//////////////////
describe('DashboardHeroComponent when inside a test host', () => {
let testHost: TestHostComponent;
let fixture: ComponentFixture<TestHostComponent>;
let heroEl: HTMLElement;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
providers: appProviders,
imports: [DashboardHeroComponent, TestHostComponent],
})
.compileComponents();
}));
beforeEach(() => {
// create TestHostComponent instead of DashboardHeroComponent
fixture = TestBed.createComponent(TestHostComponent);
testHost = fixture.componentInstance;
heroEl = fixture.nativeElement.querySelector('.hero');
fixture.detectChanges(); // trigger initial data binding
});
it('should display hero name', () => {
const expectedPipedName = testHost.hero.name.toUpperCase();
expect(heroEl.textContent).toContain(expectedPipedName);
});
it('should raise selected event when clicked', () => {
click(heroEl);
// selected hero should be the same data bound hero
expect(testHost.selectedHero).toBe(testHost.hero);
});
});
////// Test Host Component //////
import {Component} from '@angular/core';
@Component({
standalone: true,
imports: [DashboardHeroComponent],
template: ` <dashboard-hero [hero]="hero" (selected)="onSelected($event)"> </dashboard-hero>`,
})
class TestHostComponent {
hero: Hero = {id: 42, name: 'Test Name'};
selectedHero: Hero | undefined;
onSelected(hero: Hero) {
this.selectedHero = hero;
}
}

Component inside a test host

The previous tests played the role of the host DashboardComponent themselves. But does the DashboardHeroComponent work correctly when properly data-bound to a host component?

You could test with the actual DashboardComponent. But doing so could require a lot of setup, especially when its template features an *ngFor repeater, other components, layout HTML, additional bindings, a constructor that injects multiple services, and it starts interacting with those services right away.

Imagine the effort to disable these distractions, just to prove a point that can be made satisfactorily with a test host like this one:

app/dashboard/dashboard-hero.component.spec.ts (test host)

      
import {DebugElement} from '@angular/core';
import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';
import {By} from '@angular/platform-browser';
import {first} from 'rxjs/operators';
import {addMatchers, click} from '../../testing';
import {appProviders} from '../app.config';
import {Hero} from '../model/hero';
import {DashboardHeroComponent} from './dashboard-hero.component';
beforeEach(addMatchers);
describe('DashboardHeroComponent when tested directly', () => {
let comp: DashboardHeroComponent;
let expectedHero: Hero;
let fixture: ComponentFixture<DashboardHeroComponent>;
let heroDe: DebugElement;
let heroEl: HTMLElement;
beforeEach(() => {
TestBed.configureTestingModule({
providers: appProviders,
});
});
beforeEach(async () => {
fixture = TestBed.createComponent(DashboardHeroComponent);
fixture.autoDetectChanges();
comp = fixture.componentInstance;
// find the hero's DebugElement and element
heroDe = fixture.debugElement.query(By.css('.hero'));
heroEl = heroDe.nativeElement;
// mock the hero supplied by the parent component
expectedHero = {id: 42, name: 'Test Name'};
// simulate the parent setting the input property with that hero
fixture.componentRef.setInput('hero', expectedHero);
// wait for initial data binding
await fixture.whenStable();
});
it('should display hero name in uppercase', () => {
const expectedPipedName = expectedHero.name.toUpperCase();
expect(heroEl.textContent).toContain(expectedPipedName);
});
it('should raise selected event when clicked (triggerEventHandler)', () => {
let selectedHero: Hero | undefined;
comp.selected.subscribe((hero: Hero) => (selectedHero = hero));
heroDe.triggerEventHandler('click');
expect(selectedHero).toBe(expectedHero);
});
it('should raise selected event when clicked (element.click)', () => {
let selectedHero: Hero | undefined;
comp.selected.subscribe((hero: Hero) => (selectedHero = hero));
heroEl.click();
expect(selectedHero).toBe(expectedHero);
});
it('should raise selected event when clicked (click helper with DebugElement)', () => {
let selectedHero: Hero | undefined;
comp.selected.subscribe((hero: Hero) => (selectedHero = hero));
click(heroDe); // click helper with DebugElement
expect(selectedHero).toBe(expectedHero);
});
it('should raise selected event when clicked (click helper with native element)', () => {
let selectedHero: Hero | undefined;
comp.selected.subscribe((hero: Hero) => (selectedHero = hero));
click(heroEl); // click helper with native element
expect(selectedHero).toBe(expectedHero);
});
});
//////////////////
describe('DashboardHeroComponent when inside a test host', () => {
let testHost: TestHostComponent;
let fixture: ComponentFixture<TestHostComponent>;
let heroEl: HTMLElement;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
providers: appProviders,
imports: [DashboardHeroComponent, TestHostComponent],
})
.compileComponents();
}));
beforeEach(() => {
// create TestHostComponent instead of DashboardHeroComponent
fixture = TestBed.createComponent(TestHostComponent);
testHost = fixture.componentInstance;
heroEl = fixture.nativeElement.querySelector('.hero');
fixture.detectChanges(); // trigger initial data binding
});
it('should display hero name', () => {
const expectedPipedName = testHost.hero.name.toUpperCase();
expect(heroEl.textContent).toContain(expectedPipedName);
});
it('should raise selected event when clicked', () => {
click(heroEl);
// selected hero should be the same data bound hero
expect(testHost.selectedHero).toBe(testHost.hero);
});
});
////// Test Host Component //////
import {Component} from '@angular/core';
@Component({
standalone: true,
imports: [DashboardHeroComponent],
template: ` <dashboard-hero [hero]="hero" (selected)="onSelected($event)"> </dashboard-hero>`,
})
class TestHostComponent {
hero: Hero = {id: 42, name: 'Test Name'};
selectedHero: Hero | undefined;
onSelected(hero: Hero) {
this.selectedHero = hero;
}
}

This test host binds to DashboardHeroComponent as the DashboardComponent would but without the noise of the Router, the HeroService, or the *ngFor repeater.

The test host sets the component's hero input property with its test hero. It binds the component's selected event with its onSelected handler, which records the emitted hero in its selectedHero property.

Later, the tests will be able to check selectedHero to verify that the DashboardHeroComponent.selected event emitted the expected hero.

The setup for the test-host tests is similar to the setup for the stand-alone tests:

app/dashboard/dashboard-hero.component.spec.ts (test host setup)

      
import {DebugElement} from '@angular/core';
import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';
import {By} from '@angular/platform-browser';
import {first} from 'rxjs/operators';
import {addMatchers, click} from '../../testing';
import {appProviders} from '../app.config';
import {Hero} from '../model/hero';
import {DashboardHeroComponent} from './dashboard-hero.component';
beforeEach(addMatchers);
describe('DashboardHeroComponent when tested directly', () => {
let comp: DashboardHeroComponent;
let expectedHero: Hero;
let fixture: ComponentFixture<DashboardHeroComponent>;
let heroDe: DebugElement;
let heroEl: HTMLElement;
beforeEach(() => {
TestBed.configureTestingModule({
providers: appProviders,
});
});
beforeEach(async () => {
fixture = TestBed.createComponent(DashboardHeroComponent);
fixture.autoDetectChanges();
comp = fixture.componentInstance;
// find the hero's DebugElement and element
heroDe = fixture.debugElement.query(By.css('.hero'));
heroEl = heroDe.nativeElement;
// mock the hero supplied by the parent component
expectedHero = {id: 42, name: 'Test Name'};
// simulate the parent setting the input property with that hero
fixture.componentRef.setInput('hero', expectedHero);
// wait for initial data binding
await fixture.whenStable();
});
it('should display hero name in uppercase', () => {
const expectedPipedName = expectedHero.name.toUpperCase();
expect(heroEl.textContent).toContain(expectedPipedName);
});
it('should raise selected event when clicked (triggerEventHandler)', () => {
let selectedHero: Hero | undefined;
comp.selected.subscribe((hero: Hero) => (selectedHero = hero));
heroDe.triggerEventHandler('click');
expect(selectedHero).toBe(expectedHero);
});
it('should raise selected event when clicked (element.click)', () => {
let selectedHero: Hero | undefined;
comp.selected.subscribe((hero: Hero) => (selectedHero = hero));
heroEl.click();
expect(selectedHero).toBe(expectedHero);
});
it('should raise selected event when clicked (click helper with DebugElement)', () => {
let selectedHero: Hero | undefined;
comp.selected.subscribe((hero: Hero) => (selectedHero = hero));
click(heroDe); // click helper with DebugElement
expect(selectedHero).toBe(expectedHero);
});
it('should raise selected event when clicked (click helper with native element)', () => {
let selectedHero: Hero | undefined;
comp.selected.subscribe((hero: Hero) => (selectedHero = hero));
click(heroEl); // click helper with native element
expect(selectedHero).toBe(expectedHero);
});
});
//////////////////
describe('DashboardHeroComponent when inside a test host', () => {
let testHost: TestHostComponent;
let fixture: ComponentFixture<TestHostComponent>;
let heroEl: HTMLElement;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
providers: appProviders,
imports: [DashboardHeroComponent, TestHostComponent],
})
.compileComponents();
}));
beforeEach(() => {
// create TestHostComponent instead of DashboardHeroComponent
fixture = TestBed.createComponent(TestHostComponent);
testHost = fixture.componentInstance;
heroEl = fixture.nativeElement.querySelector('.hero');
fixture.detectChanges(); // trigger initial data binding
});
it('should display hero name', () => {
const expectedPipedName = testHost.hero.name.toUpperCase();
expect(heroEl.textContent).toContain(expectedPipedName);
});
it('should raise selected event when clicked', () => {
click(heroEl);
// selected hero should be the same data bound hero
expect(testHost.selectedHero).toBe(testHost.hero);
});
});
////// Test Host Component //////
import {Component} from '@angular/core';
@Component({
standalone: true,
imports: [DashboardHeroComponent],
template: ` <dashboard-hero [hero]="hero" (selected)="onSelected($event)"> </dashboard-hero>`,
})
class TestHostComponent {
hero: Hero = {id: 42, name: 'Test Name'};
selectedHero: Hero | undefined;
onSelected(hero: Hero) {
this.selectedHero = hero;
}
}

This testing module configuration shows three important differences:

  • It declares both the DashboardHeroComponent and the TestHostComponent
  • It creates the TestHostComponent instead of the DashboardHeroComponent
  • The TestHostComponent sets the DashboardHeroComponent.hero with a binding

The createComponent returns a fixture that holds an instance of TestHostComponent instead of an instance of DashboardHeroComponent.

Creating the TestHostComponent has the side effect of creating a DashboardHeroComponent because the latter appears within the template of the former. The query for the hero element (heroEl) still finds it in the test DOM, albeit at greater depth in the element tree than before.

The tests themselves are almost identical to the stand-alone version:

app/dashboard/dashboard-hero.component.spec.ts (test-host)

      
import {DebugElement} from '@angular/core';
import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';
import {By} from '@angular/platform-browser';
import {first} from 'rxjs/operators';
import {addMatchers, click} from '../../testing';
import {appProviders} from '../app.config';
import {Hero} from '../model/hero';
import {DashboardHeroComponent} from './dashboard-hero.component';
beforeEach(addMatchers);
describe('DashboardHeroComponent when tested directly', () => {
let comp: DashboardHeroComponent;
let expectedHero: Hero;
let fixture: ComponentFixture<DashboardHeroComponent>;
let heroDe: DebugElement;
let heroEl: HTMLElement;
beforeEach(() => {
TestBed.configureTestingModule({
providers: appProviders,
});
});
beforeEach(async () => {
fixture = TestBed.createComponent(DashboardHeroComponent);
fixture.autoDetectChanges();
comp = fixture.componentInstance;
// find the hero's DebugElement and element
heroDe = fixture.debugElement.query(By.css('.hero'));
heroEl = heroDe.nativeElement;
// mock the hero supplied by the parent component
expectedHero = {id: 42, name: 'Test Name'};
// simulate the parent setting the input property with that hero
fixture.componentRef.setInput('hero', expectedHero);
// wait for initial data binding
await fixture.whenStable();
});
it('should display hero name in uppercase', () => {
const expectedPipedName = expectedHero.name.toUpperCase();
expect(heroEl.textContent).toContain(expectedPipedName);
});
it('should raise selected event when clicked (triggerEventHandler)', () => {
let selectedHero: Hero | undefined;
comp.selected.subscribe((hero: Hero) => (selectedHero = hero));
heroDe.triggerEventHandler('click');
expect(selectedHero).toBe(expectedHero);
});
it('should raise selected event when clicked (element.click)', () => {
let selectedHero: Hero | undefined;
comp.selected.subscribe((hero: Hero) => (selectedHero = hero));
heroEl.click();
expect(selectedHero).toBe(expectedHero);
});
it('should raise selected event when clicked (click helper with DebugElement)', () => {
let selectedHero: Hero | undefined;
comp.selected.subscribe((hero: Hero) => (selectedHero = hero));
click(heroDe); // click helper with DebugElement
expect(selectedHero).toBe(expectedHero);
});
it('should raise selected event when clicked (click helper with native element)', () => {
let selectedHero: Hero | undefined;
comp.selected.subscribe((hero: Hero) => (selectedHero = hero));
click(heroEl); // click helper with native element
expect(selectedHero).toBe(expectedHero);
});
});
//////////////////
describe('DashboardHeroComponent when inside a test host', () => {
let testHost: TestHostComponent;
let fixture: ComponentFixture<TestHostComponent>;
let heroEl: HTMLElement;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
providers: appProviders,
imports: [DashboardHeroComponent, TestHostComponent],
})
.compileComponents();
}));
beforeEach(() => {
// create TestHostComponent instead of DashboardHeroComponent
fixture = TestBed.createComponent(TestHostComponent);
testHost = fixture.componentInstance;
heroEl = fixture.nativeElement.querySelector('.hero');
fixture.detectChanges(); // trigger initial data binding
});
it('should display hero name', () => {
const expectedPipedName = testHost.hero.name.toUpperCase();
expect(heroEl.textContent).toContain(expectedPipedName);
});
it('should raise selected event when clicked', () => {
click(heroEl);
// selected hero should be the same data bound hero
expect(testHost.selectedHero).toBe(testHost.hero);
});
});
////// Test Host Component //////
import {Component} from '@angular/core';
@Component({
standalone: true,
imports: [DashboardHeroComponent],
template: ` <dashboard-hero [hero]="hero" (selected)="onSelected($event)"> </dashboard-hero>`,
})
class TestHostComponent {
hero: Hero = {id: 42, name: 'Test Name'};
selectedHero: Hero | undefined;
onSelected(hero: Hero) {
this.selectedHero = hero;
}
}

Only the selected event test differs. It confirms that the selected DashboardHeroComponent hero really does find its way up through the event binding to the host component.

Routing component

A routing component is a component that tells the Router to navigate to another component. The DashboardComponent is a routing component because the user can navigate to the HeroDetailComponent by clicking on one of the hero buttons on the dashboard.

Routing is pretty complicated. Testing the DashboardComponent seemed daunting in part because it involves the Router, which it injects together with the HeroService.

app/dashboard/dashboard.component.ts (constructor)

      
import {Component, OnInit} from '@angular/core';
import {Router} from '@angular/router';
import {Hero} from '../model/hero';
import {HeroService} from '../model/hero.service';
import {sharedImports} from '../shared/shared';
import {DashboardHeroComponent} from './dashboard-hero.component';
@Component({
standalone: true,
selector: 'app-dashboard',
templateUrl: './dashboard.component.html',
styleUrls: ['./dashboard.component.css'],
imports: [DashboardHeroComponent, sharedImports],
})
export class DashboardComponent implements OnInit {
heroes: Hero[] = [];
constructor(
private router: Router,
private heroService: HeroService,
) {}
ngOnInit() {
this.heroService.getHeroes().subscribe((heroes) => (this.heroes = heroes.slice(1, 5)));
}
gotoDetail(hero: Hero) {
const url = `/heroes/${hero.id}`;
this.router.navigateByUrl(url);
}
get title() {
const cnt = this.heroes.length;
return cnt === 0 ? 'No Heroes' : cnt === 1 ? 'Top Hero' : `Top ${cnt} Heroes`;
}
}

app/dashboard/dashboard.component.ts (goToDetail)

      
import {Component, OnInit} from '@angular/core';
import {Router} from '@angular/router';
import {Hero} from '../model/hero';
import {HeroService} from '../model/hero.service';
import {sharedImports} from '../shared/shared';
import {DashboardHeroComponent} from './dashboard-hero.component';
@Component({
standalone: true,
selector: 'app-dashboard',
templateUrl: './dashboard.component.html',
styleUrls: ['./dashboard.component.css'],
imports: [DashboardHeroComponent, sharedImports],
})
export class DashboardComponent implements OnInit {
heroes: Hero[] = [];
constructor(
private router: Router,
private heroService: HeroService,
) {}
ngOnInit() {
this.heroService.getHeroes().subscribe((heroes) => (this.heroes = heroes.slice(1, 5)));
}
gotoDetail(hero: Hero) {
const url = `/heroes/${hero.id}`;
this.router.navigateByUrl(url);
}
get title() {
const cnt = this.heroes.length;
return cnt === 0 ? 'No Heroes' : cnt === 1 ? 'Top Hero' : `Top ${cnt} Heroes`;
}
}

Angular provides test helpers to reduce boilerplate and more effectively test code which depends on the Router and HttpClient.

app/dashboard/dashboard.component.spec.ts

      
import {provideHttpClient} from '@angular/common/http';
import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing';
import {NO_ERRORS_SCHEMA} from '@angular/core';
import {TestBed, waitForAsync} from '@angular/core/testing';
import {By} from '@angular/platform-browser';
import {NavigationEnd, provideRouter, Router} from '@angular/router';
import {RouterTestingHarness} from '@angular/router/testing';
import {firstValueFrom} from 'rxjs';
import {filter} from 'rxjs/operators';
import {addMatchers, click} from '../../testing';
import {HeroService} from '../model/hero.service';
import {getTestHeroes} from '../model/testing/test-heroes';
import {DashboardComponent} from './dashboard.component';
import {appConfig} from '../app.config';
import {HeroDetailComponent} from '../hero/hero-detail.component';
beforeEach(addMatchers);
let comp: DashboardComponent;
let harness: RouterTestingHarness;
//////// Deep ////////////////
describe('DashboardComponent (deep)', () => {
compileAndCreate();
tests(clickForDeep);
function clickForDeep() {
// get first <div class="hero">
const heroEl: HTMLElement = harness.routeNativeElement!.querySelector('.hero')!;
click(heroEl);
return firstValueFrom(
TestBed.inject(Router).events.pipe(filter((e) => e instanceof NavigationEnd)),
);
}
});
//////// Shallow ////////////////
describe('DashboardComponent (shallow)', () => {
beforeEach(() => {
TestBed.configureTestingModule(
Object.assign({}, appConfig, {
imports: [DashboardComponent, HeroDetailComponent],
providers: [provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}])],
schemas: [NO_ERRORS_SCHEMA],
}),
);
});
compileAndCreate();
tests(clickForShallow);
function clickForShallow() {
// get first <dashboard-hero> DebugElement
const heroDe = harness.routeDebugElement!.query(By.css('dashboard-hero'));
heroDe.triggerEventHandler('selected', comp.heroes[0]);
return Promise.resolve();
}
});
/** Add TestBed providers, compile, and create DashboardComponent */
function compileAndCreate() {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule(
Object.assign({}, appConfig, {
imports: [DashboardComponent],
providers: [
provideRouter([{path: '**', component: DashboardComponent}]),
provideHttpClient(),
provideHttpClientTesting(),
HeroService,
],
}),
)
.compileComponents()
.then(async () => {
harness = await RouterTestingHarness.create();
comp = await harness.navigateByUrl('/', DashboardComponent);
TestBed.inject(HttpTestingController).expectOne('api/heroes').flush(getTestHeroes());
});
}));
}
/**
* The (almost) same tests for both.
* Only change: the way that the first hero is clicked
*/
function tests(heroClick: () => Promise<unknown>) {
describe('after get dashboard heroes', () => {
let router: Router;
// Trigger component so it gets heroes and binds to them
beforeEach(waitForAsync(() => {
router = TestBed.inject(Router);
harness.detectChanges(); // runs ngOnInit -> getHeroes
}));
it('should HAVE heroes', () => {
expect(comp.heroes.length)
.withContext('should have heroes after service promise resolves')
.toBeGreaterThan(0);
});
it('should DISPLAY heroes', () => {
// Find and examine the displayed heroes
// Look for them in the DOM by css class
const heroes = harness.routeNativeElement!.querySelectorAll('dashboard-hero');
expect(heroes.length).withContext('should display 4 heroes').toBe(4);
});
it('should tell navigate when hero clicked', async () => {
await heroClick(); // trigger click on first inner <div class="hero">
// expecting to navigate to id of the component's first hero
const id = comp.heroes[0].id;
expect(TestBed.inject(Router).url)
.withContext('should nav to HeroDetail for first hero')
.toEqual(`/heroes/${id}`);
});
});
}

The following test clicks the displayed hero and confirms that we navigate to the expected URL.

app/dashboard/dashboard.component.spec.ts (navigate test)

      
import {provideHttpClient} from '@angular/common/http';
import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing';
import {NO_ERRORS_SCHEMA} from '@angular/core';
import {TestBed, waitForAsync} from '@angular/core/testing';
import {By} from '@angular/platform-browser';
import {NavigationEnd, provideRouter, Router} from '@angular/router';
import {RouterTestingHarness} from '@angular/router/testing';
import {firstValueFrom} from 'rxjs';
import {filter} from 'rxjs/operators';
import {addMatchers, click} from '../../testing';
import {HeroService} from '../model/hero.service';
import {getTestHeroes} from '../model/testing/test-heroes';
import {DashboardComponent} from './dashboard.component';
import {appConfig} from '../app.config';
import {HeroDetailComponent} from '../hero/hero-detail.component';
beforeEach(addMatchers);
let comp: DashboardComponent;
let harness: RouterTestingHarness;
//////// Deep ////////////////
describe('DashboardComponent (deep)', () => {
compileAndCreate();
tests(clickForDeep);
function clickForDeep() {
// get first <div class="hero">
const heroEl: HTMLElement = harness.routeNativeElement!.querySelector('.hero')!;
click(heroEl);
return firstValueFrom(
TestBed.inject(Router).events.pipe(filter((e) => e instanceof NavigationEnd)),
);
}
});
//////// Shallow ////////////////
describe('DashboardComponent (shallow)', () => {
beforeEach(() => {
TestBed.configureTestingModule(
Object.assign({}, appConfig, {
imports: [DashboardComponent, HeroDetailComponent],
providers: [provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}])],
schemas: [NO_ERRORS_SCHEMA],
}),
);
});
compileAndCreate();
tests(clickForShallow);
function clickForShallow() {
// get first <dashboard-hero> DebugElement
const heroDe = harness.routeDebugElement!.query(By.css('dashboard-hero'));
heroDe.triggerEventHandler('selected', comp.heroes[0]);
return Promise.resolve();
}
});
/** Add TestBed providers, compile, and create DashboardComponent */
function compileAndCreate() {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule(
Object.assign({}, appConfig, {
imports: [DashboardComponent],
providers: [
provideRouter([{path: '**', component: DashboardComponent}]),
provideHttpClient(),
provideHttpClientTesting(),
HeroService,
],
}),
)
.compileComponents()
.then(async () => {
harness = await RouterTestingHarness.create();
comp = await harness.navigateByUrl('/', DashboardComponent);
TestBed.inject(HttpTestingController).expectOne('api/heroes').flush(getTestHeroes());
});
}));
}
/**
* The (almost) same tests for both.
* Only change: the way that the first hero is clicked
*/
function tests(heroClick: () => Promise<unknown>) {
describe('after get dashboard heroes', () => {
let router: Router;
// Trigger component so it gets heroes and binds to them
beforeEach(waitForAsync(() => {
router = TestBed.inject(Router);
harness.detectChanges(); // runs ngOnInit -> getHeroes
}));
it('should HAVE heroes', () => {
expect(comp.heroes.length)
.withContext('should have heroes after service promise resolves')
.toBeGreaterThan(0);
});
it('should DISPLAY heroes', () => {
// Find and examine the displayed heroes
// Look for them in the DOM by css class
const heroes = harness.routeNativeElement!.querySelectorAll('dashboard-hero');
expect(heroes.length).withContext('should display 4 heroes').toBe(4);
});
it('should tell navigate when hero clicked', async () => {
await heroClick(); // trigger click on first inner <div class="hero">
// expecting to navigate to id of the component's first hero
const id = comp.heroes[0].id;
expect(TestBed.inject(Router).url)
.withContext('should nav to HeroDetail for first hero')
.toEqual(`/heroes/${id}`);
});
});
}

Routed components

A routed component is the destination of a Router navigation. It can be trickier to test, especially when the route to the component includes parameters. The HeroDetailComponent is a routed component that is the destination of such a route.

When a user clicks a Dashboard hero, the DashboardComponent tells the Router to navigate to heroes/:id. The :id is a route parameter whose value is the id of the hero to edit.

The Router matches that URL to a route to the HeroDetailComponent. It creates an ActivatedRoute object with the routing information and injects it into a new instance of the HeroDetailComponent.

Here's the HeroDetailComponent constructor:

app/hero/hero-detail.component.ts (constructor)

      
import {Component, OnInit} from '@angular/core';
import {ActivatedRoute, Router, RouterLink} from '@angular/router';
import {Hero} from '../model/hero';
import {sharedImports} from '../shared/shared';
import {HeroDetailService} from './hero-detail.service';
@Component({
standalone: true,
selector: 'app-hero-detail',
templateUrl: './hero-detail.component.html',
styleUrls: ['./hero-detail.component.css'],
providers: [HeroDetailService],
imports: [sharedImports, RouterLink],
})
export class HeroDetailComponent implements OnInit {
constructor(
private heroDetailService: HeroDetailService,
private route: ActivatedRoute,
private router: Router,
) {}
hero!: Hero;
ngOnInit(): void {
// get hero when `id` param changes
this.route.paramMap.subscribe((pmap) => this.getHero(pmap.get('id')));
}
private getHero(id: string | null): void {
// when no id or id===0, create new blank hero
if (!id) {
this.hero = {id: 0, name: ''} as Hero;
return;
}
this.heroDetailService.getHero(id).subscribe((hero) => {
if (hero) {
this.hero = hero;
} else {
this.gotoList(); // id not found; navigate to list
}
});
}
save(): void {
this.heroDetailService.saveHero(this.hero).subscribe(() => this.gotoList());
}
cancel() {
this.gotoList();
}
gotoList() {
this.router.navigate(['../'], {relativeTo: this.route});
}
}

The HeroDetail component needs the id parameter so it can fetch the corresponding hero using the HeroDetailService. The component has to get the id from the ActivatedRoute.paramMap property which is an Observable.

It can't just reference the id property of the ActivatedRoute.paramMap. The component has to subscribe to the ActivatedRoute.paramMap observable and be prepared for the id to change during its lifetime.

app/hero/hero-detail.component.ts (ngOnInit)

      
import {Component, OnInit} from '@angular/core';
import {ActivatedRoute, Router, RouterLink} from '@angular/router';
import {Hero} from '../model/hero';
import {sharedImports} from '../shared/shared';
import {HeroDetailService} from './hero-detail.service';
@Component({
standalone: true,
selector: 'app-hero-detail',
templateUrl: './hero-detail.component.html',
styleUrls: ['./hero-detail.component.css'],
providers: [HeroDetailService],
imports: [sharedImports, RouterLink],
})
export class HeroDetailComponent implements OnInit {
constructor(
private heroDetailService: HeroDetailService,
private route: ActivatedRoute,
private router: Router,
) {}
hero!: Hero;
ngOnInit(): void {
// get hero when `id` param changes
this.route.paramMap.subscribe((pmap) => this.getHero(pmap.get('id')));
}
private getHero(id: string | null): void {
// when no id or id===0, create new blank hero
if (!id) {
this.hero = {id: 0, name: ''} as Hero;
return;
}
this.heroDetailService.getHero(id).subscribe((hero) => {
if (hero) {
this.hero = hero;
} else {
this.gotoList(); // id not found; navigate to list
}
});
}
save(): void {
this.heroDetailService.saveHero(this.hero).subscribe(() => this.gotoList());
}
cancel() {
this.gotoList();
}
gotoList() {
this.router.navigate(['../'], {relativeTo: this.route});
}
}

Tests can explore how the HeroDetailComponent responds to different id parameter values by navigating to different routes.

Testing with the RouterTestingHarness

Here's a test demonstrating the component's behavior when the observed id refers to an existing hero:

app/hero/hero-detail.component.spec.ts (existing id)

      
import {HttpClient, HttpHandler, provideHttpClient} from '@angular/common/http';
import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing';
import {fakeAsync, TestBed, tick} from '@angular/core/testing';
import {provideRouter, Router} from '@angular/router';
import {RouterTestingHarness} from '@angular/router/testing';
import {asyncData, click} from '../../testing';
import {Hero} from '../model/hero';
import {sharedImports} from '../shared/shared';
import {HeroDetailComponent} from './hero-detail.component';
import {HeroDetailService} from './hero-detail.service';
import {HeroListComponent} from './hero-list.component';
////// Testing Vars //////
let component: HeroDetailComponent;
let harness: RouterTestingHarness;
let page: Page;
////// Tests //////
describe('HeroDetailComponent', () => {
describe('with HeroModule setup', heroModuleSetup);
describe('when override its provided HeroDetailService', overrideSetup);
describe('with FormsModule setup', formsModuleSetup);
describe('with SharedModule setup', sharedModuleSetup);
});
///////////////////
const testHero = getTestHeroes()[0];
function overrideSetup() {
class HeroDetailServiceSpy {
testHero: Hero = {...testHero};
/* emit cloned test hero */
getHero = jasmine
.createSpy('getHero')
.and.callFake(() => asyncData(Object.assign({}, this.testHero)));
/* emit clone of test hero, with changes merged in */
saveHero = jasmine
.createSpy('saveHero')
.and.callFake((hero: Hero) => asyncData(Object.assign(this.testHero, hero)));
}
beforeEach(async () => {
await TestBed.configureTestingModule(
Object.assign({}, appConfig, {
imports: [HeroDetailComponent, HeroListComponent],
providers: [
provideRouter([
{path: 'heroes', component: HeroListComponent},
{path: 'heroes/:id', component: HeroDetailComponent},
]),
HttpClient,
HttpHandler,
// HeroDetailService at this level is IRRELEVANT!
{provide: HeroDetailService, useValue: {}},
],
}),
)
.overrideComponent(HeroDetailComponent, {
set: {providers: [{provide: HeroDetailService, useClass: HeroDetailServiceSpy}]},
})
.compileComponents();
});
let hdsSpy: HeroDetailServiceSpy;
beforeEach(async () => {
harness = await RouterTestingHarness.create();
component = await harness.navigateByUrl(`/heroes/${testHero.id}`, HeroDetailComponent);
page = new Page();
// get the component's injected HeroDetailServiceSpy
hdsSpy = harness.routeDebugElement!.injector.get(HeroDetailService) as any;
harness.detectChanges();
});
it('should have called `getHero`', () => {
expect(hdsSpy.getHero.calls.count())
.withContext('getHero called once')
.toBe(1, 'getHero called once');
});
it("should display stub hero's name", () => {
expect(page.nameDisplay.textContent).toBe(hdsSpy.testHero.name);
});
it('should save stub hero change', fakeAsync(() => {
const origName = hdsSpy.testHero.name;
const newName = 'New Name';
page.nameInput.value = newName;
page.nameInput.dispatchEvent(new Event('input')); // tell Angular
expect(component.hero.name).withContext('component hero has new name').toBe(newName);
expect(hdsSpy.testHero.name).withContext('service hero unchanged before save').toBe(origName);
click(page.saveBtn);
expect(hdsSpy.saveHero.calls.count()).withContext('saveHero called once').toBe(1);
tick(); // wait for async save to complete
expect(hdsSpy.testHero.name).withContext('service hero has new name after save').toBe(newName);
expect(TestBed.inject(Router).url).toEqual('/heroes');
}));
}
////////////////////
import {getTestHeroes} from '../model/testing/test-hero.service';
const firstHero = getTestHeroes()[0];
function heroModuleSetup() {
beforeEach(async () => {
await TestBed.configureTestingModule(
Object.assign({}, appConfig, {
imports: [HeroDetailComponent, HeroListComponent],
providers: [
provideRouter([
{path: 'heroes/:id', component: HeroDetailComponent},
{path: 'heroes', component: HeroListComponent},
]),
provideHttpClient(),
provideHttpClientTesting(),
],
}),
).compileComponents();
});
describe('when navigate to existing hero', () => {
let expectedHero: Hero;
beforeEach(async () => {
expectedHero = firstHero;
await createComponent(expectedHero.id);
});
it("should display that hero's name", () => {
expect(page.nameDisplay.textContent).toBe(expectedHero.name);
});
it('should navigate when click cancel', () => {
click(page.cancelBtn);
expect(TestBed.inject(Router).url).toEqual(`/heroes/${expectedHero.id}`);
});
it('should save when click save but not navigate immediately', () => {
click(page.saveBtn);
expect(TestBed.inject(HttpTestingController).expectOne({method: 'PUT', url: 'api/heroes'}));
expect(TestBed.inject(Router).url).toEqual('/heroes/41');
});
it('should navigate when click save and save resolves', fakeAsync(() => {
click(page.saveBtn);
tick(); // wait for async save to complete
expect(TestBed.inject(Router).url).toEqual('/heroes/41');
}));
it('should convert hero name to Title Case', async () => {
harness.fixture.autoDetectChanges();
// get the name's input and display elements from the DOM
const hostElement: HTMLElement = harness.routeNativeElement!;
const nameInput: HTMLInputElement = hostElement.querySelector('input')!;
const nameDisplay: HTMLElement = hostElement.querySelector('span')!;
// simulate user entering a new name into the input box
nameInput.value = 'quick BROWN fOx';
// Dispatch a DOM event so that Angular learns of input value change.
nameInput.dispatchEvent(new Event('input'));
// Wait for Angular to update the display binding through the title pipe
await harness.fixture.whenStable();
expect(nameDisplay.textContent).toBe('Quick Brown Fox');
});
});
describe('when navigate to non-existent hero id', () => {
beforeEach(async () => {
await createComponent(999);
});
it('should try to navigate back to hero list', () => {
expect(TestBed.inject(Router).url).toEqual('/heroes');
});
});
}
/////////////////////
import {FormsModule} from '@angular/forms';
import {TitleCasePipe} from '../shared/title-case.pipe';
import {appConfig} from '../app.config';
function formsModuleSetup() {
beforeEach(async () => {
await TestBed.configureTestingModule(
Object.assign({}, appConfig, {
imports: [FormsModule, HeroDetailComponent, TitleCasePipe],
providers: [
provideHttpClient(),
provideHttpClientTesting(),
provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]),
],
}),
).compileComponents();
});
it("should display 1st hero's name", async () => {
const expectedHero = firstHero;
await createComponent(expectedHero.id).then(() => {
expect(page.nameDisplay.textContent).toBe(expectedHero.name);
});
});
}
///////////////////////
function sharedModuleSetup() {
beforeEach(async () => {
await TestBed.configureTestingModule(
Object.assign({}, appConfig, {
imports: [HeroDetailComponent, sharedImports],
providers: [
provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]),
provideHttpClient(),
provideHttpClientTesting(),
],
}),
).compileComponents();
});
it("should display 1st hero's name", async () => {
const expectedHero = firstHero;
await createComponent(expectedHero.id).then(() => {
expect(page.nameDisplay.textContent).toBe(expectedHero.name);
});
});
}
/////////// Helpers /////
/** Create the HeroDetailComponent, initialize it, set test variables */
async function createComponent(id: number) {
harness = await RouterTestingHarness.create();
component = await harness.navigateByUrl(`/heroes/${id}`, HeroDetailComponent);
page = new Page();
const request = TestBed.inject(HttpTestingController).expectOne(`api/heroes/?id=${id}`);
const hero = getTestHeroes().find((h) => h.id === Number(id));
request.flush(hero ? [hero] : []);
harness.detectChanges();
}
class Page {
// getter properties wait to query the DOM until called.
get buttons() {
return this.queryAll<HTMLButtonElement>('button');
}
get saveBtn() {
return this.buttons[0];
}
get cancelBtn() {
return this.buttons[1];
}
get nameDisplay() {
return this.query<HTMLElement>('span');
}
get nameInput() {
return this.query<HTMLInputElement>('input');
}
//// query helpers ////
private query<T>(selector: string): T {
return harness.routeNativeElement!.querySelector(selector)! as T;
}
private queryAll<T>(selector: string): T[] {
return harness.routeNativeElement!.querySelectorAll(selector) as any as T[];
}
}

HELPFUL: In the following section, the createComponent() method and page object are discussed. Rely on your intuition for now.

When the id cannot be found, the component should re-route to the HeroListComponent.

The test suite setup provided the same router harness described above.

This test expects the component to try to navigate to the HeroListComponent.

app/hero/hero-detail.component.spec.ts (bad id)

      
import {HttpClient, HttpHandler, provideHttpClient} from '@angular/common/http';
import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing';
import {fakeAsync, TestBed, tick} from '@angular/core/testing';
import {provideRouter, Router} from '@angular/router';
import {RouterTestingHarness} from '@angular/router/testing';
import {asyncData, click} from '../../testing';
import {Hero} from '../model/hero';
import {sharedImports} from '../shared/shared';
import {HeroDetailComponent} from './hero-detail.component';
import {HeroDetailService} from './hero-detail.service';
import {HeroListComponent} from './hero-list.component';
////// Testing Vars //////
let component: HeroDetailComponent;
let harness: RouterTestingHarness;
let page: Page;
////// Tests //////
describe('HeroDetailComponent', () => {
describe('with HeroModule setup', heroModuleSetup);
describe('when override its provided HeroDetailService', overrideSetup);
describe('with FormsModule setup', formsModuleSetup);
describe('with SharedModule setup', sharedModuleSetup);
});
///////////////////
const testHero = getTestHeroes()[0];
function overrideSetup() {
class HeroDetailServiceSpy {
testHero: Hero = {...testHero};
/* emit cloned test hero */
getHero = jasmine
.createSpy('getHero')
.and.callFake(() => asyncData(Object.assign({}, this.testHero)));
/* emit clone of test hero, with changes merged in */
saveHero = jasmine
.createSpy('saveHero')
.and.callFake((hero: Hero) => asyncData(Object.assign(this.testHero, hero)));
}
beforeEach(async () => {
await TestBed.configureTestingModule(
Object.assign({}, appConfig, {
imports: [HeroDetailComponent, HeroListComponent],
providers: [
provideRouter([
{path: 'heroes', component: HeroListComponent},
{path: 'heroes/:id', component: HeroDetailComponent},
]),
HttpClient,
HttpHandler,
// HeroDetailService at this level is IRRELEVANT!
{provide: HeroDetailService, useValue: {}},
],
}),
)
.overrideComponent(HeroDetailComponent, {
set: {providers: [{provide: HeroDetailService, useClass: HeroDetailServiceSpy}]},
})
.compileComponents();
});
let hdsSpy: HeroDetailServiceSpy;
beforeEach(async () => {
harness = await RouterTestingHarness.create();
component = await harness.navigateByUrl(`/heroes/${testHero.id}`, HeroDetailComponent);
page = new Page();
// get the component's injected HeroDetailServiceSpy
hdsSpy = harness.routeDebugElement!.injector.get(HeroDetailService) as any;
harness.detectChanges();
});
it('should have called `getHero`', () => {
expect(hdsSpy.getHero.calls.count())
.withContext('getHero called once')
.toBe(1, 'getHero called once');
});
it("should display stub hero's name", () => {
expect(page.nameDisplay.textContent).toBe(hdsSpy.testHero.name);
});
it('should save stub hero change', fakeAsync(() => {
const origName = hdsSpy.testHero.name;
const newName = 'New Name';
page.nameInput.value = newName;
page.nameInput.dispatchEvent(new Event('input')); // tell Angular
expect(component.hero.name).withContext('component hero has new name').toBe(newName);
expect(hdsSpy.testHero.name).withContext('service hero unchanged before save').toBe(origName);
click(page.saveBtn);
expect(hdsSpy.saveHero.calls.count()).withContext('saveHero called once').toBe(1);
tick(); // wait for async save to complete
expect(hdsSpy.testHero.name).withContext('service hero has new name after save').toBe(newName);
expect(TestBed.inject(Router).url).toEqual('/heroes');
}));
}
////////////////////
import {getTestHeroes} from '../model/testing/test-hero.service';
const firstHero = getTestHeroes()[0];
function heroModuleSetup() {
beforeEach(async () => {
await TestBed.configureTestingModule(
Object.assign({}, appConfig, {
imports: [HeroDetailComponent, HeroListComponent],
providers: [
provideRouter([
{path: 'heroes/:id', component: HeroDetailComponent},
{path: 'heroes', component: HeroListComponent},
]),
provideHttpClient(),
provideHttpClientTesting(),
],
}),
).compileComponents();
});
describe('when navigate to existing hero', () => {
let expectedHero: Hero;
beforeEach(async () => {
expectedHero = firstHero;
await createComponent(expectedHero.id);
});
it("should display that hero's name", () => {
expect(page.nameDisplay.textContent).toBe(expectedHero.name);
});
it('should navigate when click cancel', () => {
click(page.cancelBtn);
expect(TestBed.inject(Router).url).toEqual(`/heroes/${expectedHero.id}`);
});
it('should save when click save but not navigate immediately', () => {
click(page.saveBtn);
expect(TestBed.inject(HttpTestingController).expectOne({method: 'PUT', url: 'api/heroes'}));
expect(TestBed.inject(Router).url).toEqual('/heroes/41');
});
it('should navigate when click save and save resolves', fakeAsync(() => {
click(page.saveBtn);
tick(); // wait for async save to complete
expect(TestBed.inject(Router).url).toEqual('/heroes/41');
}));
it('should convert hero name to Title Case', async () => {
harness.fixture.autoDetectChanges();
// get the name's input and display elements from the DOM
const hostElement: HTMLElement = harness.routeNativeElement!;
const nameInput: HTMLInputElement = hostElement.querySelector('input')!;
const nameDisplay: HTMLElement = hostElement.querySelector('span')!;
// simulate user entering a new name into the input box
nameInput.value = 'quick BROWN fOx';
// Dispatch a DOM event so that Angular learns of input value change.
nameInput.dispatchEvent(new Event('input'));
// Wait for Angular to update the display binding through the title pipe
await harness.fixture.whenStable();
expect(nameDisplay.textContent).toBe('Quick Brown Fox');
});
});
describe('when navigate to non-existent hero id', () => {
beforeEach(async () => {
await createComponent(999);
});
it('should try to navigate back to hero list', () => {
expect(TestBed.inject(Router).url).toEqual('/heroes');
});
});
}
/////////////////////
import {FormsModule} from '@angular/forms';
import {TitleCasePipe} from '../shared/title-case.pipe';
import {appConfig} from '../app.config';
function formsModuleSetup() {
beforeEach(async () => {
await TestBed.configureTestingModule(
Object.assign({}, appConfig, {
imports: [FormsModule, HeroDetailComponent, TitleCasePipe],
providers: [
provideHttpClient(),
provideHttpClientTesting(),
provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]),
],
}),
).compileComponents();
});
it("should display 1st hero's name", async () => {
const expectedHero = firstHero;
await createComponent(expectedHero.id).then(() => {
expect(page.nameDisplay.textContent).toBe(expectedHero.name);
});
});
}
///////////////////////
function sharedModuleSetup() {
beforeEach(async () => {
await TestBed.configureTestingModule(
Object.assign({}, appConfig, {
imports: [HeroDetailComponent, sharedImports],
providers: [
provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]),
provideHttpClient(),
provideHttpClientTesting(),
],
}),
).compileComponents();
});
it("should display 1st hero's name", async () => {
const expectedHero = firstHero;
await createComponent(expectedHero.id).then(() => {
expect(page.nameDisplay.textContent).toBe(expectedHero.name);
});
});
}
/////////// Helpers /////
/** Create the HeroDetailComponent, initialize it, set test variables */
async function createComponent(id: number) {
harness = await RouterTestingHarness.create();
component = await harness.navigateByUrl(`/heroes/${id}`, HeroDetailComponent);
page = new Page();
const request = TestBed.inject(HttpTestingController).expectOne(`api/heroes/?id=${id}`);
const hero = getTestHeroes().find((h) => h.id === Number(id));
request.flush(hero ? [hero] : []);
harness.detectChanges();
}
class Page {
// getter properties wait to query the DOM until called.
get buttons() {
return this.queryAll<HTMLButtonElement>('button');
}
get saveBtn() {
return this.buttons[0];
}
get cancelBtn() {
return this.buttons[1];
}
get nameDisplay() {
return this.query<HTMLElement>('span');
}
get nameInput() {
return this.query<HTMLInputElement>('input');
}
//// query helpers ////
private query<T>(selector: string): T {
return harness.routeNativeElement!.querySelector(selector)! as T;
}
private queryAll<T>(selector: string): T[] {
return harness.routeNativeElement!.querySelectorAll(selector) as any as T[];
}
}

Nested component tests

Component templates often have nested components, whose templates might contain more components.

The component tree can be very deep and, most of the time, the nested components play no role in testing the component at the top of the tree.

The AppComponent, for example, displays a navigation bar with anchors and their RouterLink directives.

app/app.component.html

      
<app-banner></app-banner>
<app-welcome></app-welcome>
<nav>
<a routerLink="/dashboard">Dashboard</a>
<a routerLink="/heroes">Heroes</a>
<a routerLink="/about">About</a>
</nav>
<router-outlet></router-outlet>

To validate the links, you don't need the Router to navigate and you don't need the <router-outlet> to mark where the Router inserts routed components.

The BannerComponent and WelcomeComponent (indicated by <app-banner> and <app-welcome>) are also irrelevant.

Yet any test that creates the AppComponent in the DOM also creates instances of these three components and, if you let that happen, you'll have to configure the TestBed to create them.

If you neglect to declare them, the Angular compiler won't recognize the <app-banner>, <app-welcome>, and <router-outlet> tags in the AppComponent template and will throw an error.

If you declare the real components, you'll also have to declare their nested components and provide for all services injected in any component in the tree.

That's too much effort just to answer a few simple questions about links.

This section describes two techniques for minimizing the setup. Use them, alone or in combination, to stay focused on testing the primary component.

Stubbing unneeded components

In the first technique, you create and declare stub versions of the components and directive that play little or no role in the tests.

app/app.component.spec.ts (stub declaration)

      
import {Component, DebugElement, NO_ERRORS_SCHEMA} from '@angular/core';
import {ComponentFixture, fakeAsync, TestBed, tick, waitForAsync} from '@angular/core/testing';
import {By} from '@angular/platform-browser';
import {provideRouter, Router, RouterLink} from '@angular/router';
import {AppComponent} from './app.component';
import {appConfig} from './app.config';
import {UserService} from './model';
@Component({standalone: true, selector: 'app-banner', template: ''})
class BannerStubComponent {}
@Component({standalone: true, selector: 'router-outlet', template: ''})
class RouterOutletStubComponent {}
@Component({standalone: true, selector: 'app-welcome', template: ''})
class WelcomeStubComponent {}
let comp: AppComponent;
let fixture: ComponentFixture<AppComponent>;
describe('AppComponent & TestModule', () => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule(
Object.assign({}, appConfig, {
imports: [
AppComponent,
BannerStubComponent,
RouterLink,
RouterOutletStubComponent,
WelcomeStubComponent,
],
providers: [provideRouter([]), UserService],
}),
)
.compileComponents()
.then(() => {
fixture = TestBed.createComponent(AppComponent);
comp = fixture.componentInstance;
});
}));
tests();
});
//////// Testing w/ NO_ERRORS_SCHEMA //////
describe('AppComponent & NO_ERRORS_SCHEMA', () => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule(
Object.assign({}, appConfig, {
imports: [
AppComponent,
BannerStubComponent,
RouterLink,
],
providers: [provideRouter([]), UserService],
schemas: [NO_ERRORS_SCHEMA],
}),
)
.compileComponents()
.then(() => {
fixture = TestBed.createComponent(AppComponent);
comp = fixture.componentInstance;
});
}));
tests();
});
function tests() {
let routerLinks: RouterLink[];
let linkDes: DebugElement[];
beforeEach(() => {
fixture.detectChanges(); // trigger initial data binding
// find DebugElements with an attached RouterLinkStubDirective
linkDes = fixture.debugElement.queryAll(By.directive(RouterLink));
// get attached link directive instances
// using each DebugElement's injector
routerLinks = linkDes.map((de) => de.injector.get(RouterLink));
});
it('can instantiate the component', () => {
expect(comp).not.toBeNull();
});
it('can get RouterLinks from template', () => {
expect(routerLinks.length).withContext('should have 3 routerLinks').toBe(3);
expect(routerLinks[0].href).toBe('/dashboard');
expect(routerLinks[1].href).toBe('/heroes');
expect(routerLinks[2].href).toBe('/about');
});
it('can click Heroes link in template', fakeAsync(() => {
const heroesLinkDe = linkDes[1]; // heroes link DebugElement
TestBed.inject(Router).resetConfig([{path: '**', children: []}]);
heroesLinkDe.triggerEventHandler('click', {button: 0});
tick();
fixture.detectChanges();
expect(TestBed.inject(Router).url).toBe('/heroes');
}));
}

The stub selectors match the selectors for the corresponding real components. But their templates and classes are empty.

Then declare them in the TestBed configuration next to the components, directives, and pipes that need to be real.

app/app.component.spec.ts (TestBed stubs)

      
import {Component, DebugElement, NO_ERRORS_SCHEMA} from '@angular/core';
import {ComponentFixture, fakeAsync, TestBed, tick, waitForAsync} from '@angular/core/testing';
import {By} from '@angular/platform-browser';
import {provideRouter, Router, RouterLink} from '@angular/router';
import {AppComponent} from './app.component';
import {appConfig} from './app.config';
import {UserService} from './model';
@Component({standalone: true, selector: 'app-banner', template: ''})
class BannerStubComponent {}
@Component({standalone: true, selector: 'router-outlet', template: ''})
class RouterOutletStubComponent {}
@Component({standalone: true, selector: 'app-welcome', template: ''})
class WelcomeStubComponent {}
let comp: AppComponent;
let fixture: ComponentFixture<AppComponent>;
describe('AppComponent & TestModule', () => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule(
Object.assign({}, appConfig, {
imports: [
AppComponent,
BannerStubComponent,
RouterLink,
RouterOutletStubComponent,
WelcomeStubComponent,
],
providers: [provideRouter([]), UserService],
}),
)
.compileComponents()
.then(() => {
fixture = TestBed.createComponent(AppComponent);
comp = fixture.componentInstance;
});
}));
tests();
});
//////// Testing w/ NO_ERRORS_SCHEMA //////
describe('AppComponent & NO_ERRORS_SCHEMA', () => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule(
Object.assign({}, appConfig, {
imports: [
AppComponent,
BannerStubComponent,
RouterLink,
],
providers: [provideRouter([]), UserService],
schemas: [NO_ERRORS_SCHEMA],
}),
)
.compileComponents()
.then(() => {
fixture = TestBed.createComponent(AppComponent);
comp = fixture.componentInstance;
});
}));
tests();
});
function tests() {
let routerLinks: RouterLink[];
let linkDes: DebugElement[];
beforeEach(() => {
fixture.detectChanges(); // trigger initial data binding
// find DebugElements with an attached RouterLinkStubDirective
linkDes = fixture.debugElement.queryAll(By.directive(RouterLink));
// get attached link directive instances
// using each DebugElement's injector
routerLinks = linkDes.map((de) => de.injector.get(RouterLink));
});
it('can instantiate the component', () => {
expect(comp).not.toBeNull();
});
it('can get RouterLinks from template', () => {
expect(routerLinks.length).withContext('should have 3 routerLinks').toBe(3);
expect(routerLinks[0].href).toBe('/dashboard');
expect(routerLinks[1].href).toBe('/heroes');
expect(routerLinks[2].href).toBe('/about');
});
it('can click Heroes link in template', fakeAsync(() => {
const heroesLinkDe = linkDes[1]; // heroes link DebugElement
TestBed.inject(Router).resetConfig([{path: '**', children: []}]);
heroesLinkDe.triggerEventHandler('click', {button: 0});
tick();
fixture.detectChanges();
expect(TestBed.inject(Router).url).toBe('/heroes');
}));
}

The AppComponent is the test subject, so of course you declare the real version.

The rest are stubs.

NO_ERRORS_SCHEMA

In the second approach, add NO_ERRORS_SCHEMA to the TestBed.schemas metadata.

app/app.component.spec.ts (NO_ERRORS_SCHEMA)

      
import {Component, DebugElement, NO_ERRORS_SCHEMA} from '@angular/core';
import {ComponentFixture, fakeAsync, TestBed, tick, waitForAsync} from '@angular/core/testing';
import {By} from '@angular/platform-browser';
import {provideRouter, Router, RouterLink} from '@angular/router';
import {AppComponent} from './app.component';
import {appConfig} from './app.config';
import {UserService} from './model';
@Component({standalone: true, selector: 'app-banner', template: ''})
class BannerStubComponent {}
@Component({standalone: true, selector: 'router-outlet', template: ''})
class RouterOutletStubComponent {}
@Component({standalone: true, selector: 'app-welcome', template: ''})
class WelcomeStubComponent {}
let comp: AppComponent;
let fixture: ComponentFixture<AppComponent>;
describe('AppComponent & TestModule', () => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule(
Object.assign({}, appConfig, {
imports: [
AppComponent,
BannerStubComponent,
RouterLink,
RouterOutletStubComponent,
WelcomeStubComponent,
],
providers: [provideRouter([]), UserService],
}),
)
.compileComponents()
.then(() => {
fixture = TestBed.createComponent(AppComponent);
comp = fixture.componentInstance;
});
}));
tests();
});
//////// Testing w/ NO_ERRORS_SCHEMA //////
describe('AppComponent & NO_ERRORS_SCHEMA', () => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule(
Object.assign({}, appConfig, {
imports: [
AppComponent,
BannerStubComponent,
RouterLink,
],
providers: [provideRouter([]), UserService],
schemas: [NO_ERRORS_SCHEMA],
}),
)
.compileComponents()
.then(() => {
fixture = TestBed.createComponent(AppComponent);
comp = fixture.componentInstance;
});
}));
tests();
});
function tests() {
let routerLinks: RouterLink[];
let linkDes: DebugElement[];
beforeEach(() => {
fixture.detectChanges(); // trigger initial data binding
// find DebugElements with an attached RouterLinkStubDirective
linkDes = fixture.debugElement.queryAll(By.directive(RouterLink));
// get attached link directive instances
// using each DebugElement's injector
routerLinks = linkDes.map((de) => de.injector.get(RouterLink));
});
it('can instantiate the component', () => {
expect(comp).not.toBeNull();
});
it('can get RouterLinks from template', () => {
expect(routerLinks.length).withContext('should have 3 routerLinks').toBe(3);
expect(routerLinks[0].href).toBe('/dashboard');
expect(routerLinks[1].href).toBe('/heroes');
expect(routerLinks[2].href).toBe('/about');
});
it('can click Heroes link in template', fakeAsync(() => {
const heroesLinkDe = linkDes[1]; // heroes link DebugElement
TestBed.inject(Router).resetConfig([{path: '**', children: []}]);
heroesLinkDe.triggerEventHandler('click', {button: 0});
tick();
fixture.detectChanges();
expect(TestBed.inject(Router).url).toBe('/heroes');
}));
}

The NO_ERRORS_SCHEMA tells the Angular compiler to ignore unrecognized elements and attributes.

The compiler recognizes the <app-root> element and the routerLink attribute because you declared a corresponding AppComponent and RouterLink in the TestBed configuration.

But the compiler won't throw an error when it encounters <app-banner>, <app-welcome>, or <router-outlet>. It simply renders them as empty tags and the browser ignores them.

You no longer need the stub components.

Use both techniques together

These are techniques for Shallow Component Testing, so-named because they reduce the visual surface of the component to just those elements in the component's template that matter for tests.

The NO_ERRORS_SCHEMA approach is the easier of the two but don't overuse it.

The NO_ERRORS_SCHEMA also prevents the compiler from telling you about the missing components and attributes that you omitted inadvertently or misspelled. You could waste hours chasing phantom bugs that the compiler would have caught in an instant.

The stub component approach has another advantage. While the stubs in this example were empty, you could give them stripped-down templates and classes if your tests need to interact with them in some way.

In practice you will combine the two techniques in the same setup, as seen in this example.

app/app.component.spec.ts (mixed setup)

      
import {Component, DebugElement, NO_ERRORS_SCHEMA} from '@angular/core';
import {ComponentFixture, fakeAsync, TestBed, tick, waitForAsync} from '@angular/core/testing';
import {By} from '@angular/platform-browser';
import {provideRouter, Router, RouterLink} from '@angular/router';
import {AppComponent} from './app.component';
import {appConfig} from './app.config';
import {UserService} from './model';
@Component({standalone: true, selector: 'app-banner', template: ''})
class BannerStubComponent {}
@Component({standalone: true, selector: 'router-outlet', template: ''})
class RouterOutletStubComponent {}
@Component({standalone: true, selector: 'app-welcome', template: ''})
class WelcomeStubComponent {}
let comp: AppComponent;
let fixture: ComponentFixture<AppComponent>;
describe('AppComponent & TestModule', () => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule(
Object.assign({}, appConfig, {
imports: [
AppComponent,
BannerStubComponent,
RouterLink,
RouterOutletStubComponent,
WelcomeStubComponent,
],
providers: [provideRouter([]), UserService],
}),
)
.compileComponents()
.then(() => {
fixture = TestBed.createComponent(AppComponent);
comp = fixture.componentInstance;
});
}));
tests();
});
//////// Testing w/ NO_ERRORS_SCHEMA //////
describe('AppComponent & NO_ERRORS_SCHEMA', () => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule(
Object.assign({}, appConfig, {
imports: [
AppComponent,
BannerStubComponent,
RouterLink,
],
providers: [provideRouter([]), UserService],
schemas: [NO_ERRORS_SCHEMA],
}),
)
.compileComponents()
.then(() => {
fixture = TestBed.createComponent(AppComponent);
comp = fixture.componentInstance;
});
}));
tests();
});
function tests() {
let routerLinks: RouterLink[];
let linkDes: DebugElement[];
beforeEach(() => {
fixture.detectChanges(); // trigger initial data binding
// find DebugElements with an attached RouterLinkStubDirective
linkDes = fixture.debugElement.queryAll(By.directive(RouterLink));
// get attached link directive instances
// using each DebugElement's injector
routerLinks = linkDes.map((de) => de.injector.get(RouterLink));
});
it('can instantiate the component', () => {
expect(comp).not.toBeNull();
});
it('can get RouterLinks from template', () => {
expect(routerLinks.length).withContext('should have 3 routerLinks').toBe(3);
expect(routerLinks[0].href).toBe('/dashboard');
expect(routerLinks[1].href).toBe('/heroes');
expect(routerLinks[2].href).toBe('/about');
});
it('can click Heroes link in template', fakeAsync(() => {
const heroesLinkDe = linkDes[1]; // heroes link DebugElement
TestBed.inject(Router).resetConfig([{path: '**', children: []}]);
heroesLinkDe.triggerEventHandler('click', {button: 0});
tick();
fixture.detectChanges();
expect(TestBed.inject(Router).url).toBe('/heroes');
}));
}

The Angular compiler creates the BannerStubComponent for the <app-banner> element and applies the RouterLink to the anchors with the routerLink attribute, but it ignores the <app-welcome> and <router-outlet> tags.

By.directive and injected directives

A little more setup triggers the initial data binding and gets references to the navigation links:

app/app.component.spec.ts (test setup)

      
import {Component, DebugElement, NO_ERRORS_SCHEMA} from '@angular/core';
import {ComponentFixture, fakeAsync, TestBed, tick, waitForAsync} from '@angular/core/testing';
import {By} from '@angular/platform-browser';
import {provideRouter, Router, RouterLink} from '@angular/router';
import {AppComponent} from './app.component';
import {appConfig} from './app.config';
import {UserService} from './model';
@Component({standalone: true, selector: 'app-banner', template: ''})
class BannerStubComponent {}
@Component({standalone: true, selector: 'router-outlet', template: ''})
class RouterOutletStubComponent {}
@Component({standalone: true, selector: 'app-welcome', template: ''})
class WelcomeStubComponent {}
let comp: AppComponent;
let fixture: ComponentFixture<AppComponent>;
describe('AppComponent & TestModule', () => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule(
Object.assign({}, appConfig, {
imports: [
AppComponent,
BannerStubComponent,
RouterLink,
RouterOutletStubComponent,
WelcomeStubComponent,
],
providers: [provideRouter([]), UserService],
}),
)
.compileComponents()
.then(() => {
fixture = TestBed.createComponent(AppComponent);
comp = fixture.componentInstance;
});
}));
tests();
});
//////// Testing w/ NO_ERRORS_SCHEMA //////
describe('AppComponent & NO_ERRORS_SCHEMA', () => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule(
Object.assign({}, appConfig, {
imports: [
AppComponent,
BannerStubComponent,
RouterLink,
],
providers: [provideRouter([]), UserService],
schemas: [NO_ERRORS_SCHEMA],
}),
)
.compileComponents()
.then(() => {
fixture = TestBed.createComponent(AppComponent);
comp = fixture.componentInstance;
});
}));
tests();
});
function tests() {
let routerLinks: RouterLink[];
let linkDes: DebugElement[];
beforeEach(() => {
fixture.detectChanges(); // trigger initial data binding
// find DebugElements with an attached RouterLinkStubDirective
linkDes = fixture.debugElement.queryAll(By.directive(RouterLink));
// get attached link directive instances
// using each DebugElement's injector
routerLinks = linkDes.map((de) => de.injector.get(RouterLink));
});
it('can instantiate the component', () => {
expect(comp).not.toBeNull();
});
it('can get RouterLinks from template', () => {
expect(routerLinks.length).withContext('should have 3 routerLinks').toBe(3);
expect(routerLinks[0].href).toBe('/dashboard');
expect(routerLinks[1].href).toBe('/heroes');
expect(routerLinks[2].href).toBe('/about');
});
it('can click Heroes link in template', fakeAsync(() => {
const heroesLinkDe = linkDes[1]; // heroes link DebugElement
TestBed.inject(Router).resetConfig([{path: '**', children: []}]);
heroesLinkDe.triggerEventHandler('click', {button: 0});
tick();
fixture.detectChanges();
expect(TestBed.inject(Router).url).toBe('/heroes');
}));
}

Three points of special interest:

  • Locate the anchor elements with an attached directive using By.directive
  • The query returns DebugElement wrappers around the matching elements
  • Each DebugElement exposes a dependency injector with the specific instance of the directive attached to that element

The AppComponent links to validate are as follows:

app/app.component.html (navigation links)

      
<app-banner></app-banner>
<app-welcome></app-welcome>
<nav>
<a routerLink="/dashboard">Dashboard</a>
<a routerLink="/heroes">Heroes</a>
<a routerLink="/about">About</a>
</nav>
<router-outlet></router-outlet>

Here are some tests that confirm those links are wired to the routerLink directives as expected:

app/app.component.spec.ts (selected tests)

      
import {Component, DebugElement, NO_ERRORS_SCHEMA} from '@angular/core';
import {ComponentFixture, fakeAsync, TestBed, tick, waitForAsync} from '@angular/core/testing';
import {By} from '@angular/platform-browser';
import {provideRouter, Router, RouterLink} from '@angular/router';
import {AppComponent} from './app.component';
import {appConfig} from './app.config';
import {UserService} from './model';
@Component({standalone: true, selector: 'app-banner', template: ''})
class BannerStubComponent {}
@Component({standalone: true, selector: 'router-outlet', template: ''})
class RouterOutletStubComponent {}
@Component({standalone: true, selector: 'app-welcome', template: ''})
class WelcomeStubComponent {}
let comp: AppComponent;
let fixture: ComponentFixture<AppComponent>;
describe('AppComponent & TestModule', () => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule(
Object.assign({}, appConfig, {
imports: [
AppComponent,
BannerStubComponent,
RouterLink,
RouterOutletStubComponent,
WelcomeStubComponent,
],
providers: [provideRouter([]), UserService],
}),
)
.compileComponents()
.then(() => {
fixture = TestBed.createComponent(AppComponent);
comp = fixture.componentInstance;
});
}));
tests();
});
//////// Testing w/ NO_ERRORS_SCHEMA //////
describe('AppComponent & NO_ERRORS_SCHEMA', () => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule(
Object.assign({}, appConfig, {
imports: [
AppComponent,
BannerStubComponent,
RouterLink,
],
providers: [provideRouter([]), UserService],
schemas: [NO_ERRORS_SCHEMA],
}),
)
.compileComponents()
.then(() => {
fixture = TestBed.createComponent(AppComponent);
comp = fixture.componentInstance;
});
}));
tests();
});
function tests() {
let routerLinks: RouterLink[];
let linkDes: DebugElement[];
beforeEach(() => {
fixture.detectChanges(); // trigger initial data binding
// find DebugElements with an attached RouterLinkStubDirective
linkDes = fixture.debugElement.queryAll(By.directive(RouterLink));
// get attached link directive instances
// using each DebugElement's injector
routerLinks = linkDes.map((de) => de.injector.get(RouterLink));
});
it('can instantiate the component', () => {
expect(comp).not.toBeNull();
});
it('can get RouterLinks from template', () => {
expect(routerLinks.length).withContext('should have 3 routerLinks').toBe(3);
expect(routerLinks[0].href).toBe('/dashboard');
expect(routerLinks[1].href).toBe('/heroes');
expect(routerLinks[2].href).toBe('/about');
});
it('can click Heroes link in template', fakeAsync(() => {
const heroesLinkDe = linkDes[1]; // heroes link DebugElement
TestBed.inject(Router).resetConfig([{path: '**', children: []}]);
heroesLinkDe.triggerEventHandler('click', {button: 0});
tick();
fixture.detectChanges();
expect(TestBed.inject(Router).url).toBe('/heroes');
}));
}

Use a page object

The HeroDetailComponent is a simple view with a title, two hero fields, and two buttons.

HeroDetailComponent in action

But there's plenty of template complexity even in this simple form.

app/hero/hero-detail.component.html

      
@if (hero) {
<div>
<h2>
<span>{{ hero.name | titlecase }}</span> Details
</h2>
<div><span>id: </span>{{ hero.id }}</div>
<div>
<label for="name">name: </label>
<input id="name" [(ngModel)]="hero.name" placeholder="name" />
</div>
<button type="button" (click)="save()">Save</button>
<button type="button" (click)="cancel()">Cancel</button>
</div>
}

Tests that exercise the component need …

  • To wait until a hero arrives before elements appear in the DOM
  • A reference to the title text
  • A reference to the name input box to inspect and set it
  • References to the two buttons so they can click them
  • Spies for some of the component and router methods

Even a small form such as this one can produce a mess of tortured conditional setup and CSS element selection.

Tame the complexity with a Page class that handles access to component properties and encapsulates the logic that sets them.

Here is such a Page class for the hero-detail.component.spec.ts

app/hero/hero-detail.component.spec.ts (Page)

      
import {HttpClient, HttpHandler, provideHttpClient} from '@angular/common/http';
import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing';
import {fakeAsync, TestBed, tick} from '@angular/core/testing';
import {provideRouter, Router} from '@angular/router';
import {RouterTestingHarness} from '@angular/router/testing';
import {asyncData, click} from '../../testing';
import {Hero} from '../model/hero';
import {sharedImports} from '../shared/shared';
import {HeroDetailComponent} from './hero-detail.component';
import {HeroDetailService} from './hero-detail.service';
import {HeroListComponent} from './hero-list.component';
////// Testing Vars //////
let component: HeroDetailComponent;
let harness: RouterTestingHarness;
let page: Page;
////// Tests //////
describe('HeroDetailComponent', () => {
describe('with HeroModule setup', heroModuleSetup);
describe('when override its provided HeroDetailService', overrideSetup);
describe('with FormsModule setup', formsModuleSetup);
describe('with SharedModule setup', sharedModuleSetup);
});
///////////////////
const testHero = getTestHeroes()[0];
function overrideSetup() {
class HeroDetailServiceSpy {
testHero: Hero = {...testHero};
/* emit cloned test hero */
getHero = jasmine
.createSpy('getHero')
.and.callFake(() => asyncData(Object.assign({}, this.testHero)));
/* emit clone of test hero, with changes merged in */
saveHero = jasmine
.createSpy('saveHero')
.and.callFake((hero: Hero) => asyncData(Object.assign(this.testHero, hero)));
}
beforeEach(async () => {
await TestBed.configureTestingModule(
Object.assign({}, appConfig, {
imports: [HeroDetailComponent, HeroListComponent],
providers: [
provideRouter([
{path: 'heroes', component: HeroListComponent},
{path: 'heroes/:id', component: HeroDetailComponent},
]),
HttpClient,
HttpHandler,
// HeroDetailService at this level is IRRELEVANT!
{provide: HeroDetailService, useValue: {}},
],
}),
)
.overrideComponent(HeroDetailComponent, {
set: {providers: [{provide: HeroDetailService, useClass: HeroDetailServiceSpy}]},
})
.compileComponents();
});
let hdsSpy: HeroDetailServiceSpy;
beforeEach(async () => {
harness = await RouterTestingHarness.create();
component = await harness.navigateByUrl(`/heroes/${testHero.id}`, HeroDetailComponent);
page = new Page();
// get the component's injected HeroDetailServiceSpy
hdsSpy = harness.routeDebugElement!.injector.get(HeroDetailService) as any;
harness.detectChanges();
});
it('should have called `getHero`', () => {
expect(hdsSpy.getHero.calls.count())
.withContext('getHero called once')
.toBe(1, 'getHero called once');
});
it("should display stub hero's name", () => {
expect(page.nameDisplay.textContent).toBe(hdsSpy.testHero.name);
});
it('should save stub hero change', fakeAsync(() => {
const origName = hdsSpy.testHero.name;
const newName = 'New Name';
page.nameInput.value = newName;
page.nameInput.dispatchEvent(new Event('input')); // tell Angular
expect(component.hero.name).withContext('component hero has new name').toBe(newName);
expect(hdsSpy.testHero.name).withContext('service hero unchanged before save').toBe(origName);
click(page.saveBtn);
expect(hdsSpy.saveHero.calls.count()).withContext('saveHero called once').toBe(1);
tick(); // wait for async save to complete
expect(hdsSpy.testHero.name).withContext('service hero has new name after save').toBe(newName);
expect(TestBed.inject(Router).url).toEqual('/heroes');
}));
}
////////////////////
import {getTestHeroes} from '../model/testing/test-hero.service';
const firstHero = getTestHeroes()[0];
function heroModuleSetup() {
beforeEach(async () => {
await TestBed.configureTestingModule(
Object.assign({}, appConfig, {
imports: [HeroDetailComponent, HeroListComponent],
providers: [
provideRouter([
{path: 'heroes/:id', component: HeroDetailComponent},
{path: 'heroes', component: HeroListComponent},
]),
provideHttpClient(),
provideHttpClientTesting(),
],
}),
).compileComponents();
});
describe('when navigate to existing hero', () => {
let expectedHero: Hero;
beforeEach(async () => {
expectedHero = firstHero;
await createComponent(expectedHero.id);
});
it("should display that hero's name", () => {
expect(page.nameDisplay.textContent).toBe(expectedHero.name);
});
it('should navigate when click cancel', () => {
click(page.cancelBtn);
expect(TestBed.inject(Router).url).toEqual(`/heroes/${expectedHero.id}`);
});
it('should save when click save but not navigate immediately', () => {
click(page.saveBtn);
expect(TestBed.inject(HttpTestingController).expectOne({method: 'PUT', url: 'api/heroes'}));
expect(TestBed.inject(Router).url).toEqual('/heroes/41');
});
it('should navigate when click save and save resolves', fakeAsync(() => {
click(page.saveBtn);
tick(); // wait for async save to complete
expect(TestBed.inject(Router).url).toEqual('/heroes/41');
}));
it('should convert hero name to Title Case', async () => {
harness.fixture.autoDetectChanges();
// get the name's input and display elements from the DOM
const hostElement: HTMLElement = harness.routeNativeElement!;
const nameInput: HTMLInputElement = hostElement.querySelector('input')!;
const nameDisplay: HTMLElement = hostElement.querySelector('span')!;
// simulate user entering a new name into the input box
nameInput.value = 'quick BROWN fOx';
// Dispatch a DOM event so that Angular learns of input value change.
nameInput.dispatchEvent(new Event('input'));
// Wait for Angular to update the display binding through the title pipe
await harness.fixture.whenStable();
expect(nameDisplay.textContent).toBe('Quick Brown Fox');
});
});
describe('when navigate to non-existent hero id', () => {
beforeEach(async () => {
await createComponent(999);
});
it('should try to navigate back to hero list', () => {
expect(TestBed.inject(Router).url).toEqual('/heroes');
});
});
}
/////////////////////
import {FormsModule} from '@angular/forms';
import {TitleCasePipe} from '../shared/title-case.pipe';
import {appConfig} from '../app.config';
function formsModuleSetup() {
beforeEach(async () => {
await TestBed.configureTestingModule(
Object.assign({}, appConfig, {
imports: [FormsModule, HeroDetailComponent, TitleCasePipe],
providers: [
provideHttpClient(),
provideHttpClientTesting(),
provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]),
],
}),
).compileComponents();
});
it("should display 1st hero's name", async () => {
const expectedHero = firstHero;
await createComponent(expectedHero.id).then(() => {
expect(page.nameDisplay.textContent).toBe(expectedHero.name);
});
});
}
///////////////////////
function sharedModuleSetup() {
beforeEach(async () => {
await TestBed.configureTestingModule(
Object.assign({}, appConfig, {
imports: [HeroDetailComponent, sharedImports],
providers: [
provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]),
provideHttpClient(),
provideHttpClientTesting(),
],
}),
).compileComponents();
});
it("should display 1st hero's name", async () => {
const expectedHero = firstHero;
await createComponent(expectedHero.id).then(() => {
expect(page.nameDisplay.textContent).toBe(expectedHero.name);
});
});
}
/////////// Helpers /////
/** Create the HeroDetailComponent, initialize it, set test variables */
async function createComponent(id: number) {
harness = await RouterTestingHarness.create();
component = await harness.navigateByUrl(`/heroes/${id}`, HeroDetailComponent);
page = new Page();
const request = TestBed.inject(HttpTestingController).expectOne(`api/heroes/?id=${id}`);
const hero = getTestHeroes().find((h) => h.id === Number(id));
request.flush(hero ? [hero] : []);
harness.detectChanges();
}
class Page {
// getter properties wait to query the DOM until called.
get buttons() {
return this.queryAll<HTMLButtonElement>('button');
}
get saveBtn() {
return this.buttons[0];
}
get cancelBtn() {
return this.buttons[1];
}
get nameDisplay() {
return this.query<HTMLElement>('span');
}
get nameInput() {
return this.query<HTMLInputElement>('input');
}
//// query helpers ////
private query<T>(selector: string): T {
return harness.routeNativeElement!.querySelector(selector)! as T;
}
private queryAll<T>(selector: string): T[] {
return harness.routeNativeElement!.querySelectorAll(selector) as any as T[];
}
}

Now the important hooks for component manipulation and inspection are neatly organized and accessible from an instance of Page.

A createComponent method creates a page object and fills in the blanks once the hero arrives.

app/hero/hero-detail.component.spec.ts (createComponent)

      
import {HttpClient, HttpHandler, provideHttpClient} from '@angular/common/http';
import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing';
import {fakeAsync, TestBed, tick} from '@angular/core/testing';
import {provideRouter, Router} from '@angular/router';
import {RouterTestingHarness} from '@angular/router/testing';
import {asyncData, click} from '../../testing';
import {Hero} from '../model/hero';
import {sharedImports} from '../shared/shared';
import {HeroDetailComponent} from './hero-detail.component';
import {HeroDetailService} from './hero-detail.service';
import {HeroListComponent} from './hero-list.component';
////// Testing Vars //////
let component: HeroDetailComponent;
let harness: RouterTestingHarness;
let page: Page;
////// Tests //////
describe('HeroDetailComponent', () => {
describe('with HeroModule setup', heroModuleSetup);
describe('when override its provided HeroDetailService', overrideSetup);
describe('with FormsModule setup', formsModuleSetup);
describe('with SharedModule setup', sharedModuleSetup);
});
///////////////////
const testHero = getTestHeroes()[0];
function overrideSetup() {
class HeroDetailServiceSpy {
testHero: Hero = {...testHero};
/* emit cloned test hero */
getHero = jasmine
.createSpy('getHero')
.and.callFake(() => asyncData(Object.assign({}, this.testHero)));
/* emit clone of test hero, with changes merged in */
saveHero = jasmine
.createSpy('saveHero')
.and.callFake((hero: Hero) => asyncData(Object.assign(this.testHero, hero)));
}
beforeEach(async () => {
await TestBed.configureTestingModule(
Object.assign({}, appConfig, {
imports: [HeroDetailComponent, HeroListComponent],
providers: [
provideRouter([
{path: 'heroes', component: HeroListComponent},
{path: 'heroes/:id', component: HeroDetailComponent},
]),
HttpClient,
HttpHandler,
// HeroDetailService at this level is IRRELEVANT!
{provide: HeroDetailService, useValue: {}},
],
}),
)
.overrideComponent(HeroDetailComponent, {
set: {providers: [{provide: HeroDetailService, useClass: HeroDetailServiceSpy}]},
})
.compileComponents();
});
let hdsSpy: HeroDetailServiceSpy;
beforeEach(async () => {
harness = await RouterTestingHarness.create();
component = await harness.navigateByUrl(`/heroes/${testHero.id}`, HeroDetailComponent);
page = new Page();
// get the component's injected HeroDetailServiceSpy
hdsSpy = harness.routeDebugElement!.injector.get(HeroDetailService) as any;
harness.detectChanges();
});
it('should have called `getHero`', () => {
expect(hdsSpy.getHero.calls.count())
.withContext('getHero called once')
.toBe(1, 'getHero called once');
});
it("should display stub hero's name", () => {
expect(page.nameDisplay.textContent).toBe(hdsSpy.testHero.name);
});
it('should save stub hero change', fakeAsync(() => {
const origName = hdsSpy.testHero.name;
const newName = 'New Name';
page.nameInput.value = newName;
page.nameInput.dispatchEvent(new Event('input')); // tell Angular
expect(component.hero.name).withContext('component hero has new name').toBe(newName);
expect(hdsSpy.testHero.name).withContext('service hero unchanged before save').toBe(origName);
click(page.saveBtn);
expect(hdsSpy.saveHero.calls.count()).withContext('saveHero called once').toBe(1);
tick(); // wait for async save to complete
expect(hdsSpy.testHero.name).withContext('service hero has new name after save').toBe(newName);
expect(TestBed.inject(Router).url).toEqual('/heroes');
}));
}
////////////////////
import {getTestHeroes} from '../model/testing/test-hero.service';
const firstHero = getTestHeroes()[0];
function heroModuleSetup() {
beforeEach(async () => {
await TestBed.configureTestingModule(
Object.assign({}, appConfig, {
imports: [HeroDetailComponent, HeroListComponent],
providers: [
provideRouter([
{path: 'heroes/:id', component: HeroDetailComponent},
{path: 'heroes', component: HeroListComponent},
]),
provideHttpClient(),
provideHttpClientTesting(),
],
}),
).compileComponents();
});
describe('when navigate to existing hero', () => {
let expectedHero: Hero;
beforeEach(async () => {
expectedHero = firstHero;
await createComponent(expectedHero.id);
});
it("should display that hero's name", () => {
expect(page.nameDisplay.textContent).toBe(expectedHero.name);
});
it('should navigate when click cancel', () => {
click(page.cancelBtn);
expect(TestBed.inject(Router).url).toEqual(`/heroes/${expectedHero.id}`);
});
it('should save when click save but not navigate immediately', () => {
click(page.saveBtn);
expect(TestBed.inject(HttpTestingController).expectOne({method: 'PUT', url: 'api/heroes'}));
expect(TestBed.inject(Router).url).toEqual('/heroes/41');
});
it('should navigate when click save and save resolves', fakeAsync(() => {
click(page.saveBtn);
tick(); // wait for async save to complete
expect(TestBed.inject(Router).url).toEqual('/heroes/41');
}));
it('should convert hero name to Title Case', async () => {
harness.fixture.autoDetectChanges();
// get the name's input and display elements from the DOM
const hostElement: HTMLElement = harness.routeNativeElement!;
const nameInput: HTMLInputElement = hostElement.querySelector('input')!;
const nameDisplay: HTMLElement = hostElement.querySelector('span')!;
// simulate user entering a new name into the input box
nameInput.value = 'quick BROWN fOx';
// Dispatch a DOM event so that Angular learns of input value change.
nameInput.dispatchEvent(new Event('input'));
// Wait for Angular to update the display binding through the title pipe
await harness.fixture.whenStable();
expect(nameDisplay.textContent).toBe('Quick Brown Fox');
});
});
describe('when navigate to non-existent hero id', () => {
beforeEach(async () => {
await createComponent(999);
});
it('should try to navigate back to hero list', () => {
expect(TestBed.inject(Router).url).toEqual('/heroes');
});
});
}
/////////////////////
import {FormsModule} from '@angular/forms';
import {TitleCasePipe} from '../shared/title-case.pipe';
import {appConfig} from '../app.config';
function formsModuleSetup() {
beforeEach(async () => {
await TestBed.configureTestingModule(
Object.assign({}, appConfig, {
imports: [FormsModule, HeroDetailComponent, TitleCasePipe],
providers: [
provideHttpClient(),
provideHttpClientTesting(),
provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]),
],
}),
).compileComponents();
});
it("should display 1st hero's name", async () => {
const expectedHero = firstHero;
await createComponent(expectedHero.id).then(() => {
expect(page.nameDisplay.textContent).toBe(expectedHero.name);
});
});
}
///////////////////////
function sharedModuleSetup() {
beforeEach(async () => {
await TestBed.configureTestingModule(
Object.assign({}, appConfig, {
imports: [HeroDetailComponent, sharedImports],
providers: [
provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]),
provideHttpClient(),
provideHttpClientTesting(),
],
}),
).compileComponents();
});
it("should display 1st hero's name", async () => {
const expectedHero = firstHero;
await createComponent(expectedHero.id).then(() => {
expect(page.nameDisplay.textContent).toBe(expectedHero.name);
});
});
}
/////////// Helpers /////
/** Create the HeroDetailComponent, initialize it, set test variables */
async function createComponent(id: number) {
harness = await RouterTestingHarness.create();
component = await harness.navigateByUrl(`/heroes/${id}`, HeroDetailComponent);
page = new Page();
const request = TestBed.inject(HttpTestingController).expectOne(`api/heroes/?id=${id}`);
const hero = getTestHeroes().find((h) => h.id === Number(id));
request.flush(hero ? [hero] : []);
harness.detectChanges();
}
class Page {
// getter properties wait to query the DOM until called.
get buttons() {
return this.queryAll<HTMLButtonElement>('button');
}
get saveBtn() {
return this.buttons[0];
}
get cancelBtn() {
return this.buttons[1];
}
get nameDisplay() {
return this.query<HTMLElement>('span');
}
get nameInput() {
return this.query<HTMLInputElement>('input');
}
//// query helpers ////
private query<T>(selector: string): T {
return harness.routeNativeElement!.querySelector(selector)! as T;
}
private queryAll<T>(selector: string): T[] {
return harness.routeNativeElement!.querySelectorAll(selector) as any as T[];
}
}

Here are a few more HeroDetailComponent tests to reinforce the point.

app/hero/hero-detail.component.spec.ts (selected tests)

      
import {HttpClient, HttpHandler, provideHttpClient} from '@angular/common/http';
import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing';
import {fakeAsync, TestBed, tick} from '@angular/core/testing';
import {provideRouter, Router} from '@angular/router';
import {RouterTestingHarness} from '@angular/router/testing';
import {async