Original cover photo by Dele Oke on Unsplash.
Original publication date: 2020-05-25.
One of the use cases for Angular's
RouterTestingModule is to test Angular routing components.
An Angular routing component is a component that is used to trigger application navigation. It could be a navigation menu component, a component with one or more
RouterLink directives, or it could be a component that calls
In this article, we're going to explore what the
RouterTestingModule does and how we can use it to test routing components.
As a case study, we write routing component tests for the
DashboardComponent from the Tour of Heroes tutorial on Angular.io. This routing is part of the show hero detail use case as shown in Figure 1:
- The user clicks a top hero in the dashboard.
- The application navigates to the hero detail.
To learn what Angular's
RouterTestingModule does, we first have to learn about Angular's
Location service, its dependencies and how it's related to the Angular router.
Figure 2 illustrates the flow of dependencies from the
Router service through the
Location service and all of its dependencies all the way down to the browser APIs.
The dark box names the the dependency injection symbol. The inner light box names the dependency that is provided when using the
RouterModule Angular modules.
Router service subscribes to the
PopStateEvents which have the interface listed in Listing 1. It uses the
Location service to be notified of these events.
PopStateEvent wraps a native
hashchange browser event and enriches it with metadata that the Angular router uses to identify which route to activate.
Router#navigateByUrl is called or a
RouterLink directive is activated, the router figures out which route to activate and uses the
Location service to replace the browser's history state stack.
From Figure 2 we can tell that the
Location service itself delegates work to other Angular services. The concrete
LocationStrategy services are used to decide between path- or hash-based navigation. The concrete
PlatformLocation interacts with browser APIs to query parts of the URL through the Location API or listen for history stack state changes or hash changes through the History API.
Now that we know the basics of how the
Router is related to the
Location service and in turn the browser APIs, we can explore what the
RouterTestingModule does in terms of dependency injection.
Believe me, we wouldn't want to create test doubles for all those dependencies in our tests. The router testing Angular module provides a fake location service called
SpyLocation as illustrated in Figure 3.
Unfortunately, the name is a bit confusing as it doesn't include any test spies. On the other hand it's good that for example no Jasmine spies were used to implement the service as we wouldn't be able to use other testing frameworks than Jasmine if that was the case.
The router testing Angular module also provides a fake version of
MockLocationStrategy which we won't discuss further in this article.
You would think that this shouldn't be necessary seeing that
SpyLocation doesn't depend on a
LocationStrategy service at all.
RouterLink depends on
LocationStrategy as illustrated in Figure 4.
This dependency probably exists for historical reasons as some of the dependencies listed in Figure 2 were introduced later than the router link directive itself. It should really be depending on the
Location service rather than the
Figure 2 reveals that there's a similar issue with the
Location service which depends on both the
Faking the browser APIs
Why do we need to replace the History API and Location API dependency with a fake? Couldn't we just use the real APIs in our integration tests?
Angular uses Karma out-of-the-box. Karma is a test runner that drives one or more browsers, instruments them and instructs them to load our integration tests.
If we don't use the
RouterTestingModule for integration tests that involve navigation, the browser would navigate away from the Karma test page. This would fail our entire test suite.
For other test runners and frameworks, the History and Location APIs might not even be available. Because of this, we should prefer the
RouterTestingModule instead of the
RouterModule in all of our integration tests.
We already learned that
SpyLocation is a fake implementation of the
Location service for integration tests that abstracts away certain browser APIs and removes the need for providing fake services to replace
DomAdapter in tests.
We won't cover the API of the
Location service itself in this article. As we have learned, it's an abstraction on top of the Location and History browser APIs.
SpyLocation has these methods and properties in addition to the public API of
setBaseHref(url: string): void
setInitialPath(url: string): void
simulateHashChange(pathname: string): void
simulateUrlPop(pathname: string): void
I list them here for reference, but we won't discuss them further as they're created specifically for the Angular router's integration tests. In our tests, we should be able to perform the actions we need through the router service, the router link directive and the public API of the
As this is the case, we should have no need to use the
SpyLocation type in our tests. Instead, we prefer only relying on the interface defined by
Location's public API like this:
The final piece of the
RouterTestingModule that we need to learn is that it has a static method for providing routes:
withRoutes(routes: Routes, config?: ExtraOptions): ModuleWithProviders<RouterTestingModule>
withRoutes method has the same signature as
RouterModule.forRoot. This is not a coincidence. It provides routes and router options for the root level injector.
The Angular testing guide shows us how to create isolated unit tests around a routing component. The component under test is the
DashboardComponent from the Tour of Heroes tutorial.
The component model and component template are shown in Listings 2A and 2B.
The dashboard component is a routing component because it uses a router link directive to trigger navigation.
Before we discuss an integration test, let's create a shallow component test that renders the component's view, but not its view children.
First, we look at the test utilities in Listing 3A.
advance function flushes the
NgZone queues and runs change detection to stabilize the testing environment. As it uses
tick, it has to be called from within a
clickTopHero function queries for the first link in the component's DOM and then triggers a fake mouse click on it.
FakeRouterLink directive is a router link replacement without other dependencies than
Router#navigateByUrl. We isolate the component from as many dependencies as possible.
Now let's look at our test setup and variables which are listed in Listing 3B.
First, we create a fake
HeroService (1) to supply the dashboard component with fake data. We replace the
Router service with a simple Jasmine spy object (2).
As this is a shallow component test, we only declare the component itself and the fake router link (3) we discussed a moment ago. We use the
CUSTOM_ELEMENTS_SCHEMA to enable shallow rendering (4).
advance call (5) triggers the initial change detection cycle which triggers
OnInit lifecycle moment. The
ngOnInit lifecycle hook of the dashboard component reads heroes via the
HeroService. The second
advance call (6) waits for the heroes observable to emit a value, then triggers change detection.
Finally, we see the
component variable for the component instance, the
fixture variable for the component fixture, and the
routerSpy variable for a Jasmine spy object that is provided to replace the
Router service in our test suite.
Listing 3C shows the test case that exercises routing for the dashboard component. First, we click the top hero link (1) to trigger navigation, then we wait for the component fixture to stabilize (2).
We query the router spy for the route URL passed to
Router#navigateByUrl (3). This was done by the
FakeRouterLink directive. Finally, we assert that the passed route URL matches what we expected.
Listing 4 shows the shallow component routing test suite in its full length for reference.
As a finishing thought on this example, this test case shows us that having route paths hidden inside templates is a code smell. Magic strings in templates force us to have magic strings in tests. This will become even more apparent in the integrated routing component test.
See Listings 3.1, 3.2, and 3.3 of "Lean Angular components" for a simple example of solving this issue or try out Routeshub by Max Tarsis. Routeshub is a route management library that integrates easily with the Angular router.
In addition to the shallow routing component test, we want to cover how the dashboard component integrates with its view children and the real
We'll use the
RouterTestingModule to set up testing routes and replace the
Location service to abstract away the browser APIs as discussed earlier in this article.
Listing 5A shows the test utilities we use for our integrated routing component test of the
DashboardComponent from the Tour of Heroes tutorial.
As we'll see in a minute, our integrated tests simulates a tiny application in which the only two routed components are our component under test, the dashboard component, and a dummy component (1) which will serve as the target of our routing component.
We'll replace the real
TestHeroDetailComponent so that we don't have to set up any of its dependencies. What we're exercising here is the routing that is initiated by the user through the dashboard component. Which actual component is targeted by that route URL is not important for the purpose of this test.
If we wanted an integration test that exercised a full use case starting at the dashboard, selecting a top hero and looking up its hero details, we could include the real hero detail component in our test setup. That would actually be a nice behaviour test to include. We could also choose to implement it as and end-to-end test for even more confidence in our application.
To simulate an Angular application, we're going to need a root component. In a real application, this component is conventionally called
AppComponent. As the test doesn't need it to behave like the actual
AppComponent of our application, we name it
TestRootComponent to indicate its purpose – to be the root component of our
The test root component only has a router outlet in its template (2) which it exposes as a public property (3). We'll discuss why shortly.
advance test utility looks familar, but refers to a variable called
rootFixture (4) instead of
fixture. This is because the component fixture in this test suite refers to the test root component, not the component under test.
clickTopHero test utility also looks very similar to the one from our shallow component routing test. However, this version also refers to the
(6) fixes Angular warnings related to the Angular zone. As Angular issue #25837 discusses, Angular outputs a warning when we trigger navigation outside of a test case – usually in
To address this, we wrap route navigation in a callback (6) which we pass to
NgZone#run to execute it inside the Angular zone.
getActiveComponent test utility gets the active component through the test root component's router outlet (7).
The test setup in Listing 5B replaces the hero service with the same fake service (1).
In the Angular testing module, we declare the fake root component and the dummy hero detail component replacement which we discussed before (2). We additionally declare the dashboard component and the
HeroSearchComponent (3) as it's a view child used in the dashboard component template.
The final part of configuring the Angular testing module is to add fake routes for the dashboard component and the dummy target component (4) by using
We initialise the
rootComponent variables by calling
TestBed.createComponent(TestRootComponent) and by getting the
ComponentFixture#componentInstance property (6).
location variable is initialised by injecting the
Location service (7) which – as we know – will resolve to the
SpyLocation service. However, we discussed earlier that we should only depend on the
Location API in routing component tests.
In the second test case setup hook, we navigate to the default route after initialising the simulated application by calling
Router#initialNavigation (8). As we learned, we need to wrap this in a callback and pass it to
NgZone#run to prevent warnings when running this test suite.
As in the shallow component routing test, our first
advance function call (9) triggers the
OnInit lifecycle moment and the equivalent lifecycle hook in the dashboard component which resolves data from the hero service.
advance function call (10) waits for the heroes observable to emit its first value and then calls changes detection to update the dashboard component's DOM.
I know, that was a lot of utilities and setup. Now, let's move on to the test case. Hopefully, our rigorous preparations enable a concise test case.
The integrated test case in Listing 5C looks surprisingly similar to the shallow test case in Listing 3C with a few exceptions:
- As the component fixture wraps the
TestRootComponent, we use the
getActiveComponentto access the
- This time we don't have a
Routerservice spy object to ask for arguments to
Router#navigateByUrl. Instead, we call
Location#pathto see the URL path as it would appear in a browser at runtime.
Listing 6 shows the full test suite for reference.
Like in the shallow routing component test, we see magic strings in use, representing the hero detail route, but this time in two places:
- Our fake target route has to match the route URL specified in the dashboard component template.
- As in the shallow routing component test, the expected path in our test case also has to match the one specified in the dashboard component template.
I hope that you enjoyed learning about how the
Router interacts with the browser through a series of dependencies, starting with the
We discussed how to test a routing component both by using a shallow component test and an integrated component test approach. Both can be useful, so we don't necessarily have to pick one over the other. If we have to choose, I would prefer an integration routing component test as it covers more ground, gives a higher level of confidence and requires less custom test doubles.
What did we test in our routing component test suite?
We tested the show hero detail use case from the dashboard: When the user clicks a top hero in the dashboard, the application navigates to the hero detail.
Let's finish by summing up what we learned about all of these topics.
When testing an Angular routing component, we can create a shallow component test which doesn't need routing as it renders our component under test as the root component.
For shallow routing component tests, we need to create a spy object representing the
Router service. We also isolate our component from data services by replacing them with fake services.
In shallow routing component tests, we use the test bed and a component fixture to test as a user by clicking the component DOM which triggers navigation to a different route. To verify this navigation, we ask our router spy for arguments passed to it.
Instead of – or in addition to – a shallow routing component test, we can create an integrated routing component test.
In an integrated routing component test, we simulate an Angular application by creating a fake root component with a primary router outlet which we can use to access the active component at any given time during our test case.
In addition to the fake root component, we declare the component under test, its view child components and a dummy component to replace our route target.
RouterTestingModule.withRoutes to add a default route to our component under test and a target route to the dummy component we declared. This target route must match the route passed to a router link directive or
We use the component fixture's debug element to query for an element and activate it to trigger navigation.
After waiting for navigation to finish, we use
Location#path to query for the path as it would appear in a browser's URL address bar. Finally, we compare this to the expected target route.
We learned how Angular's
Location service and its dependencies abstract away the Location and History APIs as well as native
As seen in Figure 3, the
RouterTestingModule replaces Angular's
Location service with the
SpyLocation service. This prevents tests from trying to navigate which is problematic when using the Karma test runner or a test environment that doesn't have all browser APIs.
For the purpose of routing component tests, we don't need the extra properties and methods introduced by
SpyLocation. This is proven in that we keep the
Location type when resolving a
SpyLocation service instance from the
Location dependency injection symbol.
SpyLocation API should only be required for the
Router's own test suite.
Figure 4 illustrates the need for the
RouterTestingModule to provide
MockLocationStrategy for the
LocationStrategy dependency injection symbol. This is the case as the
RouterLink directive depends on
LocationStrategy#prepareExternalUrl instead of
Location#prepareExternalUrl – probably for historical reasons.
Thank you for reading. I appreciate your support! It's been my pleasure to educate you. I learned a lot myself while preparing this article.
Continue learning about the
RouterTestingModule and how it's used to test routed Angular components in "Testing routed Angular components with the RouterTestingModule".
Learn how to fake routing data and stub services to test Angular route guards in isolation as well as how to verify them in practice using the
RouterTestingModule in "Testing Angular route guards with the RouterTestingModule".
The peer reviewers for this article include:
Thank you, friends!