DEV Community

Cover image for Testing Angular routing components with RouterTestingHarness, provideLocationMocks, and provideRouter
Lars Gyrup Brink Nielsen for This is Angular

Posted on

Testing Angular routing components with RouterTestingHarness, provideLocationMocks, and provideRouter

Cover art by DALL·E 2.

It's been three years since Testing Angular routing components with the RouterTestingModule. This article revisits integrated routing component tests with modern Angular APIs, including standalone components, provideRouter, provideLocationMocks, and RouterTestingHarness. Additionally, we use a SIFERS for managing our test setup and test utilities.

The show hero detail use case

The show hero detail use case.

providerRouter and provideLocationMocks

provideRouter (introduced by Angular version 14.2) is the standalone version of RouterModule.forRoot. Combine it with provideLocationMocks (introduced by Angular version 15.0) and we have the standalone version of RouterTestingModule.withRoutes.

ℹ️ Note
Read What does the RouterTestingModule do? for a detailed explanation of how RouterTestingModule replaces Angular Router dependencies. provideLocationMocks does the same.

RouterTestingHarness

RouterTestingHarness (introduced by Angular version 15.2) is similar to Spectacular's Feature testing API.

When we call RouterTestingHarness.create (only call it once per test), a test root component with a router outlet is created behind the scenes but we don't get access to this component or its component fixture.

The resolved RouterTestingHarness instance has the properties routeDebugElement and routeNativeElement which access the DebugElement and HTMLElement corresponding to the component currently activated by the test root component's RouterOutlet.

RouterTestingHarness has a detectChanges method which calls ComponentFixture#detectChanges for the test root component.

The RouterTestingHarness#navigateByUrl method wraps Router#navigateByUrl and resolves the component activated by that navigation.

That's all the background we need. Let's explore a RouterTestingHarness version of the integrated routed component test for the DashboardComponent from the Tour of Heroes Router tutorial.

Integrated routing component test suite

import { Location } from '@angular/common';
import { provideLocationMocks } from '@angular/common/testing';
import { Component } from '@angular/core';
import {
  fakeAsync,
  TestBed,
  tick,
} from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { provideRouter } from '@angular/router';
import { RouterTestingHarness } from '@angular/router/testing';
import { asapScheduler, of } from 'rxjs';
import { observeOn } from 'rxjs/operators';

import { HeroService } from '../hero.service';
import { HEROES } from '../mock-heroes';
import { DashboardComponent } from './dashboard.component';

async function setup() {
  const fakeService = {
    getHeroes() {
      return of([...HEROES]).pipe(observeOn(asapScheduler));
    },
  } as Partial<HeroService>;

  TestBed.configureTestingModule({
    providers: [
      provideRouter([
        {
          path: '',
          pathMatch: 'full',
          component: DashboardComponent,
        },
        {
          path: 'detail/:id',
          component: TestHeroDetailComponent,
        },
      ]),
      provideLocationMocks(),
      { provide: HeroService, useValue: fakeService },
    ],
  });

  const harness = await RouterTestingHarness.create(); // [1]
  const location = TestBed.inject(Location);

  return {
    advance() {
      tick();
      harness.detectChanges();
    },
    clickTopHero() {
      const firstHeroLink = harness.routeDebugElement.query(
        By.css('a')
      );

      firstHeroLink.triggerEventHandler('click', {
        button: leftMouseButton,
      });
    },
    harness,
    location,
  };
}

@Component({
  standalone: true,
  template: '',
})
class TestHeroDetailComponent {}

const leftMouseButton = 0;

describe('DashboardComponent (integrated)', () => {
  it('navigates to the detail view when a hero link is clicked', fakeAsync(async () => {
    const { advance, clickTopHero, harness, location } =
      await setup();
    const component /* [2] */ = await harness.navigateByUrl(
      '/',
      DashboardComponent // [3]
    );
    const [topHero] = component.heroes;

    clickTopHero();
    advance();

    const expectedPath = '/detail/' + topHero.id;
    expect(location.path())
      .withContext(
        'must navigate to the detail view for the top hero'
      )
      .toBe(expectedPath);
  }));
});
Enter fullscreen mode Exit fullscreen mode

(1) Notice how we only call RouterTestingHarness.create once per test case in our setup SIFERS.

⚠️ Warning
ModuleTeardownOptions#destroyAfterEach must be set to true for RouterTestingHarness to work correctly. See Improving Angular tests by enabling Angular testing module teardown for details on this option.

(1) We could have passed an initial URL, for example await RouterTestingHarness.create("/") or await RouterTestingHarness.create("/heroes") but it doesn't return an activated component.

(2) RouterTestingHarness#navigateByUrl resolves an activated component and optionally accepts the type (class) of the activated component we expect (3). If the component activated by that navigation is not of the expected type, an error is thrown.

The full test suite is available in this Gist.

Summary

Let's sum up what we learned in this article:

RouterTestingHarness.create creates an initializes a test root component with a router outlet. It must only be called once per test case and requires ModuleTeardownOptions#destroyAfterEach to be set to true. It optionally accepts an initial URL.

RouterTestingHarness#navigateByUrl accepts a URL for navigation and optionally the expected type of the component activated by that navigation. The activated component is resolved by the method call.

RouterTestingHarness#detectChanges triggers a change detection cycle starting at the test root component.

Top comments (2)

Collapse
 
arthurfedotiev profile image
Arthur-Fedotiev

Thanks for nice and tidy update!)
I cannot get where should we use this ModuleTeardownOptions#destroyAfterEach.
It seems you have nothing concerned with destroyAfterEach in you code example, have you?

Collapse
 
layzee profile image
Lars Gyrup Brink Nielsen

I'm glad you found it useful, Arthur. Have a look at dev.to/this-is-angular/improving-a..., also linked in this article.