Getting a component under test when routing is involved has traditionally meant wrestling with mocks. The documentation around RouterTestingModule and provideLocationMock has never been stellar, so this has been a common source of friction.

Community-driven solutions, such as Spectacular by @layzee, have stepped in to fill the gap.

Spectacular by ngworkers | Spectacular

Spectacular Angular integration testing. Flexible when you need it. Specialized test harnesses. Faster than end-to-end tests.

favicon ngworker.github.io

This guide walks through the RouterTestingHarness and demonstrates how it allows for reliable tests without mocking any part of the Router.

For those who prefer video content, the following resource is available:

Angular 16.2 opened the door to retrieving route parameters via @Input. Then, in version 17.1, Signal Inputs were introduced, offering the input function as an alternative pathway.

The question of how to write tests without excessive mocking remains relevant.

The answer has been present since Angular 15.2, with the release of the RouterTestingHarness. It simplifies the process of testing in a routing context considerably.

Here is the component we intend to test:

@Component({
  selector: 'app-detail',
  template: `<p>Current Id: {{ id() }}</p>`,
  standalone: true,
})
export class DetailComponent {
  id = signal(0);

  constructor() {
    inject(ActivatedRoute)
      .paramMap.pipe(takeUntilDestroyed())
      .subscribe((paramMap) => 
        this.id.set(Number(paramMap.get('id') || '0')));
  }
}
Enter fullscreen mode Exit fullscreen mode

This harness takes over the role of the standard TestBed::createComponent pattern. It instantiates the component but places it within a simulated "testing routing context."

Harnesses have found popularity in Angular Material. They are helper classes designed to make testing more ergonomic by taking charge of certain asynchronous processes and change detection triggering.

A prerequisite exists: all harness commands are asynchronous. Consequently, tests that use them will be structured with async/await.

Just like any routing scenario, a configuration is necessary. That setup looks like this:

describe('Detail Component', () => {
  it('should test verify the id is 5', waitForAsync(async () => {
    TestBed.configureTestingModule({
      providers: [
        provideRouter([{ path: 'detail/:id', component: DetailComponent }]),
      ],
    });
 }));
});
Enter fullscreen mode Exit fullscreen mode

We then create an instance of RouterTestingHarness and use it to navigate to "/detail/5".

const harness = await RouterTestingHarness.create('detail/5');
Enter fullscreen mode Exit fullscreen mode

Behind the scenes, the component's subscription to the route is executed, and the template should already be displaying an id value of 5:

const p: HTMLParagraphElement = harness.fixture.debugElement.query(
  By.css('p'),
).nativeElement;

expect(p.textContent).toBe('Current Id: 5');
Enter fullscreen mode Exit fullscreen mode

The scope of the test can be expanded from here. Sticking with the same route, we could, for instance, switch to a different id.

That is straightforward with the RouterTestingHarness. For the sake of completeness, the entire test code is provided below:

describe('Detail Component', () => {
  it('should test verify the id is 5', waitForAsync(async () => {
    TestBed.configureTestingModule({
      providers: [
        provideRouter([{ path: 'detail/:id', component: DetailComponent }]),
      ],
    });

    const harness = await RouterTestingHarness.create('detail/5');

    const p: HTMLParagraphElement = harness.fixture.debugElement.query(
      By.css('p'),
    ).nativeElement;

    expect(p.textContent).toBe('Current Id: 5');

    await harness.navigateByUrl('detail/6');
    expect(p.textContent).toBe('Current Id: 6');
  }));
});
Enter fullscreen mode Exit fullscreen mode

Notice that no manual change detection call or any other action was required when navigating to "/detail/6". The Harness internally took care of every detail. The sole responsibility on our end is to ensure we include the await keyword.

Using the RouterTestingHarness offers a clear advantage over mocking the ActivatedRouter.

Mocking entities we don't own, like ActivatedRouter, is a gamble. There's no guarantee that the mock's behavior will match the intricacies of the actual implementation.

The genuine object might carry out asynchronous work, trigger change detection, or perform other internal actions that are not immediately obvious. This creates many potential pitfalls.

It is safer to let internal functions operate within their own domain.


Find the repository at https://github.com/rainerhahnekamp/how-do-i-test

If you have a specific testing problem you would like to see covered here, feel free to reach out.

For more updates, connect with me on LinkedIn or X, and check out our website for details on workshops and consulting services.