What are Angular route guards?
If you’ve worked with Angular on any complex or commercial product, you’ve most likely came across Angular router
navigation guards at some point or the other. This Angular mechanic allows you to control access to certain parts of
your application. Angular differentiates four different types of route guards:
- CanActivate
- CanActivateChild
- CanLoad
- CanDeactivate
The most common use case for Angular routers is an authentication guard, which shields part of the application from
unauthenticated access, e.g. requiring a user to login before accessing another user’s profile.
How did route guards work in Angular <= 14?
In Angular version 14 and below, Angular provided interfaces, e.g. CanActivate
, which could be implemented by a class to
become a guard and thus decide whether or not a route can be activated. Such classes implementing the CanActivate
interface had to implement a canActivate method returning true or false to signal to the router if it should proceed
with the requested navigation. Alternatively a UrlTree
could be returned to stop the requested navigation and instead
navigate to a different URL.
This could look roughly like in the following example.
Let’s imagine a very simple login service:
login.service.ts
import { Injectable } from "@angular/core";
@Injectable({
providedIn: "root",
})
export class LoginService {
private loggedInUser: string;
public isLoggedIn() {
return this.loggedInUser ? true : false;
}
}
This service saves the currently logged-in user and allows to check if a user is logged in.
app.module.ts:
const routes: Routes = [
{ path: "", component: HomeComponent },
{ path: "login", component: LoginComponent },
{ path: "profile", component: UserProfileComponent, canActivate: [CanActivateRouteGuard] },
];
In the app.module.ts (or the app-routing.module.ts, depending on your setup) we define the routes. The “/profile” route,
which allows displaying the user profile should only be accessible if the user is logged in, thus we protect it with our
CanActivateRouteGuard
.
can-activate-route-class.guard.ts:
import { CanActivate, Router } from "@angular/router";
import { LoginService } from "./login.service";
import { Injectable } from "@angular/core";
@Injectable({
providedIn: "root",
})
export class CanActivateRouteGuard implements CanActivate {
constructor(private router: Router, private loginService: LoginService) {}
canActivate() {
if (this.loginService.isLoggedIn()) {
return true;
}
return this.router.createUrlTree(["login"]);
}
}
The CanActivateRouteGuard
class implements Angular’s CanActivate
RouteGuard interface. This also means that we have to
implement a canActivate
method. In this case we check whether the user is logged in. If they are logged in, we return true,
otherwise we’ll redirect them to the login page by returning a UrlTree
pointing to the “/login” route.
Also note that the LoginService
is provided through Angular’s dependency injection mechanism in the class’ constructor
in this example.
What has changed with Angular 15?
In version 14.2 Angular first introduced functional route guards as a new concept to protect parts of your application.
With the release of the major version 15 they then finally deprecated the original concept of creating class-based route
guards. While these changes provide a more flexible, modernized solution, they also introduced a new problem.
The old class-based guards were extremely easy to test, as at the end of the day, you were just testing another class
and a class method, returning a boolean. Thus, they could be handled exactly the same way every other class in your
projects could be handled.
Functional route guards on the other hand, proved to be a bit more tricky when it comes to unit-testing their
functionality.
How has the implementation changed?
But let’s take a step back first. What exactly has changed?
In order to understand that, we’ll take a look at the above example with a functional route guard instead of the
class-based one.
app.module.ts:
const routes: Routes = [
{ path: "", component: HomeComponent },
{ path: "login", component: LoginComponent },
{ path: "profile", component: UserProfileComponent, canActivate: [CanActivateRouteGuard] },
];
If we take a look at our route definition, we can see that nothing has changed here actually. You’re still able to
define your routes, including your guards, exactly the way you already used to do it with the old concept.
can-activate-route.guard.ts:
import { CanActivateFn, Router } from "@angular/router";
import { inject } from "@angular/core";
export const canActivateRouteGuard: CanActivateFn = () => {
const loginService = inject(LoginService);
const router = inject(Router);
if (loginService.isLoggedIn()) {
return true;
}
return router.createUrlTree(["login"]);
};
As you can see in the code above, instead of setting up a class, importing canActivate from @angular/router
and
implementing the CanActivate
interface, you now have to create a function. After importing CanActivateFn
from
@angular/router
we have to define a function of that type. The content of this function is basically identical to the
canActivate
method from the initial example. However, as the logic is now function-based and not class-based, we can’t
rely on Angular’s constructor dependency injection anymore. Instead we have to use the inject function from
@angular/core
to access Router
and LoginService
.
How to test functional route guards?
While converting all the guards of our current project to be functional guards, which was done in a flash, I’ve noticed
that converting the corresponding unit-tests wouldn’t be quite as straightforward. A quick Google search later, I wasn’t
any smarter, as of the time of writing this article, information on how to successfully test functional route guards
with Jasmine in Angular is still quite sparse.
The most tricky part of testing a functional route guard is to run the guard’s logic in a context where all the
necessary dependencies are present. With the old route guard concept, Angular’s standard dependency injection mechanism
for classes could be used to take care of that.
The snippets below show how unit-testing in the correct context could be achieved nonetheless.
Imagine again a functional route guard (as shown above) that checks whether a user is logged in, returns true if the
user is logged in and redirects the user to the login page otherwise.
import { TestBed } from "@angular/core/testing";
import { ActivatedRoute, Router, RouterStateSnapshot } from "@angular/router";
import { RouterTestingModule } from "@angular/router/testing";
import { canActivateRouteGuard } from "./can-activate-route.guard";
import { LoginService } from "./login.service";
describe("canActivateRouteGuard", () => {
const mockLoginService = jasmine.createSpyObj("LoginService", ["isLoggedIn"]);
const mockRouter = jasmine.createSpyObj("Router", ["createUrlTree"]);
beforeEach(() => {
TestBed.configureTestingModule({
imports: [RouterTestingModule],
providers: [
{
provide: Router,
useValue: mockRouter,
},
{
provide: LoginService,
useValue: mockLoginService,
},
{
provide: ActivatedRoute,
useValue: {
snapshot: {},
},
},
],
});
});
beforeEach(() => {
mockLoginService.isLoggedIn.calls.reset();
mockRouter.createUrlTree.calls.reset();
});
it("should return true if user is logged in", () => {
const activatedRoute = TestBed.inject(ActivatedRoute);
mockLoginService.isLoggedIn.and.returnValue(true);
const guardResponse = TestBed.runInInjectionContext(() => {
return canActivateRouteGuard(activatedRoute.snapshot, {} as RouterStateSnapshot);
});
expect(guardResponse).toBeTrue();
expect(mockRouter.createUrlTree).not.toHaveBeenCalled();
});
it("should route to login page if user is not logged in", () => {
const activatedRoute = TestBed.inject(ActivatedRoute);
mockLoginService.isLoggedIn.and.returnValue(false);
TestBed.runInInjectionContext(() => {
return canActivateRouteGuard(activatedRoute.snapshot, {} as RouterStateSnapshot);
});
expect(mockRouter.createUrlTree).toHaveBeenCalledOnceWith(["login"]);
});
});
At first glance this might be a bit much to take in, so let’s break it down.
We start off by creating spy objects (mockLoginService
and mockRouter
) for our dependencies. This allows us to control
the output of the method calls in our test cases later on. Additionally we’re able to track whether or not a method has
been called (and how often for that matter).
As common with Angular unit-tests, we then set up our TestBed
and configure the TestingModule
. Aside from importing the
RouterTestingModule
, we also provide our previously created mocks here. Note that we also provide an empty object to be
returned whenever snapshot is requested on ActivatedRoute
, which will prove handy in a minute.
In a second beforeEach
block we then make sure any previously recorded method calls on our spy objects are reset to
ensure that our test cases don’t influence each other and we’re always starting from a clean slate.
For our example, we’ve created two test cases. One for granting access to the user profile and one for denying it.
Necessary dependencies can be provided through the TestBed
’s inject method, as can be seen with ActivatedRoute
.
By manipulating the return values on our spy objects we can then set up the correct prerequisites for our test case.
Here for example, we want to test our guard for a logged in user, thus we alter what isLoggedIn
will return:
mockLoginService.isLoggedIn.and.returnValue(true);
To actually execute the guard code we want to test, we need to call our guard function in the EnvironmentInjector
context of our TestBed
. This can be achieved by utilizing the runInInjectionContext
method of TestBed
.
const guardResponse = TestBed.runInInjectionContext(() => {
return canActivateRouteGuard(activatedRoute.snapshot, {} as RouterStateSnapshot);
});
For the final call to our new guard function, we’ll additionally have to provide an ActivatedRouteSnapshot
, which we’ll
just substitute with the empty object for our testing purposes, as defined earlier in our providers array. Finally, the
guard’s response is saved for subsequent assertions.
To assert potential redirects triggered by your functional guards, calls to the Router
spy object can be monitored and
asserted, as can be seen in this line:
expect(mockRouter.createUrlTree).toHaveBeenCalledOnceWith(["login"]);
Final thoughts
While conversion of legacy route guards has proven to be easily achievable, converting existing unit-tests might come
with some challenges. However, once it’s clear how to set up the test environment appropriately and provide the correct
context and dependencies, adapting existing unit-tests becomes way less intimidating.