Was sind Angular Route-Guards?
Wenn Sie mit Angular an einem komplexen oder kommerziellen Produkt gearbeitet haben, sind Sie höchstwahrscheinlich irgendwann
auf Angular-Router Navigation Guards gestoßen. Diese Angular-Mechanik ermöglicht es, den Zugang zu bestimmten Teilen Ihrer
Anwendung zu kontrollieren. Angular unterscheidet vier verschiedene Arten von Route-Guards:
- CanActivate
- CanActivateChild
- CanLoad
- CanDeactivate
Der häufigste Anwendungsfall für Angular Router ist ein Authentifizierungs-Guard,
der Teile der Anwendung vor unauthentifiziertem Zugriff schützt und z.B. eine
Anmeldung erzwingt, bevor das Profil eines anderen Benutzers aufgerufen werden kann.
Wie funktionierten Route-Guards in Angular <= 14?
In Angular Version 14 und darunter hat Angular Schnittstellen bereitgestellt, z. B. CanActivate, die von einer Klasse
implementiert werden konnten, um ein Guard zu werden und somit zu entscheiden, ob eine Route aktiviert werden kann oder nicht.
Solche Klassen, die das CanActivate Interface implementierten, mussten eine canActivate Methode implementieren, die true
oder false zurückgibt, um dem Router zu signalisieren, ob er mit der angeforderten Navigation fortfahren soll. Alternativ
konnte ein UrlTree zurückgegeben werden, um die angeforderte Navigation zu stoppen und stattdessen zu einer anderen URL zu navigieren.
Dies könnte ungefähr wie im folgenden Beispiel aussehen.
Stellen wir uns einen sehr einfachen Login-Service vor:
login.service.ts
import { Injectable } from "@angular/core";
@Injectable({
providedIn: "root",
})
export class LoginService {
private loggedInUser: string;
public isLoggedIn() {
return this.loggedInUser ? true : false;
}
}
Dieser Dienst speichert den derzeit eingeloggten Benutzer und ermöglicht es zu überprüfen,
ob ein Benutzer eingeloggt ist.
app.module.ts:
const routes: Routes = [
{ path: "", component: HomeComponent },
{ path: "login", component: LoginComponent },
{ path: "profile", component: UserProfileComponent, canActivate: [CanActivateRouteGuard] },
];
Im app.module.ts (oder dem app-routing.module.ts, abhängig von Ihrer Konfiguration)
definieren wir die Routen. Die „/profile“-Route, welche das Anzeigen des Benutzerprofils
ermöglicht, sollte nur zugänglich sein, wenn der Benutzer eingeloggt ist. Aus diesem Grund
schützen wir sie mit unserem 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"]);
}
}
Die CanActivateRouteGuard
Klasse implementiert Angulars CanActivate
RouteGuard Interface.
Das bedeutet auch, dass wir eine canActivate
Methode implementieren müssen. In diesem Fall
prüfen wir, ob der Benutzer eingeloggt ist. Wenn sie eingeloggt sind, geben wir true zurück,
andernfalls leiten wir sie durch die Rückgabe eines UrlTree
, der auf die Route „/login“ zeigt,
zur Anmeldeseite um.
Beachten Sie auch, dass der LoginService
durch den Mechanismus der Dependency-Injection
von Angular im Konstruktor der Klasse in diesem Beispiel bereitgestellt wird.
Was hat sich bei Angular 15 geändert?
In Version 14.2 führte Angular funktionale Route-Guards als ein
neues Konzept ein, um Teile Ihrer Anwendung zu schützen. Mit
der Veröffentlichung der Hauptversion 15 haben sie dann das
ursprüngliche Konzept der Erstellung von klassenbasierten
Route-Guards endgültig veraltet.
Obwohl diese Neuerungen eine flexiblere, modernisierte Lösung bieten,
führen sie auch zu einem neuen Problem. Die alten klassenbasierten
Route-Guards waren extrem einfach zu testen, da man am Ende des Tages
einfach eine andere Klasse und eine Klassenmethode testete, die einen
booleschen Wert zurückgibt. So konnten sie genau auf die gleiche Weise
behandelt werden, wie jede andere Klasse in Ihren Projekten.
Das Unit-Testing der Funktionalen Route-Guards hingegen erwies sich als
etwas kniffliger.
Wie hat sich die Implementierung geändert?
Aber lassen Sie uns zunächst einen Schritt zurücktreten. Was hat sich genau geändert?
Um das zu verstehen, werden wir uns das obige Beispiel mit einem funktionalen Route-Guard
anstelle des klassenbasierten ansehen.
app.module.ts:
const routes: Routes = [
{ path: "", component: HomeComponent },
{ path: "login", component: LoginComponent },
{ path: "profile", component: UserProfileComponent, canActivate: [CanActivateRouteGuard] },
];
Wenn wir uns unsere Routendefinition ansehen, können wir feststellen, dass sich
hier eigentlich nichts verändert hat. Sie könnsen Ihre Routen, einschließlich
der Route-Guards, genau so definieren, wie Sie es bereits mit dem alten Konzept
gewohnt sind.
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"]);
};
Wie Sie im obigen Code sehen können, müssen Sie anstatt einer Klasse einzurichten,
canActivate
von @angular/router
zu importieren und das CanActivate
Interface
zu implementieren, nun eine Funktion erstellen. Nach dem Import von CanActivateFn
aus @angular/router
müssen wir eine Funktion dieses Typs definieren. Der Inhalt
dieser Funktion ist im Grunde identisch mit der canActivate
-Methode aus dem
ersten Beispiel oben. Da die Logik jetzt jedoch auf Funktionen basiert und nicht
mehr auf Klassen, können wir uns nicht mehr auf die Konstruktor-Dependency-Injection
von Angular verlassen. Stattdessen müssen wir die inject
-Funktion von @angular/core
verwenden, um auf Router
und LoginService
zuzugreifen.
Wie testet man funktionale Route-Guards?
Beim Umwandeln aller Guards unseres aktuellen Projekts in funktionale Route-Guards,
was im Nu erledigt war, ist mir aufgefallen, dass das Umwandeln der entsprechenden
Unit-Tests nicht ganz so einfach sein würde. Eine schnelle Google-Suche später war
ich nicht schlauer, denn zum Zeitpunkt des Schreibens dieses Artikels sind Informationen
darüber, wie man funktionale Route-Guards mit Jasmine in Angular erfolgreich testet,
immer noch recht spärlich gesät.
Der kniffligste Teil beim Testen eines funktionalen Route-Guards besteht darin,
die Logik des Guards in einem Kontext auszuführen, in dem alle notwendigen Abhängigkeiten
vorhanden sind. Mit dem alten Route-Guard-Konzept konnte der standardmäßige
Dependency-Injection-Mechanismus von Angular für Klassen verwendet werden,
um das zu bewerkstelligen.
Die untenstehenden Snippets zeigen, wie das Unit-Testing im richtigen Kontext
dennoch erreicht werden könnte.
Stellen Sie sich erneut einen funktionalen Route-Guard vor (wie oben gezeigt),
der überprüft, ob ein Benutzer eingeloggt ist, true zurückgibt, wenn der Benutzer
eingeloggt ist, und den Benutzer andernfalls zur Login-Seite umleitet.
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"]);
});
});
Auf den ersten Blick scheint das vielleicht etwas viel zu sein, also lassen Sie uns das
aufdröseln.
Wir beginnen damit, Spy-Objekte (mockLoginService
und mockRouter
) für unsere
Abhängigkeiten zu erstellen. Das erlaubt uns, die Ausgabe der Methodenaufrufe in
unseren Testfällen später zu steuern. Zusätzlich können wir verfolgen, ob eine Methode
aufgerufen wurde (und wie oft, um genau zu sein).
Wie bei Angular-Unit-Tests üblich, richten wir dann unser TestBed
ein und konfigurieren
das TestingModule
. Neben dem Importieren des RouterTestingModule
, stellen wir hier
auch unsere zuvor erstellten Mocks bereit. Beachten Sie, dass wir auch ein leeres Objekt
zurückgeben, wann immer ein Snapshot von ActivatedRoute
angefordert wird, was sich in
einer Minute als praktisch erweisen wird.
In einem zweiten beforeEach
-Block stellen wir dann sicher, dass alle zuvor
aufgezeichneten Methodenaufrufe auf unseren Spionage-Objekten zurückgesetzt werden, um
zu gewährleisten, dass unsere Testfälle sich nicht gegenseitig beeinflussen und wir
immer von einem sauberen Stand aus starten.
Für unser Beispiel haben wir zwei Testfälle erstellt. Einen, um dem Nutzer Zugang zum
Benutzerprofil zu gewähren, und einen, um ihn zu verweigern. Notwendige Abhängigkeiten
können durch die TestBed
’s inject-Methode bereitgestellt werden, wie man am
ActivatedRoute
sehen kann. Indem wir die Rückgabewerte auf unseren Spy-Objekten
manipulieren, können wir dann die richtigen Voraussetzungen für unseren Testfall
einrichten. Hier wollen wir zum Beispiel unseren Route-Guard für einen eingeloggten
Nutzer testen, also ändern wir, was isLoggedIn
zurückgeben wird:
mockLoginService.isLoggedIn.and.returnValue(true);
Um den Guard-Code auszuführen, den wir testen möchten, müssen wir unsere
Route-Guard-Funktion im EnvironmentInjector
-Kontext unseres TestBed
aufrufen.
Das kann erreicht werden, indem wir die runInInjectionContext
-Methode von
TestBed
nutzen.
const guardResponse = TestBed.runInInjectionContext(() => {
return canActivateRouteGuard(activatedRoute.snapshot, {} as RouterStateSnapshot);
});
Für den letzten Aufruf unserer neuen guard-Funktion müssen wir zusätzlich einen
ActivatedRouteSnapshot
bereitstellen, den wir für unsere Testzwecke einfach durch
das leere Objekt ersetzen, wie es zuvor in unserem Provider-Array definiert wurde.
Schließlich wird die Guard-Antwort für spätere Assertions gespeichert.
Um potenzielle Umleitungen, die durch Ihre funktionalen Guard ausgelöst werden,
zu prüfen, können Aufrufe des Spy-Objekts Router
überwacht und mit Assertions
versehen werden, wie in dieser Zeile zu sehen ist:
expect(mockRouter.createUrlTree).toHaveBeenCalledOnceWith(["login"]);
Abschließende Überlegungen
Während sich die Konvertierung von “Legacy” Route-Guards als leicht realisierbar
erwiesen hat, könnte die Konvertierung bestehender Unit-Tests einige Herausforderungen
mit sich bringen. Sobald jedoch klar ist, wie man die Testumgebung angemessen einrichtet
und den richtigen Kontext und die richtigen Abhängigkeiten bereitstellt, fällt die Anpassung
bestehender Unit-Tests wesentlich leichter.