Wie man funktionale Angular-Guards mit Jasmine testet

Post image

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.

You May Also Like