Case Study: Tour of Heroes Authentication Guard
Before we dive into testing strategies, let's examine the AuthGuard implementation that serves as our example throughout this guide.
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate, CanActivateChild, CanLoad, NavigationExtras, Route, Router, RouterStateSnapshot, UrlSegment } from '@angular/router';
import { AuthService } from './auth.service';
@Injectable({
providedIn: 'root',
})
export class AuthGuard implements CanActivate, CanActivateChild, CanLoad {
constructor(private authService: AuthService, private router: Router) {}
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean {
const url = state.url;
return this.checkLogin(url);
}
canActivateChild(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean {
return this.canActivate(route, state);
}
canLoad(route: Route, segments: UrlSegment[]): boolean {
const url = `/${route.path}`;
return this.checkLogin(url);
}
checkLogin(url: string): boolean {
if (this.authService.isLoggedIn) {
return true;
}
// Store the attempted URL for redirecting
this.authService.redirectUrl = url;
// Create a dummy session id
const sessionId = 123456789;
// Set our navigation extras object
// that contains our global query params and fragment
const navigationExtras: NavigationExtras = {
queryParams: { session_id: sessionId },
fragment: 'anchor',
};
// Navigate to the login page with extras
this.router.navigate(['/login'], navigationExtras);
return false;
}
}
Listing 1. Authentication guard implementation.
This guard enforces three distinct route protection interfaces:
CanActivate: Prevents access to the protected route itself.CanActivateChild: Prevents access to any child routes nested beneath the protected parent.CanLoad: Prevents lazy-loaded feature modules from being fetched when usingloadChildren.
The
CanDeactivateinterface also exists for guarding route exits, but it's typically used for unsaved-changes warnings rather than authentication concerns.
All three interface methods — canActivate, canActivateChild, and canLoad — delegate to a single checkLogin method. This method returns a straightforward boolean, but when authentication fails, it performs several side effects:
- Attaches a session ID to the query parameters.
- Simulates an auth token that external login providers would generally receive.
- Triggers a navigation to the login route with those details included.
While CanActivate and CanActivateChild have supported returning observables or promises that resolve to a UrlTree or boolean for quite some time, CanLoad lacked this capability until Angular 10 arrived.
In principle, AuthGuard#checkLogin could be modernized to return a UrlTree directly and hand over the navigation responsibility to the framework. That refactoring is beyond our scope here, though.
The extended Tour of Heroes sample configures this auth guard on the routes displayed in Listings 2A and 2B.
const appRoutes: Routes = [
{
path: 'admin',
loadChildren: () => import('./admin/admin.module').then((m) => m.AdminModule),
canLoad: [AuthGuard],
},
];
Listing 2A. Extracted root-level route definitions.
const adminRoutes: Routes = [
{
path: '',
component: AdminComponent,
canActivate: [AuthGuard],
children: [
{
path: '',
canActivateChild: [AuthGuard],
children: [
{ path: 'crises', component: ManageCrisesComponent },
{ path: 'heroes', component: ManageHeroesComponent },
{ path: '', component: AdminDashboardComponent },
],
},
],
},
];
Listing 2B. Admin feature module routes.
The guard gets attached to the appropriate routing hooks based on which interface each route declaration should honor.
From Listings 2A and 2B, we can derive three distinct scenarios to verify with a logged-out user:
- With a
CanLoadguard in place, the route stays inaccessible, and its associated feature module remains unloaded. - With a regular
CanActivateguard, navigation to that route is blocked. - With a
CanActivateChildguard, attempts to reach any nested child route are denied.
Conversely, all three situations should grant access without issue when the user is properly authenticated.
Testing the Guard in Isolation
We'll start by putting together a standalone test suite to confirm the guard behaves correctly on its own.
To do that, we need a stand-in for the AuthService that tells the guard about the user's login state. We also need a mock for the Router service that the guard invokes for redirection when authentication fails.
Beyond those mock services, a major hurdle for isolated guard testing lies in the routing data structures each interface accepts. These are the objects the real router constructs and provides:
ActivatedRouteSnapshotRouteRouterStateSnapshotUrlTree
For this case study, we'll construct minimal fakes for these objects — using only the properties we know the guard touches. This keeps our test lean, but it also couples our tests directly to the guard's current implementation details. If AuthGuard changes which properties it reads, our tests will need updating right along with it.
Angular doesn't officially ship factories or helpers for mocking these intricate router types at the moment, which is a known pain point. If this matters to you, consider contributing your thoughts or use cases to this long-standing GitHub feature request.
Test Utilities for the Isolated Approach
To exercise the guard from Listing 1, our suite starts with a custom faking helper, as shown in Listing 3A.
// auth.guard.spec.ts
import { RouterStateSnapshot } from '@angular/router';
function fakeRouterState(url: string): RouterStateSnapshot {
return {
url,
} as RouterStateSnapshot;
}
Listing 3A. Helper for building the fake route state.
Notice that our fake router state only needs to expose a url property — that's the single piece of data AuthGuard#canActivate pulls from the snapshot.
Configuring the Isolated Test Environment
Next, let's set up the test fixtures and shared variables that our isolation suite relies on.
// auth.guard.spec.ts
import { ActivatedRouteSnapshot, Router } from '@angular/router';
import { AuthGuard } from './auth.guard';
import { AuthService } from './auth.service';
describe('AuthGuard (isolated)', () => {
beforeEach(() => {
routerSpy = jasmine.createSpyObj<Router>('Router', ['navigate']); // [1]
serviceStub = {}; // [2]
guard = new AuthGuard(serviceStub as AuthService, routerSpy); // [3]
});
const dummyRoute = {} as ActivatedRouteSnapshot;
const fakeUrls = ['/', '/admin', '/crisis-center', '/a/deep/route'];
let guard: AuthGuard;
let routerSpy: jasmine.SpyObj<Router>;
let serviceStub: Partial<AuthService>;
describe('when the user is logged in', () => {
beforeEach(() => {
serviceStub.isLoggedIn = true;
});
});
describe('when the user is logged out', () => {
beforeEach(() => {
serviceStub.isLoggedIn = false;
});
});
});
Listing 3B. Test setup and shared variables.
Inside the top-level beforeEach callback, we first construct our router double. This spy object only implements a fraction of the actual Router API — just the navigate method (1). We turn it into a jasmine spy right away, allowing it to accept whatever arguments it receives. Our assertions later on will interrogate this spy to verify the navigation calls. This object is assigned to the shared routerSpy variable for all nested test scopes to use.
We initialize the serviceStub as a bare object (2). Again, this relies on our foresight telling us the guard only consults the AuthService#isLoggedIn flag. That flag gets configured within each nested describe block. For example, one suite might set isLoggedIn to true in its own setup hook to simulate an authenticated user.
Finally, the outermost setup hook creates the AuthGuard instance, handing it our stubbed service and mocked router through its constructor (3).
Two other shared variables in Listing 3B warrant a closer look. The dummyRoute object is another minimal stand-in—an empty object passed as the first argument to canActivate and canActivateChild. While these methods formally expect an ActivatedRouteSnapshot, our auth guard never inspects it, so an empty shell suffices.
Finally, fakeUrls stores an array of route URL strings. We'll feed these into the various guard methods to ensure they handle different URL shapes correctly. The array is reused throughout both the authenticated and unauthenticated test groups.
Isolated route guard tests for granted access
The straightforward cases come first. When the user is authenticated, every one of the guard's lifecycle hooks simply has to yield true regardless of the URL being requested, so long as AuthService#isLoggedIn is truthy.
// auth.guard.spec.ts
import { Params, Route, UrlSegment } from '@angular/router';
describe('AuthGuard (isolated)', () => {
describe('when the user is logged in', () => {
fakeUrls.forEach((fakeUrl) => {
// [1]
it('grants access', () => {
const isAccessGranted = guard.checkLogin(fakeUrl); // [2]
expect(isAccessGranted).toBeTrue(); // [2]
});
describe('and navigates to a guarded route configuration', () => {
it('grants route access', () => {
const canActivate = guard.canActivate(dummyRoute, fakeRouterState(fakeUrl)); // [3]
expect(canActivate).toBeTrue(); // [3]
});
it('grants child route access', () => {
const canActivateChild = guard.canActivateChild(dummyRoute, fakeRouterState(fakeUrl)); // [4]
expect(canActivateChild).toBeTrue(); // [4]
});
const paths = fakeUrl.split('/').filter((path) => path !== ''); // [5]
paths.forEach((path) => {
// [6]
it('grants feature access', () => {
const fakeRoute: Route = { path }; // [6]
const fakeUrlSegment = { path } as UrlSegment; // [6]
const canLoad = guard.canLoad(fakeRoute, [fakeUrlSegment]); // [7]
expect(canLoad).toBeTrue(); // [7]
});
});
});
});
});
});
Listing 3C. Isolated route guard test cases covering when access is granted.
A crucial detail in Listing 3C is the loop over all URLs contained in the shared fakeUrls variable (1). The variable fakeUrl serves as the URL under examination during each iteration. Consequently, every individual test executes once for each entry present in fakeUrls.
At point (2), a dummy route is handed to AuthGuard#checkLogin, and we verify the method's return value is true.
When AuthGuard returns true from its route lifecycle hooks, the router permits navigation to protected routes. In (3), the dummy route along with a fabricated router state that encapsulates the dummy route's URL are supplied to AuthGuard#canActivate, and we confirm it evaluates to true.
The AuthGuard#canActivateChild method takes the same set of arguments (4). The expectation here is also a true return value.
Testing AuthGuard#canLoad requires a slightly different approach. This method's first parameter is a Route object, and since a route only holds a URL segment within its path property, we break the fake URL apart into individual segments (5) and construct the appropriate Route and UrlSegment structures (6).
Lastly, these fabricated routing structures are passed to AuthGuard#canLoad, and we assert that it responds with true (7).
Isolated route guard tests for denied access
Next, we turn our attention to the test cases that verify the guard's behavior when access is denied. Since this scenario involves more complexity, we'll divide these tests into two distinct groups for clarity during this explanation.
Listing 3D showcases the tests that verify how the authentication guard redirects an unauthenticated user to the login page.
// auth.guard.spec.ts
import { Params, Route, UrlSegment } from '@angular/router';
describe('AuthGuard (isolated)', () => {
describe('when the user is logged out', () => {
fakeUrls.forEach((fakeUrl) => {
// [2]
it('rejects access', () => {
const isAccessGranted = guard.checkLogin(fakeUrl);
expect(isAccessGranted).toBeFalse();
});
it('stores the redirect URL', () => {
guard.checkLogin(fakeUrl);
expect(serviceStub.redirectUrl).toBe(fakeUrl);
});
it('navigates to the login page', () => {
// [1]
guard.checkLogin(fakeUrl);
expect(routerSpy.navigate).toHaveBeenCalledWith(['/login'], jasmine.any(Object));
});
it('adds a token to the login URL', () => {
const expectedToken = 'anchor';
guard.checkLogin(fakeUrl);
expect(routerSpy.navigate).toHaveBeenCalledWith(
jasmine.any(Array),
jasmine.objectContaining({
fragment: expectedToken,
})
);
});
it('adds a session ID to the login URL', () => {
const expectedQueryParams: Params = {
session_id: jasmine.any(Number),
};
guard.checkLogin(fakeUrl);
expect(routerSpy.navigate).toHaveBeenCalledWith(
jasmine.any(Array),
jasmine.objectContaining({
queryParams: expectedQueryParams,
})
);
});
});
});
});
Listing 3D. Isolated route guard test cases covering redirect to the login page when access is rejected
Every side effect that occurs when the authorization guard blocks access is verified individually within its own dedicated test. These tests reveal that certain pieces of metadata are saved to the URL state and the authorization service, after which navigation is initiated through the router.
The test confirming that navigation to the login page (1) is triggered could have been made considerably more concise had the AuthGuard methods been refactored to return a UrlTree, a possibility we touched upon in the "Case study: Tour of Heroes" section.
Just as before, each test case in this listing is executed once for every URL found in the fakeUrls array (2).
While Listing 3D focuses on the AuthGuard#checkLogin method, Listing 3E is dedicated to testing the routing hooks themselves.
// auth.guard.spec.ts
import { Params, Route, UrlSegment } from '@angular/router';
describe('AuthGuard (isolated)', () => {
describe('when the user is logged out', () => {
fakeUrls.forEach((fakeUrl) => {
// [1]
describe('and navigates to a guarded route configuration', () => {
it('rejects route access', () => {
const canActivate = guard.canActivate(dummyRoute, fakeRouterState(fakeUrl)); // [3]
expect(canActivate).toBeFalse();
});
it('rejects child route access', () => {
const canActivateChild = guard.canActivateChild(dummyRoute, fakeRouterState(fakeUrl)); // [4]
expect(canActivateChild).toBeFalse();
});
const paths = fakeUrl.split('/').filter((path) => path !== ''); // [2]
paths.forEach((path) => {
// [2]
it('rejects feature access', () => {
const fakeRoute: Route = { path }; // [5]
const fakeUrlSegment = { path } as UrlSegment; // [5]
const canLoad = guard.canLoad(fakeRoute, [fakeUrlSegment]); // [5]
expect(canLoad).toBeFalse();
});
});
});
});
});
});
Listing 3E. Isolated route guard test cases covering redirect to the login page when access is rejected
These tests, too, are run once for each fake URL (1). Furthermore, the test that invokes AuthGuard#canLoad is executed once per segment within the paths array (2).
The initial test case examines the use of the CanActivate route guard interface (3). It relies on the dummyRoute parameter and the previously established fakeRouterState factory. We are checking that it returns false when the user is not authenticated.
For the second test, we validate the guard's implementation of the CanActivateChild interface (4). Using the same set of parameters as the first test, we confirm the hook also returns false under these conditions.
To conclude, fake Route and UrlSegment objects are generated to feed into AuthGuard#canLoad (5), and we confirm its return of false for an unauthenticated user.
Complete isolated route guard test suite
For your convenience, the entire isolated test suite is presented in full within Listing 4.
// auth.guard.spec.ts
import { ActivatedRouteSnapshot, Params, Route, Router, RouterStateSnapshot, UrlSegment } from '@angular/router';
import { AuthGuard } from './auth.guard';
import { AuthService } from './auth.service';
function fakeRouterState(url: string): RouterStateSnapshot {
return {
url,
} as RouterStateSnapshot;
}
describe('AuthGuard (isolated)', () => {
beforeEach(() => {
routerSpy = jasmine.createSpyObj<Router>('Router', ['navigate']);
serviceStub = {};
guard = new AuthGuard(serviceStub as AuthService, routerSpy);
});
const dummyRoute = {} as ActivatedRouteSnapshot;
const fakeUrls = ['/', '/admin', '/crisis-center', '/a/deep/route'];
let guard: AuthGuard;
let routerSpy: jasmine.SpyObj<Router>;
let serviceStub: Partial<AuthService>;
describe('when the user is logged in', () => {
beforeEach(() => {
serviceStub.isLoggedIn = true;
});
fakeUrls.forEach((fakeUrl) => {
it('grants access', () => {
const isAccessGranted = guard.checkLogin(fakeUrl);
expect(isAccessGranted).toBeTrue();
});
describe('and navigates to a guarded route configuration', () => {
it('grants route access', () => {
const canActivate = guard.canActivate(dummyRoute, fakeRouterState(fakeUrl));
expect(canActivate).toBeTrue();
});
it('grants child route access', () => {
const canActivateChild = guard.canActivateChild(dummyRoute, fakeRouterState(fakeUrl));
expect(canActivateChild).toBeTrue();
});
const paths = fakeUrl.split('/').filter((path) => path !== '');
paths.forEach((path) => {
it('grants feature access', () => {
const fakeRoute: Route = { path };
const fakeUrlSegment = { path } as UrlSegment;
const canLoad = guard.canLoad(fakeRoute, [fakeUrlSegment]);
expect(canLoad).toBeTrue();
});
});
});
});
});
describe('when the user is logged out', () => {
beforeEach(() => {
serviceStub.isLoggedIn = false;
});
fakeUrls.forEach((fakeUrl) => {
it('rejects access', () => {
const isAccessGranted = guard.checkLogin(fakeUrl);
expect(isAccessGranted).toBeFalse();
});
it('stores the redirect URL', () => {
guard.checkLogin(fakeUrl);
expect(serviceStub.redirectUrl).toBe(fakeUrl);
});
it('navigates to the login page', () => {
guard.checkLogin(fakeUrl);
expect(routerSpy.navigate).toHaveBeenCalledWith(['/login'], jasmine.any(Object));
});
it('adds a token to the login URL', () => {
const expectedToken = 'anchor';
guard.checkLogin(fakeUrl);
expect(routerSpy.navigate).toHaveBeenCalledWith(
jasmine.any(Array),
jasmine.objectContaining({
fragment: expectedToken,
})
);
});
it('adds a session ID to the login URL', () => {
const expectedQueryParams: Params = {
session_id: jasmine.any(Number),
};
guard.checkLogin(fakeUrl);
expect(routerSpy.navigate).toHaveBeenCalledWith(
jasmine.any(Array),
jasmine.objectContaining({
queryParams: expectedQueryParams,
})
);
});
describe('and navigates to a guarded route configuration', () => {
it('rejects route access', () => {
const canActivate = guard.canActivate(dummyRoute, fakeRouterState(fakeUrl));
expect(canActivate).toBeFalse();
});
it('rejects child route access', () => {
const canActivateChild = guard.canActivateChild(dummyRoute, fakeRouterState(fakeUrl));
expect(canActivateChild).toBeFalse();
});
const paths = fakeUrl.split('/').filter((path) => path !== '');
paths.forEach((path) => {
it('rejects feature access', () => {
const fakeRoute: Route = { path };
const fakeUrlSegment = { path } as UrlSegment;
const canLoad = guard.canLoad(fakeRoute, [fakeUrlSegment]);
expect(canLoad).toBeFalse();
});
});
});
});
});
});
Listing 4. Isolated route guard test suite.
The test infrastructure builds a spy for the router, a stub for the authorization service, and an instance of the authorization guard prior to each individual test.
Two broad groups organize the test suite: one that validates the guard's actions with a logged-in user, and another that stresses the AuthGuard when the user is logged out. Each group includes shared setup code that configures the AuthService#isLoggedIn property accordingly.
Every test case iterates over the fakeUrls array. Tests for the CanLoad guard go one step further, executing for each URL and for each segment within that URL.
The full test suite is available in this Gist.
Integrated route guard test with the RouterTestingModule
By examining the AuthGuard in isolation against every operation it supports, we've achieved complete test coverage. At this stage, are you fully assured that the authorization guard integrates seamlessly into a real route configuration? Perhaps, if you have considerable experience with route guards. Nevertheless, let's build an integrated test suite that exercises the AuthGuard with realistic, albeit fake, route setups for both authenticated and unauthenticated states.
As we'll discover, there's no need to construct partial fakes for the intricate data structures the Angular router relies on. Our only stub will be for the authorization service, and we'll supply it with valid route configurations.
If you're looking for an introduction to Angular's
RouterTestingModule, go read the first section of "Testing Angular routing components with the RouterTestingModule".
Consolidated Route Guard Testing Utilities
For the integrated suite, we begin by examining the helper functions shown in Listing 5A.
// auth.guard.integration.spec.ts
import { Component, Injectable, NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthService } from './auth.service';
function parseUrl(url: string) {
// [1]
const urlPattern = /^(?<path>.*?)(\?(?<queryString>.*?))?(#(?<fragment>.*))?$/;
const {
groups: { fragment = '', path, queryString = '' },
} = url.match(urlPattern);
const query = new URLSearchParams(queryString);
return {
fragment,
path,
query,
};
}
function testRouteGuard({
// [2]
routes,
testUrl,
}: {
routes: Routes;
testUrl: string;
}) {
// Implementation discussed later in this article (...)
}
@Component({
template: '',
})
class TestLazyComponent {} // [3]
@NgModule({
declarations: [TestLazyComponent],
imports: [
RouterModule.forChild([
{
path: '', // [5]
component: TestLazyComponent, // [5]
},
]),
],
})
class TestFeatureModule {} // [4]
@Component({
template: '<router-outlet></router-outlet>',
})
class TestRootComponent {} // [6]
@Component({
template: '',
})
class TestTargetComponent {} // [7]
@Component({
template: '',
})
class TestLoginComponent {} // [8]
@Injectable()
class FakeAuthService implements AuthService {
// [9]
isLoggedIn = false; // [10]
redirectUrl: string; // [10]
login() {
// [11]
this.isLoggedIn = true;
return of(true);
}
logout() {
// [11]
this.isLoggedIn = false;
}
}
Listing 5A. Shared helpers for the integrated route guard verification.
The parseUrl helper (1) takes the URL produced by Location#path and decomposes it into three distinct segments:
- Fragment: The portion appearing after the hash symbol (
#). Delivered as a string. - Path: The URL section preceding any fragment or query string. Delivered as a string.
- Query: The parameters extracted from the portion following the question mark (
?). We provide these as aURLSearchParamsinstance.
testRouteGuard (2) acts as a factory for test suites. It receives a route configuration along with a URL that serves as the initial navigation destination. This function encapsulates both the arrangement phase and the assertions, with its internals discussed in the upcoming sections.
TestLazyComponent (3) is the destination for a route that loads lazily. TestFeatureModule (4) is a feature module loaded on demand, comprising a route pointing to TestLazyComponent (5).
TestRootComponent (6) serves as the top-level component within our test suite. Its template includes a router outlet where our test routes are rendered.
TestTargetComponent (7) is the destination for a route that loads eagerly.
The login screen is represented by a route rendering TestLoginComponent (8).
We emulate the entire public interface of the AuthService through the FakeAuthService class (9). It exposes the isLoggedIn and redirectUrl attributes (10) that our route guard relies on.
During the arrangement phase, we employ the login and logout functions (11).
Route Guard Test Suite Configuration
Before exploring the test setup and assertions, let's examine the configurations supplied to the testRouteGuard factory. These appear in Listing 5B.
// auth.guard.integration.spec.ts
testRouteGuard({
routes: [
{
path: 'lazy',
canLoad: [AuthGuard], // [1]
loadChildren: () => TestFeatureModule, // [2]
},
],
testUrl: '/lazy', // [3]
});
testRouteGuard({
routes: [
{
path: 'target', // [5]
canActivate: [AuthGuard], // [4]
component: TestTargetComponent,
},
],
testUrl: '/target', // [6]
});
testRouteGuard({
routes: [
{
path: '',
canActivateChild: [AuthGuard], // [8]
children: [
{
path: 'target', // [7]
component: TestTargetComponent,
},
],
},
],
testUrl: '/target', // [7]
});
Listing 5B. Configuration variants for the integrated route guard verification.
In the initial configuration, the AuthGuard is attached through the canLoad route property (1). The TestFeatureModule is loaded eagerly, yet it still utilizes the loadChildren route property (2).
This isn't truly lazy loading; I choose this approach to keep the entire test suite and its helpers within a single file. I confirmed that the behavior remains identical with actual lazy-loaded Angular modules.
This first configuration targets the /lazy URL (3), which seeks access to the LazyComponent routed within the TestFeatureModule as previously described.
The second configuration places the authorization guard within the canActivate route property (4). Access is checked upon navigating to the target route (5). This scenario occurs when we supply /target as the test URL (6).
The final configuration also evaluates the /target URL (7), but here it resides within a componentless route that declares the AuthGuard in its canActivateChild property (8).
This yields a streamlined, efficient testing interface. We customize only the aspects that differ across the three test suites, while the factory handles the wiring and schedules the assertions, as we'll demonstrate shortly.
Test Arrangement for the Integrated Suite
Now we dive into the testRouteGuard factory itself, starting with the setup phase it contains. Let's step through Listing 5C.
// auth.guard.integration.spec.ts
import { Location } from '@angular/common';
import { NgZone } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Router, Routes } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { AuthService } from './auth.service';
function testRouteGuard({ routes, testUrl }: { routes: Routes; testUrl: string }) {
describe('AuthGuard#canActivateChild (integrated)', () => {
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [
TestLoginComponent,
TestRootComponent, // [2]
TestTargetComponent, // [1]
],
imports: [
RouterTestingModule.withRoutes([
{
path: 'login', // [4]
component: TestLoginComponent, // [4]
},
...routes, // [5]
]),
],
providers: [
{ provide: AuthService, useClass: FakeAuthService }, // [6]
],
});
await TestBed.compileComponents();
TestBed.createComponent(TestRootComponent); // [3]
location = TestBed.inject(Location);
router = TestBed.inject(Router);
fakeService = TestBed.inject(AuthService); // [6]
ngZone = TestBed.inject(NgZone);
});
let fakeService: FakeAuthService; // [6]
let location: Location;
let ngZone: NgZone;
let router: Router;
describe('when the user is logged in', () => {
// [7]
beforeEach(async () => {
// [8]
await fakeService.login().toPromise(); // [8]
});
describe('and navigates to a guarded feature', () => {
beforeEach(async () => {
await ngZone.run(
async () =>
// [13]
(canNavigate = await router.navigateByUrl(testUrl))
); // [10]
});
let canNavigate: boolean; // [9]
});
});
describe('when the user is logged out', () => {
// [11]
beforeEach(() => {
fakeService.logout(); // [12]
});
describe('and navigates to a guarded feature', () => {
beforeEach(async () => {
await ngZone.run(
async () =>
// [13]
(canNavigate = await router.navigateByUrl(testUrl))
); // [10]
});
let canNavigate: boolean; // [9]
});
});
});
}
Listing 5C. Arrangement for the integrated route guard verification.
Our objective is to navigate to a target URL based on the routes provided. The consumer of the factory specifies the target route, which may involve the TestTargetComponent, so we register it in our Angular testing module (1).
We introduced the TestRootComponent earlier. It's registered in the Angular testing module (2), yet we don't require the fixture it creates, as indicated in (3).
The AuthGuard is designed to redirect to the /login route, so we set up this route with the TestLoginComponent (4). This login route is appended to the routes provided by the consumer (5).
Our AuthGuard relies on AuthService to determine the user's login status. We substitute it with a controllable FakeAuthService (6).
There's a collection of test cases where the user is authenticated (7). To establish this condition, we invoke the FakeAuthService#login method and await the resolution of the returned promise (8).
We define a shared state indicating whether navigation to a protected feature is permitted (9). This is achieved by navigating to the consumer-specified URL (10). The Router#navigateByUrl method returns a Boolean that signals whether navigation succeeded.
Note that (10) also serves as the action for every test case in the next section. Navigating to a protected route is precisely the behavior we aim to validate.
The remaining test cases focus on scenarios where the user is unauthenticated (11). To establish this condition, we invoke the FakeAuthService#logout method (12). The canNavigate state is arranged identically to the other group, sharing annotations (9) and (10).
We explicitly navigate within the NgZone (13) to avoid warnings during test execution. Typically, navigation is triggered by something already operating inside the NgZone, such as an event handler or timer. Omitting the NgZone wrapper wouldn't impact our test outcomes, but the NgZone isn't aware that the app is under test control.
Assertions for the integrated route guard suite We have seven integrated test cases exercising the AuthGuard, as shown in Listing 5D.
// auth.guard.integration.spec.ts
import { Routes } from '@angular/router';
function testRouteGuard({ routes, testUrl }: { routes: Routes; testUrl: string }) {
describe('AuthGuard#canActivateChild (integrated)', () => {
describe('when the user is logged in', () => {
describe('and navigates to a guarded feature', () => {
it('grants access', () => {
expect(canNavigate).toBeTrue(); // [1]
});
it('lazy loads a feature module', () => {
expect(location.path()).toBe(testUrl); // [2]
});
});
});
describe('when the user is logged out', () => {
describe('and navigates to a guarded feature', () => {
it('rejects access', () => {
expect(canNavigate).toBeFalse(); // [3]
});
it('navigates to the login page', () => {
const { path } = parseUrl(location.path()); // [4]
expect(path).toBe('/login'); // [4]
});
it('stores the redirect URL', () => {
expect(fakeService.redirectUrl).toBe(testUrl); // [5]
});
it('adds a token to the login URL', () => {
const expectedToken = 'anchor'; // [6]
const { fragment } = parseUrl(location.path());
expect(fragment).toBe(expectedToken); // [6]
});
it('adds a session ID to the login URL', () => {
const { query } = parseUrl(location.path());
const sessionIdPattern = /^\d+$/;
expect(query.get('session_id')).toMatch(sessionIdPattern); // [7]
});
});
});
});
}
Listing 5D. Assertions for the integrated route guard verification.
The initial assertion checks that Router#navigateByUrl resolves to true when invoked with the testUrl while the user is authenticated (1).
In the second assertion, we confirm that the final URL matches the anticipated destination (2) when the user is authenticated.
The first assertion under the unauthenticated precondition verifies that Router#navigateByUrl resolves to false (3). This would block Angular from eagerly or lazily loading the guarded feature module.
In assertion (4), we confirm that the URL reached when the user is unauthenticated is /login. This demonstrates that the authentication guard redirected the user to the login page.
We check that the FakeAuthService#redirectUrl property is assigned the specified URL (5), enabling the authorization service to return the user to the requested route following login.
From the AuthGuard's perspective, the FakeAuthService acts as the actual service; the resolved AuthService dependency is injected into its constructor, and we've registered the FakeAuthService in our Angular testing module as outlined earlier.
There's no utility in directing our FakeAuthService to redirect back after login. If we tested the subsequent flow, we'd be examining the mechanics of the FakeAuthService, not the real authorization service:
- The user is unauthenticated.
- The user navigates to a protected route.
- The user gets redirected to the login page.
- The user logs in.
- The user gets redirected back to the protected route.
- This assertion holds no value with a mock authorization service. Verifying this scenario demands a more comprehensive feature test, which lies outside this article's scope.
Our AuthGuard appends an auth token and a session ID to the login URL for reasons covered in the introduction. This is validated in (6) and (7).
End-to-end route guard test suite
The complete integrated route guard test suite is presented in Listing 6 below.
import { Location } from '@angular/common';
import { Component, Injectable, NgModule, NgZone } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Router, RouterModule, Routes } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { of } from 'rxjs';
import { AuthGuard } from './auth.guard';
import { AuthService } from './auth.service';
function parseUrl(url: string) {
const urlPattern = /^(?<path>.*?)(\?(?<queryString>.*?))?(#(?<fragment>.*))?$/;
const {
groups: { fragment = '', path, queryString = '' },
} = url.match(urlPattern);
const query = new URLSearchParams(queryString);
return {
fragment,
path,
query,
};
}
function testRouteGuard({ routes, testUrl }: { routes: Routes; testUrl: string }) {
describe('AuthGuard#canActivateChild (integrated)', () => {
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [TestLoginComponent, TestRootComponent, TestTargetComponent],
imports: [
RouterTestingModule.withRoutes([
{
path: 'login',
component: TestLoginComponent,
},
...routes,
]),
],
providers: [{ provide: AuthService, useClass: FakeAuthService }],
});
await TestBed.compileComponents();
TestBed.createComponent(TestRootComponent);
location = TestBed.inject(Location);
router = TestBed.inject(Router);
fakeService = TestBed.inject(AuthService);
ngZone = TestBed.inject(NgZone);
});
let fakeService: FakeAuthService;
let location: Location;
let ngZone: NgZone;
let router: Router;
describe('when the user is logged in', () => {
beforeEach(async () => {
await fakeService.login().toPromise();
});
describe('and navigates to a guarded feature', () => {
beforeEach(async () => {
await ngZone.run(async () => (canNavigate = await router.navigateByUrl(testUrl)));
});
let canNavigate: boolean;
it('grants access', () => {
expect(canNavigate).toBeTrue();
});
it('lazy loads a feature module', () => {
expect(location.path()).toBe(testUrl);
});
});
});
describe('when the user is logged out', () => {
beforeEach(() => {
fakeService.logout();
});
describe('and navigates to a guarded feature', () => {
beforeEach(async () => {
await ngZone.run(async () => (canNavigate = await router.navigateByUrl(testUrl)));
});
let canNavigate: boolean;
it('rejects access', () => {
expect(canNavigate).toBeFalse();
});
it('navigates to the login page', () => {
const { path } = parseUrl(location.path());
expect(path).toBe('/login');
});
it('stores the redirect URL', () => {
expect(fakeService.redirectUrl).toBe(testUrl);
});
it('adds a token to the login URL', () => {
const expectedToken = 'anchor';
const { fragment } = parseUrl(location.path());
expect(fragment).toBe(expectedToken);
});
it('adds a session ID to the login URL', () => {
const { query } = parseUrl(location.path());
const sessionIdPattern = /^\d+$/;
expect(query.get('session_id')).toMatch(sessionIdPattern);
});
});
});
});
}
@Component({
template: '',
})
class TestLazyComponent {}
@NgModule({
declarations: [TestLazyComponent],
imports: [
RouterModule.forChild([
{
path: '',
component: TestLazyComponent,
},
]),
],
})
class TestFeatureModule {}
@Component({
template: '<router-outlet></router-outlet>',
})
class TestRootComponent {}
@Component({
template: '',
})
class TestTargetComponent {}
@Component({
template: '',
})
class TestLoginComponent {}
@Injectable()
class FakeAuthService implements AuthService {
isLoggedIn = false;
redirectUrl: string;
login() {
this.isLoggedIn = true;
return of(true);
}
logout() {
this.isLoggedIn = false;
}
}
testRouteGuard({
routes: [
{
path: 'lazy',
canLoad: [AuthGuard],
loadChildren: () => TestFeatureModule,
},
],
testUrl: '/lazy',
});
testRouteGuard({
routes: [
{
path: 'target',
canActivate: [AuthGuard],
component: TestTargetComponent,
},
],
testUrl: '/target',
});
testRouteGuard({
routes: [
{
path: '',
canActivateChild: [AuthGuard],
children: [
{
path: 'target',
component: TestTargetComponent,
},
],
},
],
testUrl: '/target',
});
Listing 6. The complete integrated route guard test suite.
The test harness creates a root test component and configures the routes under test alongside a mock login route. Navigation is initiated to the specified route URL so that the suite can confirm whether Router#navigateByUrl is permitted.
The test cases verify the expected behavior for both authenticated and unauthenticated users by invoking FakeAuthService#login and FakeAuthService#logout respectively.
Both the setup routine and the test cases are registered and executed for every configuration supplied to the testRouteGuard test suite factory.
The first configuration checks the AuthGuard when it guards a route defined in a feature Angular module. The second configuration examines the AuthGuard when it is attached directly to a route. The third configuration applies the guard to parent routes so that it protects child routes.
The full test suite can be found in this Gist.
Final thoughts
The AuthGuard route guard from the Tour of Heroes tutorial implements three of the available route guard interfaces:
CanActivate: Guards a route.CanActivateChild: Guards a route's child routes.CanLoad: Guards lazy/eager loading of routes using theloadChildrenAPI.
In current Angular versions, these interfaces all accept returning a plain Boolean or a UrlTree. They also support returning a promise or an observable that resolves or emits either a boolean or a UrlTree.
The AuthGuard in this example chooses to return a Boolean value and handles redirection itself when the user is not authorized. Instead of returning a UrlTree that the Angular router could use to navigate to a login page, the guard performs the navigation directly.
When the user is logged out, the authorization guard triggers several side effects:
- It appends a session ID query parameter.
- It simulates the auth token typically passed to external login forms.
- It navigates to the login route including the details mentioned above.
The control flow of the AuthGuard route guard.
When testing a route guard, the process is:
- Configure any precondition the guard depends on.
- Initiate navigation.
- Check whether navigation succeeds.
- Confirm that the final URL is the one expected.
- Validate any side effects the route guard is expected to produce.
Standalone route guard test
In a standalone route guard test suite, every dependency of the guard is replaced with a stub, including the Router service when it is used.
In this example, a Jasmine spy object with a spy navigate method was provided, since that was the only part of the Router's API required. In a more current implementation, the AuthGuard would likely return a UrlTree rather than invoking navigation through the router directly.
Navigation is simulated by invoking the methods that implement the route guard interfaces directly, passing dummy URLs. For the AuthGuard, the route URL does not affect the business logic it contains, but different fake and real routes are supplied anyway to document and verify its behavior across the application.
In this example, the route guard hooks return a Boolean value. The standalone test suite checks the returned result based on a precondition of either user logged in or user logged out, using stubbed dependencies—in this case a stub for the AuthService.
Route guard hooks expect complex objects:
ActivatedRouteSnapshotRouteRouterStateSnapshotUrlTree
Creating fake instances of these objects is the most involved part of setting up standalone route guard test cases.
To check the expected side effects for the AuthGuard, the test suite inspects the arguments passed to the router spy method and the properties set on the fake authorization service.
Integrated route guard test
For an integrated route guard test, fake guarded routes are passed to the static RouterTestingModule.withRoutes method. This allows the real Router service to trigger navigation without altering the URL location of the test browser environment.
Routed components are created and declared in the Angular testing module, such as:
- A test root component
- A test navigation target component
- A test login component
For integrated route guard test cases, navigation is started via Router#navigate or Router#navigateByUrl. This is done inside a callback passed to NgZone#run to avoid warnings during test execution.
The router navigation methods return a Boolean indicating whether navigation was allowed, considering all route guards applied to the fake route.
Just like with the standalone tests, the user logged out/in preconditions are set up through a stubbed dependency—here an instance of FakeAuthService.
The tests confirm that navigation either succeeds or is rejected as expected. The provided Location service is used to check the URL that is reached after navigation has completed.
The advantage of using the RouterTestingModule in integrated route guard tests over standalone route guard tests is that the RouterTestingModule allows:
- Configuring routes.
- Setting up a test application.
- Using the real
Routerservice to navigate the configured routes. - Using a provided
Locationservice to verify the final URL.
Keep in mind that the class-based service provided as
Locationby theRouterTestingModuleis actually an instance of theSpyLocationclass. More details can be found in "Testing Angular routing components with the RouterTestingModule".
Route guard tests provide confidence
With test suites covering route guards, there is solid confidence in adding these guards to route configurations. Every side effect is exercised and verified. Support for relevant route hooks is tested in practice with the RouterTestingModule.
Further reading
Understand the RouterTestingModule and how to test routing components in "Testing Angular routing components with the RouterTestingModule".
Find out how to test routed components in "Testing routed Angular components with the RouterTestingModule".
