Testing Route-Linked Angular Components with RouterTestingModule

Angular's RouterTestingModule provides the necessary infrastructure for testing components that are tied to routing configurations.

When we talk about a route-linked component, we mean any component that serves as the destination for a navigation event—essentially, components referenced within route definitions. These might include full-page views, application shells, layout containers, modals, or nested child components.

Certain route-linked components depend on dynamic route values, such as the hero detail view from the Tour of Heroes guide on Angular.io, which relies on an id parameter.

These components are most often rendered through the primary outlet, but some may be associated with auxiliary outlets, for instance a named outlet like popup or drawer.

In this discussion, we'll start by examining a well-organized shallow test for a route-linked component—one that keeps the test isolated from both services and the router.

Following that, we'll construct an integrated test for a route-linked component, leveraging the router testing module. For a deeper look into what this module provides, check out "Testing Angular routing components with the RouterTestingModule".

Testing routed Angular components with the RouterTestingModule — figure 1 Figure 1. Navigating to the HeroDetailComponent.

Our example focuses on testing suites for the HeroDetailComponent from the Tour of Heroes guide on Angular.io. As Figure 1 shows, when the router navigates to the dynamic /detail/:id path, this component appears, displaying the chosen hero's name in its header.

The flow proceeds in this manner:

  1. A navigation event starts from some routing component. The specific source component is not relevant to this test suite.
  2. The hero detail component must retrieve the hero information using the id supplied in the route.
  3. The hero's name then appears in the heading element of the hero detail component.

With that, we're ready to dive in.

Keeping the Hero Detail Test Shallow

The Angular testing documentation outlines how to create unit tests that are separate from the rest of the application for a route-linked component. Our test target is the HeroDetailComponent from the Tour of Heroes guide.

You can inspect the component's logic and markup in Listings 1A and 1B.

<!-- hero-detail.component.html -->
<div *ngIf="hero">
  <h2>
    {{hero.name | uppercase}} Details
  </h2>

  <div>
    <span>id:</span>
    {{hero.id}}
  </div>

  <div>
    <label>
      name:
      <input [(ngModel)]="hero.name" placeholder="name" />
    </label>
  </div>

  <button (click)="goBack()">go back</button>

  <button (click)="save()">save</button>
</div>
Enter fullscreen mode Exit fullscreen mode
// hero-detail.component.ts
import { Location } from '@angular/common';
import { Component, Input, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';

import { Hero } from '../hero';
import { HeroService } from '../hero.service';

@Component({
  selector: 'app-hero-detail',
  styleUrls: ['./hero-detail.component.css'],
  templateUrl: './hero-detail.component.html',
})
export class HeroDetailComponent implements OnInit {
  @Input() hero: Hero;

  constructor(private route: ActivatedRoute, private heroService: HeroService, private location: Location) {}

  ngOnInit(): void {
    this.getHero();
  }

  getHero(): void {
    const id = +this.route.snapshot.paramMap.get('id');

    this.heroService.getHero(id).subscribe((hero) => (this.hero = hero));
  }

  goBack(): void {
    this.location.back();
  }

  save(): void {
    this.heroService.updateHero(this.hero).subscribe(() => this.goBack());
  }
}
Enter fullscreen mode Exit fullscreen mode

Listing 1B. The component's model for the hero detail.

This component qualifies as route-linked due to its corresponding route configuration, visible in Listing 2.

// app-routing.module.ts
const routes: Routes = [
  { path: '', redirectTo: '/dashboard', pathMatch: 'full' },
  { path: 'dashboard', component: DashboardComponent },
  { path: 'detail/:id', component: HeroDetailComponent }, // 👈
  { path: 'heroes', component: HeroesComponent },
];
Enter fullscreen mode Exit fullscreen mode

Listing 2. The route definitions declared in the AppRoutingModule of the Tour of Heroes guide.

We'll start by building an isolated component test. This will be a shallow test—the goal is to avoid rendering any child components, whether there are many or none at all.

The target component currently has no view children, yet the same principles apply as if it did. This approach keeps the test robust, allowing for a seamless split into smaller components later without necessitating changes to the test.

Utilities for the Shallow Route Test

First, let's examine the utility functions in Listing 3A.

// hero-detail.component.spec.ts
import { By } from '@angular/platform-browser';
import { ActivatedRoute, ActivatedRouteSnapshot, convertToParamMap, ParamMap, Params } from '@angular/router';
import { ReplaySubject } from 'rxjs';

class ActivatedRouteStub implements Partial<ActivatedRoute> {
  private _paramMap: ParamMap;
  private subject = new ReplaySubject<ParamMap>();

  paramMap = this.subject.asObservable();
  get snapshot(): ActivatedRouteSnapshot {
    const snapshot: Partial<ActivatedRouteSnapshot> = {
      paramMap: this._paramMap,
    };

    return snapshot as ActivatedRouteSnapshot;
  }

  constructor(initialParams?: Params) {
    this.setParamMap(initialParams);
  }

  setParamMap(params?: Params) {
    const paramMap = convertToParamMap(params);
    this._paramMap = paramMap;
    this.subject.next(paramMap);
  }
}

describe('HeroDetailComponent (shallow)', () => {
  function getTitle() {
    const element = fixture.debugElement.query(By.css('h2')).nativeElement as HTMLElement;

    return element.textContent.trim();
  }

  function navigateByHeroId(id: number) {
    routeStub.setParamMap({ id });
  }
});
Enter fullscreen mode Exit fullscreen mode

Listing 3A. Utility functions configured for the shallow test.

The getTitle function extracts the text located in the hero detail component's heading. It does this by searching for the element through the debugElement and then reading its nativeElement's textContent.

With navigateByHeroId, we mimic a navigation event by assigning a new value to the id parameter on the activated route stub.

The ActivatedRouteStub class originates from the official Angular testing guide. I've adapted it here, adding supplementary capacity to provide the snapshot property.

These utilities are sufficient for the test at hand.

Preparing the Test Environment

Now, let's look at the setup and variable declarations for this routed component test. See Listing 3B.

// hero-detail.component.spec.ts
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute } from '@angular/router';
import { of } from 'rxjs';

import { Hero } from '../hero';
import { HeroService } from '../hero.service';
import { HEROES } from '../mock-heroes';
import { HeroDetailComponent } from './hero-detail.component';

describe('HeroDetailComponent (shallow)', () => {
  beforeEach(async () => {
    const fakeService = {
      // [1]
      getHero(id: number) {
        const hero = [...fakeHeroes].find((h) => h.id === id);

        return of(hero);
      },
    } as Partial<HeroService>;
    routeStub = new ActivatedRouteStub(); // [2]

    TestBed.configureTestingModule({
      declarations: [HeroDetailComponent], // [3]
      imports: [FormsModule], // [4]
      providers: [
        { provide: ActivatedRoute, useValue: routeStub }, // [2]
        { provide: HeroService, useValue: fakeService }, // [1]
      ],
      schemas: [CUSTOM_ELEMENTS_SCHEMA], // [5]
    });

    await TestBed.compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(HeroDetailComponent);
    component = fixture.componentInstance;
  });

  let component: HeroDetailComponent;
  const fakeHeroes: ReadonlyArray<Hero> = [...HEROES];
  let fixture: ComponentFixture<HeroDetailComponent>;
  let routeStub: ActivatedRouteStub;
});
Enter fullscreen mode Exit fullscreen mode

Listing 3B. Configuration for the shallow routed component test.

We begin by constructing a mock HeroService (1) intended to fetch data for a lone hero given an identifier. We then substitute the actual ActivatedRoute service with the stub we've prepared.

In the test bed, we register (3) the component we're testing. We also include the FormsModule (4) so that the component's template can be correctly interpreted. By adding the CUSTOM_ELEMENTS_SCHEMA (5), we notify Angular to bypass checks for any child components and treat them as generic elements.

Inside the test case, the fakeHeroes array serves as a handy reference, reflecting the same information that our mock service will return. The fixture provides a handle to the instance of the hero detail component under test. Lastly, the routeStub variable is a stored reference, giving us the ability to modify routing parameters when we want to simulate different navigations.

The Shallow Test Scenario

Listing 3C showcases a test that runs through the standard, successful path—where a legitimate hero ID is provided via the route.

// hero-detail.component.spec.ts
describe('HeroDetailComponent (shallow)', () => {
  it("displays the hero's name in upper-case letters", () => {
    const [expectedHero] = fakeHeroes;

    navigateByHeroId(expectedHero.id); // [1]
    fixture.detectChanges(); // [2]

    expect(getTitle()).toContain(expectedHero.name.toUpperCase()); // [3]
  });
});
Enter fullscreen mode Exit fullscreen mode

Listing 3C. The individual test for the shallow routed component.

In this test, we start by (1) calling navigateByHeroId with a valid hero ID, simulating what happens when the user navigates to that URL. Moving on, we explicitly tell Angular to detect changes (2) using fixture.detectChanges(), ensuring our component reads the stubbed route parameter and refreshes the template. It's important to remember that the stub is what holds this parameter.

To finalize, we fetch the title element via our utility and verify that its text matches the hero's name, ensuring it appears with all-caps formatting (3).

Shallow routed component test suite

Listing 4 presents the complete shallow routed component test suite for reference.

// hero-detail.component.spec.ts
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormsModule } from '@angular/forms';
import { By } from '@angular/platform-browser';
import { ActivatedRoute, ActivatedRouteSnapshot, convertToParamMap, ParamMap, Params } from '@angular/router';
import { of, ReplaySubject } from 'rxjs';

import { Hero } from '../hero';
import { HeroService } from '../hero.service';
import { HEROES } from '../mock-heroes';
import { HeroDetailComponent } from './hero-detail.component';

class ActivatedRouteStub implements Partial<ActivatedRoute> {
  private _paramMap: ParamMap;
  private subject = new ReplaySubject<ParamMap>();

  paramMap = this.subject.asObservable();
  get snapshot(): ActivatedRouteSnapshot {
    const snapshot: Partial<ActivatedRouteSnapshot> = {
      paramMap: this._paramMap,
    };

    return snapshot as ActivatedRouteSnapshot;
  }

  constructor(initialParams?: Params) {
    this.setParamMap(initialParams);
  }

  setParamMap(params?: Params) {
    const paramMap = convertToParamMap(params);
    this._paramMap = paramMap;
    this.subject.next(paramMap);
  }
}

describe('HeroDetailComponent (shallow)', () => {
  function getTitle() {
    const element = fixture.debugElement.query(By.css('h2')).nativeElement as HTMLElement;

    return element.textContent.trim();
  }

  function navigateByHeroId(id: number) {
    routeStub.setParamMap({ id });
  }

  beforeEach(async () => {
    const fakeService = {
      getHero(id: number) {
        const hero = [...fakeHeroes].find((h) => h.id === id);

        return of(hero);
      },
    } as Partial<HeroService>;
    routeStub = new ActivatedRouteStub();

    TestBed.configureTestingModule({
      declarations: [HeroDetailComponent],
      imports: [FormsModule],
      providers: [
        { provide: ActivatedRoute, useValue: routeStub },
        { provide: HeroService, useValue: fakeService },
      ],
      schemas: [CUSTOM_ELEMENTS_SCHEMA],
    });

    await TestBed.compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(HeroDetailComponent);
    component = fixture.componentInstance;
  });

  let component: HeroDetailComponent;
  const fakeHeroes: ReadonlyArray<Hero> = [...HEROES];
  let fixture: ComponentFixture<HeroDetailComponent>;
  let routeStub: ActivatedRouteStub;

  it("displays the hero's name in upper-case letters", () => {
    const [expectedHero] = fakeHeroes;

    navigateByHeroId(expectedHero.id);
    fixture.detectChanges();

    expect(getTitle()).toContain(expectedHero.name.toUpperCase());
  });
});

Listing 4. The shallow routed component test suite for the HeroDetailComponent.

The full test suite is available in this Gist.

The test case above covers only the happy path. Additional scenarios worth exploring include what occurs when the hero ID points to a non-existent record, is empty, or is otherwise malformed. Working through those cases is left as an exercise.

Integrated routed component test with the RouterTestingModule

To gain stronger assurance, we'll examine how the HeroDetailComponent works together with an actual ActivatedRoute service and in-memory stand-ins for routing-related services.

The RouterTestingModule helps us define a testing route and replace the Location service to keep browser APIs out of the equation, as covered in "Testing Angular routing components with the RouterTestingModule".

Integrated routed component test utilities

Listing 5A shows the utilities needed for the routed component test of the HeroDetailComponent from the Tour of Heroes tutorial.

// hero-detail.component.integration.spec.ts
import { Component } from '@angular/core';
import { tick } from '@angular/core/testing';
import { By } from '@angular/platform-browser';

@Component({
  template: '<router-outlet></router-outlet>', // [1]
})
class TestRootComponent {}

describe('HeroDetailComponent (integrated)', () => {
  function advance() {
    tick();
    rootFixture.detectChanges();
  }

  function getTitle() {
    const element = rootFixture.debugElement.query(By.css('h2')).nativeElement as HTMLElement;

    return element.textContent.trim();
  }

  function navigateByHeroId(id: number) {
    rootFixture.ngZone.run(() => router.navigate(['detail', id]));
  }
});

Listing 5A. Test utilities for the integrated routed component test.

This integrated test simulates a small application that exposes a single route for the component. The test root component includes a router outlet (1) that renders our component once the test route is configured. The TestRootComponent plays the role of what is commonly referred to as the AppComponent.

What we're verifying here is the behavior of the application when a navigation to the hero detail component occurs. We don't need to know which routing component initiated that navigation in this particular test case.

For an integration test that covers an entire user flow—starting at the DashboardComponent and selecting a hero link to reach the detail view—we would define a default route for the dashboard component. Then we would trigger navigation by locating and interacting with a hero link through a debug element obtained from the root component fixture.

Such a behavior-driven test is worth adding. See "Testing Angular routing components with the RouterTestingModule" for guidance on implementing most of this approach. An end-to-end test could also be added for additional runtime confidence.

The advance helper flushes the NgZone task queues and runs change detection so the testing environment stabilizes. Since it relies on tick, it must run inside a function wrapped with fakeAsync.

The getTitle utility matches the one used in the shallow routed component test, except it now references a rootFixture variable. In this suite, the component fixture is bound to the TestRootComponent rather than the component being tested.

The navigateByHeroId helper takes the injected Router service and performs a navigation to the detail/:id route path, substituting the provided ID for the route parameter. Wrapping the callback in NgZone#run suppresses Angular warnings related to zone handling.

As noted in Angular issue #25837, Angular emits a warning when navigation is triggered outside of a test case—typically within beforeEach hooks. Our callback executes inside the Angular zone to avoid that warning, even though it has no bearing on test outcomes.

Integrated routed component test setup

Listing 5B demonstrates that we supply the same fake hero service (1) as in the shallow routed component test.

// hero-detail.component.integration.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { of } from 'rxjs';

import { Hero } from '../hero';
import { HeroService } from '../hero.service';
import { HEROES } from '../mock-heroes';
import { HeroDetailComponent } from './hero-detail.component';

describe('HeroDetailComponent (integrated)', () => {
  beforeEach(async () => {
    const fakeService = {
      // [1]
      getHero(id: number) {
        const hero = [...fakeHeroes].find((h) => h.id === id);

        return of(hero);
      },
    } as Partial<HeroService>;

    TestBed.configureTestingModule({
      declarations: [
        TestRootComponent, // [3]
        HeroDetailComponent, // [2]
      ],
      imports: [
        RouterTestingModule.withRoutes([
          // [6]
          { path: 'detail/:id', component: HeroDetailComponent }, // [5]
        ]),
        FormsModule, // [7]
      ],
      providers: [
        { provide: HeroService, useValue: fakeService }, // [1]
      ],
    });

    await TestBed.compileComponents();

    rootFixture = TestBed.createComponent(TestRootComponent); // [4]
    router = TestBed.inject(Router); // [8]
  });

  const fakeHeroes: ReadonlyArray<Hero> = [...HEROES]; // [9]
  let router: Router; // [8]
  let rootFixture: ComponentFixture<TestRootComponent>; // [4]
});

Listing 5B. Test setup for our integrated routed component test.

The Angular testing module is configured with our component under test (2), the HeroDetailComponent.

We also declare the fake root component (3), which is the component wrapped by our component fixture (4). The TestRootComponent contains a router outlet that renders the component under test once the test route (5) points to it.

The RouterTestingModule#withRoutes is used to establish the test route, the Router service, and a fake Location service (6).

Mirroring the shallow routed component test, we import the FormsModule (7) so the form directives in the component template render properly. The CommonModule is included automatically by the Angular testing module.

After the component fixture is created, the injected Router service is stored in the router variable (8), which the navigateByHeroId helper depends on.

Finally, the fakeHeroes variable (9) mirrors the shallow routed component test setup. It supplies the hero ID that the fake hero service can resolve.

No initial navigation is performed here because the test case focuses solely on what occurs during and after navigating to the hero detail. The routing component that initiates the navigation is outside the scope of this test.

Since there is no initial navigation, calling advance in the setup is unnecessary—the component fixture and the change detection cycle that follows will settle on their own.

The setup remains manageable. Isolation happens at the data service level by providing a fake HeroService, and at the browser API level through the RouterTestingModule.

If the component under test had nested view components, those could also be declared in this setup, incorporating more software artifacts and further strengthening the confidence provided by this test.

The final place of isolation concerns the routes included in this suite. As mentioned, only a test route for the hero detail component exists, but a routing component could be added if desired.

Integrated routed component test case

Now that the test setup has been examined piece by piece, we turn to the test case itself. With our robust test environment in place, the test case remains quite concise.

// hero-detail.component.integration.spec.ts
import { fakeAsync } from '@angular/core/testing';

describe('HeroDetailComponent (integrated)', () =>
    it("displays the hero's name in upper-case letters", fakeAsync(() => {
    const [expectedHero] = fakeHeroes;

    navigateByHeroId(expectedHero.id); // [2]
    advance(); // [1]

    expect(getTitle()).toContain(expectedHero.name.toUpperCase());
    }));
});

Listing 5C. The test case of our integrated routed component test.

The integrated test case in Listing 5C closely resembles its shallow counterpart, with one key difference: the advance test utility function (1) is now required.

This stems from the fact that the test integrates real or fake routing and navigation services rather than an activated route stub. We're now validating how the hero detail component responds to the genuine ActivatedRoute service.

This suite also exercises the real Router service to navigate to a hero detail route, although that detail is tucked away inside the navigateByHeroId utility (2).

Integrated routed component test suite

For reference, the complete integrated routed component test suite appears in Listing 6.

// hero-detail.component.integration.spec.ts
import { Component } from '@angular/core';
import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
import { FormsModule } from '@angular/forms';
import { By } from '@angular/platform-browser';
import { Router } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { of } from 'rxjs';

import { Hero } from '../hero';
import { HeroService } from '../hero.service';
import { HEROES } from '../mock-heroes';
import { HeroDetailComponent } from './hero-detail.component';

@Component({
  template: '<router-outlet></router-outlet>',
})
class TestRootComponent {}

describe('HeroDetailComponent (integrated)', () => {
  function advance() {
    tick();
    rootFixture.detectChanges();
  }

  function getTitle() {
    const element = rootFixture.debugElement.query(By.css('h2')).nativeElement as HTMLElement;

    return element.textContent.trim();
  }

  function navigateByHeroId(id: number) {
    rootFixture.ngZone.run(() => router.navigate(['detail', id])); // [1] [2]
  }

  beforeEach(async () => {
    const fakeService = {
      getHero(id: number) {
        const hero = [...fakeHeroes].find((h) => h.id === id);

        return of(hero);
      },
    } as Partial<HeroService>;

    TestBed.configureTestingModule({
      declarations: [TestRootComponent, HeroDetailComponent],
      imports: [
        RouterTestingModule.withRoutes([
          { path: 'detail/:id', component: HeroDetailComponent }, // [2]
        ]),
        FormsModule,
      ],
      providers: [{ provide: HeroService, useValue: fakeService }],
    });

    await TestBed.compileComponents();

    rootFixture = TestBed.createComponent(TestRootComponent);
    router = TestBed.inject(Router);
  });

  const fakeHeroes: ReadonlyArray<Hero> = [...HEROES];
  let router: Router;
  let rootFixture: ComponentFixture<TestRootComponent>;

  it("displays the hero's name in upper-case letters", fakeAsync(() => {
    const [expectedHero] = fakeHeroes;

    navigateByHeroId(expectedHero.id);
    advance();

    expect(getTitle()).toContain(expectedHero.name.toUpperCase());
  }));
});

Listing 6. The complete integrated routed component test suite.

Once again, we rely on the actual Router service to navigate to a /detail/:id route (1), and this navigation occurs within the Angular zone.

The route paths and URL segments in (2) may appear to be magic strings. In reality, any test route with a dynamic :id parameter would have worked equally well.

Our test route happened to align with the actual route used in the Tour of Heroes application, but this is a coincidence rather than a deliberate choice. To achieve a deeper level of integration, the hero detail route path would need to be defined in a shared location accessible during tests, at compile time, and at runtime.

Refer to Listings 3.1, 3.2, and 3.3 of "Lean Angular components" for a straightforward solution to this challenge, or explore Routeshub by Max Tarsis. Routeshub is a route management library that pairs well with the Angular router.

The full test suite is available in this Gist.

Final thoughts

We have test suites exercising what would appear to be very simple runtime behavior. Despite the apparent simplicity of what we're verifying, a considerable amount of test setup and utility scaffolding is necessary to achieve concise test cases.

What exactly did we verify in our routed component test suite?

Testing routed Angular components with the RouterTestingModule — figure 2

Figure 1 (repeated). Navigation to the HeroDetailComponent.

We confirmed that the hero's name appears in the component title whenever we navigate to the hero detail route.

I noted earlier that the router testing Angular module replaces navigation-related browser APIs with fake services, while still allowing us to use the real Router service. What wasn't mentioned is that RouterTestingModule also prevents Karma from leaving the Karma test page and provides compatibility with test runners lacking browser APIs such as the History and Location APIs.

If we restrict our integrated routed component test to include the routing component responsible for triggering navigation to our component under test, we eliminate the need to navigate through the Router service.

With this expanded scope, we add the routing component to the default test route and rely on the Router#initialNavigation method to establish our starting point.

The RouterTestingModule enables integration with the real Router service—but more importantly, for the case study in this article, it allows our tests to exercise the component interacting with the actual ActivatedRoute service.

One intriguing aspect of the HeroDetailComponent is that it functions both as a routed component and a routing component. While it's the destination of a dynamic route, it also uses the Location service to navigate back to the DashboardComponent in the Tour of Heroes application, making it a routing component as well.

To test this additional behavior, apply the techniques described in "Testing Angular routing components with the RouterTestingModule."

Acknowledgments

I want to express my sincere gratitude to you! Your engagement with my earlier article, "Testing Angular routing components with the RouterTestingModule," motivated me to delve deeper into the RouterTestingModule.

Further reading

For insights into the RouterTestingModule and testing routing components, see "Testing Angular routing components with the RouterTestingModule."

To learn how to mock routing data and stub services for testing Angular route guards in isolation, as well as verifying them in practice with the RouterTestingModule, consult "Testing Angular route guards with the RouterTestingModule."