The Role of the RouterTestingModule in Component Testing
When testing Angular applications, the RouterTestingModule serves a specialized purpose: it enables us to verify that routing components behave correctly without coupling our tests to the real browser navigation stack.
Routing components—whether they are navigation menus, elements containing RouterLink directives, or components invoking Router#navigate—all rely on the router to trigger URL changes. The RouterTestingModule provides a controlled environment for these scenarios.
Consider the DashboardComponent from the Tour of Heroes tutorial found on Angular.io. This component participates in the show hero detail navigation flow, which works as follows:
- A user selects one of the top heroes displayed on the dashboard.
- The application then routes the user to the hero detail page.
Understanding the Location Service's Purpose
Before diving into the RouterTestingModule, we need to understand the Location service from @angular/common, its own dependencies, and how it fits into the router's architecture.
Router service through the Location service and to the browser APIs.In the dependency diagram, the darker boxes represent the dependency injection tokens, while the lighter inner boxes indicate what gets provided when you import the BrowserModule and RouterModule.
The Router service listens to PopStateEvents, which come from the @angular/common package. The interface for these events is shown in Listing 1. To receive these notifications, the router delegates to the Location service.
interface PopStateEvent {
pop?: boolean;
state?: any;
type?: string;
url?: string;
}
PopStateEvent interface from @angular/common.Essentially, a PopStateEvent takes a native browser event—either popstate or hashchange—and enhances it with extra information. This metadata helps the Angular router determine which route should become active.
Whenever navigation is triggered—through Router#navigate, Router#navigateByUrl, or by clicking a RouterLink—the router determines the target route and then instructs the Location service to update the browser's history stack.
Figure 2 demonstrates that the Location service itself doesn't interact with browser APIs directly. It relies on two other abstractions:
LocationStrategywhich chooses between path-based and hash-based URL handling.PlatformLocationwhich directly accesses browser APIs to read URL components via the Location API or to listen for history and hash changes via the History API.
What the RouterTestingModule Really Provides
Armed with a clear picture of the Location service and its chain of dependencies, we can now examine what the RouterTestingModule contributes through dependency injection.
Router service to SpyLocation when using the RouterTestingModule.Creating test doubles for every single one of those dependencies in each test would be wildly impractical. Instead, the router testing module supplies a fake location service named SpyLocation, which you can see in Figure 3.
The name SpyLocation can be misleading because it doesn't actually contain any test spies. This is actually a deliberate design choice: if the implementation used Jasmine spies, the module would be locked into the Jasmine testing framework. By avoiding spies, the module remains framework-agnostic.
The module also provides a fake LocationStrategy implementation called MockLocationStrategy, though it won't be covered here.
You might wonder why a fake LocationStrategy is necessary at all, since SpyLocation has no dependency on it. The answer lies in the RouterLink directive, which does depend on LocationStrategy, as Figure 4 shows.
RouterLink directive to the SpyLocation and MockLocationStrategy services when using RouterTestingModule.This coupling is likely an artifact of history: some of the services in the dependency chain were introduced after the RouterLink directive was already in existence. From a design perspective, one could argue that RouterLink should depend on the Location service rather than the lower-level LocationStrategy.
Figure 2 also highlights a comparable situation with the Location service, which requires both LocationStrategy and PlatformLocation.
The Need to Fake Browser APIs
Why not just use the actual History and Location APIs during integration tests? The reason becomes clear when we consider how Angular tests are executed.
Angular's default test runner is Karma. Karma launches one or more real browsers, loads your test suite onto a dedicated test page, and reports results. If your test triggers a navigation that isn't faked, the browser would physically navigate away from that Karma page, breaking the entire test run.
Furthermore, other test environments might not even expose the History or Location APIs. Due to these constraints, it's safer to use the RouterTestingModule in place of the RouterModule for all integration tests involving navigation.
The API of the SpyLocation Service
As established, SpyLocation acts as a fake for the Location service in integration tests. It abstracts the relevant browser APIs and eliminates the need to manually mock LocationStrategy, PlatformLocation, or DomAdapter.
This article won't detail the full Location API, since that service is essentially an abstraction over the browser's Location and History APIs.
Beyond the public API it inherits from Location, SpyLocation exposes the following members:
setBaseHref(url: string): voidsetInitialPath(url: string): voidsimulateHashChange(pathname: string): voidsimulateUrlPop(pathname: string): voidurlChanges: string[]
These are listed for completeness, but we generally won't need to interact with them directly in our tests. They exist primarily to support Angular's own router integration tests. In our own test suites, we can achieve our goals using the router service, the RouterLink directive, and the public API of the Location service.
Given that, you should avoid referencing the concrete SpyLocation type in your tests. Instead, it's a better practice to code against the interface defined by the Location service:
import { Location } from '@angular/common';
import { TestBed } from '@angular/core/testing';
describe('MyComponent', () => {
// (...)
it('navigates to another route when (...)', () => {
// The type annotation can be left out as it's inferred
// from `TestBed.inject`
const location: Location = TestBed.inject(Location);
// (...)
expect(location.path()).toBe('/some/other/route');
});
});
Location , not SpyLocation.Configuring Routes with withRoutes
The last crucial feature of the RouterTestingModule is its static method for setting up routes, which has the following signature:
withRoutes(routes: Routes, config?: ExtraOptions): ModuleWithProviders<RouterTestingModule>
This method mirrors the signature of RouterModule.forRoot intentionally. It does the same job: registering routes and router options at the root injector level.
Setting Up a Shallow Routing Component Test
The Angular testing guide offers guidance on writing isolated unit tests for routing components. We'll use the same DashboardComponent from the Tour of Heroes as our example.
You can review the component's class and template in Listings 2A and 2B.
<!-- dashboard.component.html -->
<h3>Top Heroes</h3>
<div class="grid grid-pad">
<a
*ngFor="let hero of heroes"
class="col-1-4"
routerLink="/detail/{{hero.id}}"
>
<div class="module hero">
<h4>{{hero.name}}</h4>
</div>
</a>
</div>
<app-hero-search></app-hero-search>
// dashboard.component.ts
import { Component, OnInit } from '@angular/core';
import { Hero } from '../hero';
import { HeroService } from '../hero.service';
@Component({
selector: 'app-dashboard',
templateUrl: './dashboard.component.html',
styleUrls: ['./dashboard.component.css'],
})
export class DashboardComponent implements OnInit {
heroes: Hero[] = [];
constructor(private heroService: HeroService) {}
ngOnInit() {
this.getHeroes();
}
getHeroes(): void {
this.heroService
.getHeroes()
.subscribe(
heroes => (this.heroes = heroes.slice(1, 5))
);
}
}
This component qualifies as a routing component because its template contains a RouterLink directive, which is its mechanism for initiating navigation.
Before tackling a full integration test, would do well to create a shallow component test. Shallow tests render the component's own view but stub out its child components.
Test Utilities for a Shallow Test
To begin, we'll need the test utilities defined in Listing 3A.
// dashboard.component.spec.ts
import {
Directive,
HostListener,
Input,
} from '@angular/core';
import {
ComponentFixture,
tick,
} from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { Router } from '@angular/router';
import { DashboardComponent } from './dashboard.component';
@Directive({
selector: '[routerLink]',
})
class RouterLinkSpy {
@Input()
routerLink = '';
constructor(private router: Router) {}
@HostListener('click')
onClick() {
this.router.navigateByUrl(this.routerLink);
}
}
const leftMouseButton = 0;
describe('DashboardComponent (shallow)', () => {
function advance() {
tick();
fixture.detectChanges();
}
function clickTopHero() {
const firstLink = fixture.debugElement.query(By.css('a'));
firstLink.triggerEventHandler('click', {
button: leftMouseButton,
});
}
});
The advance utility is responsible for stabilizing the test environment. It does this by flushing the NgZone task queues and then running change detection. Since it relies on tick, advance must be called from within a block wrapped by the fakeAsync function.
The clickTopHero utility finds the first RouterLink in the rendered DOM and simulates a click event on it.
The FakeRouterLink directive is our stand-in for the real RouterLink. It has no external dependencies besides the Router#navigateByUrl method, which keeps the component isolated from the actual router implementation.
Configuring the Test Module
Let's turn our attention to the test setup and the variables declared in Listing 3B.
// dashboard.component.spec.ts
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import {
ComponentFixture,
fakeAsync,
TestBed,
} from '@angular/core/testing';
import { Router } from '@angular/router';
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';
describe('DashboardComponent (shallow)', () => {
beforeEach(async () => {
const fakeService = {
// [1]
getHeroes() {
return of([...HEROES]).pipe(observeOn(asapScheduler));
},
} as Partial<HeroService>;
routerSpy = jasmine.createSpyObj('Router', [
'navigateByUrl',
]); // [2]
TestBed.configureTestingModule({
declarations: [
DashboardComponent, // [3]
FakeRouterLink, // [3]
],
providers: [
{ provide: HeroService, useValue: fakeService }, // [1]
{ provide: Router, useValue: routerSpy }, // [2]
],
schemas: [CUSTOM_ELEMENTS_SCHEMA], // [4]
});
await TestBed.compileComponents();
});
beforeEach(fakeAsync(() => {
fixture = TestBed.createComponent(DashboardComponent);
component = fixture.componentInstance;
advance(); // [5]
advance(); // [6]
}));
let component: DashboardComponent;
let fixture: ComponentFixture<DashboardComponent>;
let routerSpy: jasmine.SpyObj<Router>;
});
First, a fake HeroService (1) is defined to give the dashboard component access to predetermined data. In parallel, the real Router is swapped out for a Jasmine spy object (2).
In a shallow test, the testing module only declares the component itself and the FakeRouterLink (3). Additionally, the CUSTOM_ELEMENTS_SCHEMA is kept in place to prevent errors from any unknown elements in the component's shadow DOM (4).
The first call to advance (5) kicks off the initial change detection cycle, which in turn triggers the component's OnInit lifecycle hook. During ngOnInit, the component invokes the HeroService to fetch its data. A second call to advance (6) then processes the observable emission from the service and runs a fresh round of change detection.
For clarity, the setup also defines the component and fixture variables to hold the component instance and its test fixture, respectively, along with the routerSpy variable for the Jasmine spy provided as the Router service.
The Shallow Test Case
// dashboard.component.spec.ts
import { fakeAsync } from '@angular/core/testing';
describe('DashboardComponent (shallow)', () => {
it('navigates to hero detail when a hero link is clicked', fakeAsync(() => {
const [topHero] = component.heroes;
clickTopHero(); // [1]
advance(); // [2]
const expectedPath = '/detail/' + topHero.id;
const [actualPath] =
routerSpy.navigateByUrl.calls.first().args; // [3]
expect(actualPath).toBe(
expectedPath,
'must navigate to the detail view for the top hero'
);
}));
});
The actual test case for routing is displayed in Listing 3C. In this scenario, the test begins by simulating a click on the top hero (1), which is supposed to initiate navigation. After this trigger, the test waits for the fixture to settle by running pending asynchronous tasks (2).
To verify that navigation occurred as expected, the test queries the routerSpy for the URL argument that was passed to Router#navigateByUrl (3). This was the method called by our FakeRouterLink. The final assertion checks that this URL matches the one we anticipated.
Testing routing components with a shallow test suite
Listing 4 presents the complete shallow component routing test suite for easy reference.
// dashboard.component.spec.ts
import {
CUSTOM_ELEMENTS_SCHEMA,
Directive,
HostListener,
Input,
} from '@angular/core';
import {
ComponentFixture,
fakeAsync,
TestBed,
tick,
} from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { Router } from '@angular/router';
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';
@Directive({
selector: '[routerLink]',
})
class FakeRouterLink {
@Input()
routerLink = '';
constructor(private router: Router) {}
@HostListener('click')
onClick() {
this.router.navigateByUrl(this.routerLink);
}
}
const leftMouseButton = 0;
describe('DashboardComponent (shallow)', () => {
function advance() {
tick();
fixture.detectChanges();
}
function clickTopHero() {
const firstLink = fixture.debugElement.query(By.css('a'));
firstLink.triggerEventHandler('click', {
button: leftMouseButton,
});
}
beforeEach(async () => {
const fakeService = {
getHeroes() {
return of([...HEROES]).pipe(observeOn(asapScheduler));
},
} as Partial<HeroService>;
routerSpy = jasmine.createSpyObj('Router', [
'navigateByUrl',
]);
TestBed.configureTestingModule({
declarations: [DashboardComponent, FakeRouterLink],
providers: [
{ provide: HeroService, useValue: fakeService },
{ provide: Router, useValue: routerSpy },
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
});
await TestBed.compileComponents();
});
beforeEach(fakeAsync(() => {
fixture = TestBed.createComponent(DashboardComponent);
component = fixture.componentInstance;
advance();
advance();
}));
let component: DashboardComponent;
let fixture: ComponentFixture<DashboardComponent>;
let routerSpy: jasmine.SpyObj<Router>;
it('navigates to hero detail when a hero link is clicked', fakeAsync(() => {
const [topHero] = component.heroes;
clickTopHero();
advance();
const expectedPath = '/detail/' + topHero.id;
const [actualPath] =
routerSpy.navigateByUrl.calls.first().args;
expect(actualPath).toBe(
expectedPath,
'must navigate to the detail view for the top hero'
);
}));
});
DashboardComponent.You can find the full test suite in this Gist.
One takeaway from this example: route paths embedded directly in templates signal a code smell. When magic strings appear in templates, tests inevitably end up with the same magic strings. This problem becomes more obvious in the integrated routing component test that follows.
For a straightforward solution to this issue, check out Listings 3.1, 3.2, and 3.3 of "Lean Angular components". Alternatively, consider Routeshub by Max Tarsis — a route management library designed to integrate smoothly with the Angular router.
Beyond the shallow test approach, we also want to verify how the dashboard component collaborates with its view children and the actual Router service.
The RouterTestingModule lets us define testing routes and swap out the Location service, so we avoid depending on browser APIs directly, as outlined earlier in this article.
Utilities for the integrated routing component test
// dashboard.component.integration.spec.ts
import { Component, ViewChild } from '@angular/core';
import { tick } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { RouterOutlet } from '@angular/router';
@Component({
template: '<router-outlet></router-outlet>', // [2]
})
class TestRootComponent {
@ViewChild(RouterOutlet)
routerOutlet: RouterOutlet; // [3]
}
@Component({
template: '',
})
class TestHeroDetailComponent {} // [1]
const leftMouseButton = 0;
describe('DashboardComponent (integrated)', () => {
function advance() {
tick();
rootFixture.detectChanges(); // [4]
}
function clickTopHero() {
const firstHeroLink = rootFixture.debugElement.query(
By.css('a')
); // [5]
rootFixture.ngZone.run(() =>
// [6]
firstHeroLink.triggerEventHandler('click', {
button: leftMouseButton,
})
);
}
function getActiveComponent<T>(): T {
return rootComponent.routerOutlet.component as T; // [7]
}
});
Listing 5A contains the helper utilities used in our integrated routing component test for the DashboardComponent from the Tour of Heroes tutorial.
As you'll see shortly, our integrated test mimics a miniature application with only two routed components: the dashboard component under test and a placeholder component (1) that acts as the destination for our routing scenario.
We replace the real HeroDetailComponent with TestHeroDetailComponent, which avoids having to configure any of its dependencies. What matters in this test is the navigation triggered by user interaction within the dashboard component. The actual component resolved by the target route URL is irrelevant for what we're verifying.
Had we wanted to exercise a full user journey — starting at the dashboard, picking a top hero, and viewing its details — we could have included the genuine hero detail component in the test setup. Such a behavior test would be a nice addition. Alternatively, an end-to-end test could provide even greater confidence in the application.
To emulate an Angular application, we need a root component. In production code, this is typically called AppComponent. Since our test doesn't require the behavior of the real AppComponent, we call it TestRootComponent to make its purpose clear — serving as the root of our ComponentFixture.
The test root component includes only a router outlet in its template (2), which it also exposes publicly (3). We'll explain why in a moment.
The advance helper looks familiar, but instead of referencing fixture, it operates on rootFixture (4). That's because this test suite's fixture wraps the test root component, not the component under test.
The clickTopHero helper resembles its counterpart from the shallow test — except it too references rootFixture (5).
Point (6) addresses Angular warnings tied to the Angular zone. As documented in Angular issue #25837, Angular emits a warning when navigation happens outside a test case — typically within beforeEach hooks.
To fix this, we wrap route navigation in a callback (6) and pass it to NgZone#run, ensuring execution happens within the Angular zone.
The getActiveComponent helper retrieves the active component via the test root component's router outlet (7).
Configuring the integrated routing component test
The setup in Listing 5B substitutes the hero service with the same fake service used previously (1).
// dashboard.component.integration.spec.ts
import { Location } from '@angular/common';
import {
ComponentFixture,
fakeAsync,
TestBed,
} from '@angular/core/testing';
import { Router } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { asapScheduler, of } from 'rxjs';
import { observeOn } from 'rxjs/operators';
import { HeroSearchComponent } from '../hero-search/hero-search.component';
import { HeroService } from '../hero.service';
import { HEROES } from '../mock-heroes';
import { DashboardComponent } from './dashboard.component';
describe('DashboardComponent (integrated)', () => {
beforeEach(async () => {
const fakeService = {
// [1]
getHeroes() {
return of([...HEROES]).pipe(observeOn(asapScheduler));
},
} as Partial<HeroService>;
TestBed.configureTestingModule({
declarations: [
TestRootComponent, // [2]
TestHeroDetailComponent, // [2]
DashboardComponent, // [3]
HeroSearchComponent, // [3]
],
imports: [
RouterTestingModule.withRoutes([
// [5]
{
path: '',
pathMatch: 'full',
component: DashboardComponent,
}, // [4]
{
path: 'detail/:id',
component: TestHeroDetailComponent,
}, // [4]
]),
],
providers: [
{ provide: HeroService, useValue: fakeService }, // [1]
],
});
await TestBed.compileComponents();
rootFixture = TestBed.createComponent(TestRootComponent); // [6]
rootComponent = rootFixture.componentInstance; // [6]
location = TestBed.inject(Location); // [7]
});
beforeEach(fakeAsync(() => {
const router = TestBed.inject(Router);
rootFixture.ngZone.run(() => router.initialNavigation()); // [8]
advance(); // [9]
advance(); // [10]
}));
let location: Location;
let rootComponent: TestRootComponent; // [6]
let rootFixture: ComponentFixture<TestRootComponent>; // [6]
});
Within the Angular testing module, we declare the fake root component and the dummy hero detail replacement discussed above (2). We also declare the dashboard component and the HeroSearchComponent (3), since the latter appears as a view child in the dashboard template.
To finish configuring the testing module, we define fake routes for the dashboard component and the placeholder target component (4) using RouterTestingModule.withRoutes (5).
We initialize rootFixture and rootComponent by invoking TestBed.createComponent(TestRootComponent) and reading the ComponentFixture#componentInstance property (6).
The location variable gets initialized by injecting the Location service (7). As mentioned earlier, this resolves to SpyLocation — though we should only rely on the Location API in routing component tests.
In the second test case setup hook, we navigate to the default route after bootstrapping the simulated app by calling Router#initialNavigation (8). Recall that this needs to be wrapped in a callback passed to NgZone#run to avoid warnings during test execution.
Just like in the shallow routing test, our first advance call (9) triggers the OnInit lifecycle hook in the dashboard component, allowing it to fetch data from the hero service.
A second advance call (10) waits for the heroes observable to emit its first value, then runs change detection to refresh the dashboard component's DOM.
The integrated routing component test case
That was a fair amount of utilities and setup. Now let's look at the actual test case. Hopefully, our preparation pays off with a concise test.
// dashboard.component.integration.spec.ts
import { fakeAsync } from '@angular/core/testing';
import { DashboardComponent } from './dashboard.component';
describe('DashboardComponent (integrated)', () => {
it('navigates to the detail view when a hero link is clicked', fakeAsync(() => {
const component: DashboardComponent =
getActiveComponent(); // [1]
const [topHero] = component.heroes;
clickTopHero();
advance();
const expectedPath = '/detail/' + topHero.id;
expect(location.path() /* [2] */).toBe(
expectedPath,
'must navigate to the detail view for the top hero'
);
}));
});
The integrated test case in Listing 5C closely resembles the shallow test from Listing 3C, with two key differences:
- Because the fixture wraps
TestRootComponent, we usegetActiveComponentto gain access to theDashboardComponent. - Instead of a
Routerspy object to inspect arguments passed toRouter#navigateByUrl, we callLocation#pathto see the URL path as it would appear in a real browser.
Full integrated routing component test suite
Listing 6 provides the entire test suite for reference.
// dashboard.component.integration.spec.ts
import { Location } from '@angular/common';
import { Component, ViewChild } from '@angular/core';
import {
ComponentFixture,
fakeAsync,
TestBed,
tick,
} from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { Router, RouterOutlet } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { asapScheduler, of } from 'rxjs';
import { observeOn } from 'rxjs/operators';
import { HeroSearchComponent } from '../hero-search/hero-search.component';
import { HeroService } from '../hero.service';
import { HEROES } from '../mock-heroes';
import { DashboardComponent } from './dashboard.component';
@Component({
template: '<router-outlet></router-outlet>',
})
class TestRootComponent {
@ViewChild(RouterOutlet)
routerOutlet: RouterOutlet;
}
@Component({
template: '',
})
class TestHeroDetailComponent {}
const leftMouseButton = 0;
describe('DashboardComponent (integrated)', () => {
function advance() {
tick();
rootFixture.detectChanges();
}
function clickTopHero() {
const firstHeroLink = rootFixture.debugElement.query(
By.css('a')
);
rootFixture.ngZone.run(() =>
firstHeroLink.triggerEventHandler('click', {
button: leftMouseButton,
})
);
}
function getActiveComponent<T>(): T {
return rootComponent.routerOutlet.component as T;
}
beforeEach(async () => {
const fakeService = {
getHeroes() {
return of([...HEROES]).pipe(observeOn(asapScheduler));
},
} as Partial<HeroService>;
TestBed.configureTestingModule({
declarations: [
TestRootComponent,
TestHeroDetailComponent,
DashboardComponent,
HeroSearchComponent,
],
imports: [
RouterTestingModule.withRoutes([
{
path: '',
pathMatch: 'full',
component: DashboardComponent,
},
{
path: 'detail/:id',
component: TestHeroDetailComponent,
}, // [1]
]),
],
providers: [
{ provide: HeroService, useValue: fakeService },
],
});
await TestBed.compileComponents();
rootFixture = TestBed.createComponent(TestRootComponent);
rootComponent = rootFixture.componentInstance;
location = TestBed.inject(Location);
});
beforeEach(fakeAsync(() => {
const router = TestBed.inject(Router);
rootFixture.ngZone.run(() => router.initialNavigation());
advance();
advance();
}));
let location: Location;
let rootComponent: TestRootComponent;
let rootFixture: ComponentFixture<TestRootComponent>;
it('navigates to the detail view when a hero link is clicked', fakeAsync(() => {
const component: DashboardComponent =
getActiveComponent();
const [topHero] = component.heroes;
clickTopHero();
advance();
const expectedPath = '/detail/' + topHero.id; // [2]
expect(location.path()).toBe(
expectedPath,
'must navigate to the detail view for the top hero'
);
}));
});
Similar to the shallow test, magic strings appear here too — representing the hero detail route — but this time in two separate locations:
- Our fake target route must match the route URL embedded in the dashboard component template.
- As with the shallow test, the expected path in the test case must also align with the route defined in the dashboard template.
The complete test suite is available in this Gist.
I hope you enjoyed learning how the Router collaborates with the browser through a chain of dependencies, beginning with the Location service.
We explored two ways to test a routing component: the shallow approach and the integrated approach. Both have their place, so you don't have to choose one exclusively. If I had to pick, I'd lean toward the integrated routing component test — it covers more ground, offers higher confidence, and requires fewer custom test doubles.
So what exactly did we verify in our routing component test suite?
We validated the show hero detail flow from the dashboard: when a user clicks a top hero, the app navigates to the hero detail view.
Let's close by summarizing what we covered across all these topics.
Shallow routing component tests
For Angular routing components, a shallow test avoids routing entirely by placing the component under test at the root of the fixture.
This approach requires creating a spy object for the Router service. We also isolate the component from data services by swapping in fake implementations.
In shallow tests, we rely on the test bed and a component fixture. We simulate user interaction by clicking the component's DOM, which triggers navigation. To confirm navigation happened, we inspect the arguments passed to our router spy.
Integrated routing component tests
As an alternative — or complement — to the shallow test, we can create an integrated routing component test.
In this approach, we simulate an Angular application by creating a fake root component with a primary router outlet. This outlet gives us a way to retrieve the active component at any point during the test.
Alongside the fake root component, we declare the component under test, its view children, and a dummy component to serve as the route target.
Using RouterTestingModule.withRoutes, we define a default route pointing to our component under test and a target route for the dummy component. That target route must match the one used in a router link directive or Router#navigateByUrl.
To trigger navigation, we query the component fixture's debug element for a specific element and activate it.
After navigation completes, we call Location#path to retrieve the path as it would appear in a browser's address bar. Finally, we compare this value to the expected target route.
Inside the RouterTestingModule
We have covered how the Location service in Angular, along with its supporting dependencies, provides an abstraction layer over the native Location and History browser APIs, as well as the popstate and hashchange events.
Router service to SpyLocation when using the RouterTestingModule.In Figure 3, you can see that the RouterTestingModule swaps in the SpyLocation service for Angular's standard Location implementation. This substitution avoids triggering genuine navigation during tests. Such navigation can be especially problematic in environments like the Karma test runner, where some browser APIs may not be fully available.
For most component tests involving routing, the extended surface of SpyLocation is not necessary. We continue to hold the variable as the Location type even when we pull a SpyLocation instance from the injector using the Location token, which confirms that the standard interface is sufficient.
The specialized API that SpyLocation offers is typically only used within the internal test suite for the Router itself.
RouterLink directive to the SpyLocation and MockLocationStrategy services when using RouterTestingModule.Figure 4 demonstrates another key provisioning detail: the RouterTestingModule must supply MockLocationStrategy against the LocationStrategy token. This is required because the RouterLink directive calls LocationStrategy#prepareExternalUrl rather than the analogous method on the Location service — likely a legacy design decision.
Acknowledgements
I appreciate you taking the time to read this piece. Your support means a lot, and it has been a genuine pleasure to write and share this knowledge. The preparation process taught me quite a bit as well.
Further reading
To go deeper into the RouterTestingModule and its role in testing routed components, take a look at the guide "Testing routed Angular components with the RouterTestingModule".
You can also learn how to simulate routing data and stub out services to unit test Angular route guards, and see how to verify them using the RouterTestingModule, in the article "Testing Angular route guards with the RouterTestingModule".
Reviewers
This article was reviewed by the following contributors:
Many thanks to both of you.




