, ,

Einführung in Angular-Komponenten (Part 2)


Bei diesem Artikel handel es sich um Teil 2 der Einführung in Angular-Komponenten. Falls du dir vorher die Grundlagen erarbeiten möchtest, sieh dir gerne Einführung in Angular-Komponenten (Part 1) an.


Anmerkung: In diesem Beitrag nutze ich die informelle Anrede „du“ und gleichzeitig das generische Maskulinum. Dies dient dazu, eine persönliche Atmosphäre zu schaffen, die den Austausch und das Lernen ansprechender macht. Gleichzeitig erleichtert es den Lesefluss. Dennoch gilt natürlich allen mein höchster Respekt.


Testen der Komponenten

Nun, da du die grundlegende Mini-Anwendung implementiert hast, ist es an der Zeit, sie zu testen. Bevor wir uns den eigentlichen Integrationstests der Komponenten widmen, zeige ich dir anhand eines kurzen Unittests, wie du diese in Angular grundsätzlich aufsetzen kannst. Dafür werfen wir einen Blick auf die Datei app.component.ts aus dem Repository:
import { Component, OnInit } from "@angular/core";

@Component ({
        selector: "app-root",
        templateUrl: "./app.component.html",
        styleUrls: ["./app.component.scss"]
})
export class AppComponent implements OnInit {
        readonly title: string;
        postImg: string[];
        postText: string[];
        constructor() {
                this.title = "angular-demo-application";
                this.postImg = [];
                this.postText = [];
        }

        ngOnInit() {
                this.postImg.push("0", "1", "2", "3", "4", "5");
                this.postText.push(
                        "This is a card.",
                        "That is also a card.",
                        "This is anonther card.",
                        "That is the fourth card. ",
                        "This is the fifth card.",
                        "That is the last card."
                );
        }
}

Als einfachen Unittest möchten wir hier den Wert der Eigenschaft title überprüfen. Öffne dafür bitte die Datei app.component.spec.ts, die bisher etwa so aussehen sollte:

import { AppComponent } from "./app.component";
import { CardComponent } from "./card/card.component";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { FriendBoxComponent } from "./friend-box/friend-box.component";
import { HeaderComponent } from "./header/header.component";
import { MatIcon } from "@angular/material/icon";
import { ProfileRowComponent } from "./profile-row/profile-row.component";
import { ProposalsComponent } from "./proposals/proposals.component";

describe("AppComponent", () => {
        let component: AppComponent;
        let fixture: ComponentFixture<AppComponent>;

        beforeEach(() => {
                TestBed.configureTestingModule({
			declarations: [
				AppComponent,
				CardComponent,
				FriendBoxComponent,
				HeaderComponent,
				MatIcon,
				ProfileRowComponent,
				ProposalsComponent
			],
                        imports: [],
                        providers: []
                }).compileComponents();
                fixture = TestBed.createComponent(AppComponent);
                component = fixture.componentInstance;
                fixture.detectChanges();
        });

        it("should be created", () => {
                expect(component).toBeTruthy();
        });
});
Hier passiert zunächst nicht allzu viel, außer dass überprüft wird, ob die Komponente überhaupt erstellt werden kann. Falls du bereits Erfahrung mit Tests in JavaScript oder TypeScript hast, sollten dir die Bezeichner describe, beforeEach und it bekannt vorkommen. Diese Konvention wird in verschiedenen Frameworks genutzt, um eine konsistente Struktur für Tests zu gewährleisten. Der beforeEach-Block selbst ist dazu da, mithilfe des Angular-Kernmoduls TestBed die Konfiguration vorzubereiten. Hierbei kannst du drei verschiedene Eigenschaften festlegen:
  • declarations: In diesem Array sind die Komponentenklassen aufgelistet, die du in deinem Unittest verwenden willst.
  • imports: In diesem Array kannst weitere Modulklassen aufführen, die du in deinen Unittest importieren willst.
  • providers: In diesem Array sind alle Dienste aufgelistet, auf die du in deinem Unittest zugreifen willst.

Die Methode compileComponents() sorgt dafür, dass alle mit configureTestingModule geladenen Klassen und Module zusammengeführt werden. Anschließend wird mithilfe der Methode createComponent() eine Instanz der Komponente AppComponent erstellt und der Variablen fixture zugewiesen. Durch die Eigenschaft componentInstance kannst du dann auf das tatsächliche Objekt zugreifen. Dann sorgt die Zeile fixture.detectChanges() dafür, dass alle Änderungen erkannt und angewandt werden. Es ist wichtig, dass du Methode nach jeder Änderung aufrufst, wenn du mit Komponenten arbeitest. Obwohl es auch eine Möglichkeit gibt, dass Angular Änderungen automatisch erkennt, habe ich persönlich festgestellt, dass das nicht immer zuverlässig funktioniert.


Asynchrone Testkonfiguration

Jetzt gibt es noch eine Kleinigkeit, die du wissen solltest. Das Zusammenführen mit compileComponents läuft asynchron ab, und somit im Hintergrund. Das bedeutet, wenn du dir eine Komponente erstellst, kann es sein, dass Angular mit der Konfiguration noch gar nicht fertig ist. Deswegen ist es gängige Praxis, also Best Practise, die Konfiguration und die Zuweisung in zwei separate beforeEach-Blöcke zu packen:
import { AppComponent } from "./app.component";
import { CardComponent } from "./card/card.component";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { FriendBoxComponent } from "./friend-box/friend-box.component";
import { HeaderComponent } from "./header/header.component";
import { MatIcon } from "@angular/material/icon";
import { ProfileRowComponent } from "./profile-row/profile-row.component";
import { ProposalsComponent } from "./proposals/proposals.component";

describe("AppComponent", () => {
        let component: AppComponent;
        let fixture: ComponentFixture<AppComponent>;

        beforeEach(async() => {
                TestBed.configureTestingModule({
                        declarations: [
				AppComponent,
				CardComponent,
				FriendBoxComponent,
				HeaderComponent,
				MatIcon,
				ProfileRowComponent,
				ProposalsComponent
			],
                        imports: [],
                        providers: []
                }).compileComponents()
        })

        beforeEach(() => {
                fixture = TestBed.createComponent(AppComponent);
                component = fixture.componentInstance;
                fixture.detectChanges();
        });

        it("should be created", () => {
                expect(component).toBeTruthy();
        });
});

Wie du siehst, steht die Konfiguration in einem asynchronen beforeEach-Block und die Zuweisung an die Variable in einem „normalen“ (synchronen) beforeEach-Block. Dadurch ist sichergestellt, dass die Konfiguration erst abgearbeitet wird, und dann finden die Zuweisungen statt.


Ein einfacher Unittest

Nachdem du jetzt einen ersten Einblick bekommen hast, wie ein Unittest in Angular aufgebaut ist, kannst du nun relativ leicht einen eigenen Unittest implementieren. Die Assertion kommt aus dem Testframework Jasmine und wird immer mit expect und einem Ausdruck oder einer Variable in Klammern eingeleitet. Danach folgt nach einem Punkt der entsprechende, sogenannte Matcher. Dieser prüft auf bestimmte Art und und Weise, ob eine bestimmte Bedingung gegeben ist. Hier ein paar Beispiele:

  • toBe(), toEqual(): Damit kannst du prüfen, ob eine Variable einen bestimmten Wert hat.
  • toBeTrue(), toBeFalse(): Damit kannst du prüfen, ob eine Bedingung erfüllt (true) oder nicht erfüllt (false) ist.
  • toBeTruthy(), toBeFalsy(): Damit kannst du Bedingungen prüfen, aber auch z.B. ob Objekte (nicht) null sind.
  • toHaveBeenCalled(), toHaveBeenCalledTimes(): Damit kannst du prüfen, ob oder auch wie oft eine Funktion aufgerufen wurde. Dies ist eine Spezialität in Jasmine, die man Spy nennt. Darauf gehe ich nachher noch ein.

Jetzt prüfen wir, welchen Wert der Titel hat. Im TypeScript der Komponente AppComponent wird ja der Titel standardmäßig auf den Wert "angular-demo-application" gesetzt. Also ist genau das auch unsere Bedingung. Die Assertion lautet also:

expect(component.title).toBe("angular-demo-application");
Der Matcher ist in diesem Fall toBe(), da der Titel mit dem angegebenen String übereinstimmen muss. Du könntest auch toEqual verwenden, aber den nimmt man normalerweise bei komplexen Datentypen. Und somit fügst du einfach unterhalb des bereits vorhandenen it-Blocks den folgenden Test ein:
it("should have the title 'angular-demo-application'", () => {
        expect(component.title).toBe("angular-demo-application");
});

Die Unittests rufst du nun im Projektordner mit dem Befehl ng test auf:

C:\Users\Robert\angular-demo-application>ng test
⠸ Generating browser application bundles (phase: building)...
✔ Browser application bundle generation complete.
24 08 2023 13:41:32.099:WARN [karma]: No captured browser, open http://localhost:9876/
24 08 2023 13:41:32.134:INFO [karma-server]: Karma v6.4.2 server started at http://localhost:9876/
24 08 2023 13:41:32.136:INFO [launcher]: Launching browsers Chrome with concurrency unlimited
24 08 2023 13:41:32.145:INFO [launcher]: Starting browser Chrome
24 08 2023 13:41:34.081:INFO [Chrome 116.0.0.0 (Windows 10)]: Connected on socket 5qgCI-_c462ygcS9AAAB with id 87533278
Chrome 116.0.0.0 (Windows 10): Executed 5 of 8 SUCCESS (0 secs / 0.155 secs)
Chrome 116.0.0.0 (Windows 10): Executed 8 of 8 SUCCESS (0.242 secs / 0.196 secs)
TOTAL: 8 SUCCESS

Und wenn der Test erfolgreich war, sollte sich eine Browserinstanz mit dem Karma-Testrunner öffnen, und du solltest in etwa folgendes sehen (Klicken zum Vergrößern):

Den eben implementierten Test habe ich rot markiert. Damit du siehst, wie ein fehlgeschlagener Test aussieht, habe ich einfach mal die Bedingung negiert. Dies geht durch Einfügen des Wortes not:

expect(component.title).not.toBe("angular-demo-application");

Und so sieht das im Testrunner aus (Klicken zum Vergrößern):

Du siehst also sofort, welcher Test fehlgeschlagen ist, was die Ursache war, und du bekommst einen Stacktrace. Wenn mehrere Tests fehlschlagen, werden diese selbstverständlich auch angezeigt.


Spionage – Der Jasmine-Spy

Eine tolle Möglichkeit in Jasmine ist, dass du – wie bereits erwähnt – prüfen kannst, ob und wie oft eine bestimmte Funktion aufgerufen wird. Hierzu richtest du vorher einen sogenannten Spy ein. Dieser kann jedoch noch viel mehr. Du kannst damit auch Funktionen manipulieren, wenn du ihnen bestimmte Werte übergibst. Ich will dir hier zwei allgemeine Beispiele zeigen. Das erste davon benötigen wir dann später im Komponententest noch.


Wurde ich aufgerufen?

Zuerst zeige ich dir, wie du einen Spy so einrichtest, dass du einen Funktionsaufruf prüfen kannst. Dies sieht im Allgemeinen so aus:

class SomeClass {
        someMethod() {
                //  do something ...
        }
}
[...]
let someObject = new SomeClass();
spyOn(someObject, "someMethod");
[...]
someObject.someMethod();
[...]
expect(someObject.someMethod).toHaveBeenCalled();
expect(someObject.someMethod).toHaveBeenCalledTimes(<number>);

Hierbei wird mit spyOn der Spion eingerichtet. Man übergibt ihm das zu überwachende Objekt, und den zu überwachenden Methodennamen als String. Später kannst du die Methode wie gewohnt aufrufen, und mit expect(someObject.someMethod).toHaveBeenCalled() prüfen, ob die Methode aufgerufen wurde, und mit expect(someObject.someMethod).toHaveBeenCalledTimes(<number>) kannst du prüfen, wie oft die Methode aufgerufen wurde. Wenn du prüfen willst, ob die Methode nicht aufgerufen wurde, dann schreibst du einfach expect(someObject.someMethod).not.toHaveBeenCalled() oder alternativ expect(someObject.someMethod).toHaveBeenCalledTimes(0).

WICHTIG: Achte bitte darauf, dass du beim Einrichten des Spions den Methodennamen als Stringliteral angibst, da es sich technisch um einen keyof-Operator aus TypeScript handelt. Beim expect kannst du dann den Methodennamen wie im Beispiel angegeben verwenden. Falls du aber Gründe hast, den Namen in einer Variablen zu speichern, kannst du auch folgenden Workaround nutzen:

let variable = someObject.someMethod.name as keyof SomeClass;
spyOn(someObject, variable);
Bei den Integrationstests werde ich die Variante mit dem Stringliteral verwenden, da es für unser Mini-Projekt völlig ausreicht, und einfach und unkompliziert ist.

Der Manipulator

Ein weiteres bemerkenswertes Merkmal von Jasmine-Spionen ist ihre Verwendung als Mocks. Du kannst einen Spion einrichten und ihm Anweisungen geben, welche Ergebnisse er zurückliefern soll. Ein praktisches Beispiel dafür ist, wenn du in einem bestimmten Test Szenarien simulieren möchtest, die in der Realität zu zeitaufwendig wären, wie beispielsweise eine Datenbankabfrage. Du könntest dann stattdessen einfach dem Spy sagen, welche Werte er liefern soll. Das ermöglicht dir, verschiedene Szenarien zu testen, ohne reale Ressourcen zu beanspruchen. Die Verwendung von Mocks, wie sie durch Spione ermöglicht wird, ist besonders nützlich, wenn du Tests unabhängig von externen Ressourcen durchführen oder komplexe Szenarien simulieren möchtest, die in einer Testumgebung besser kontrolliert werden können. Die Funktionalität deiner Methode über den Spy kannst du wie im folgenden Beispiel ändern:
class SomeClass {
        someMethod(): number {
                return 1;
        }
}
[...]
let someObject = new SomeClass();
spyOn(someObject, "someMethod").and.returnValue(2);
[...]
let value = someObject.someMethod();
expect(value).toBe(2);

Hier sagst du nach dem Aufruf von spyOn() dem Spion, was er als Rückgabewert zurückgeben soll. Das and in der Mitte ist übrigens optional. Es handelt sich um eine Art syntaktischer Zucker und dient somit nur der besseren Lesbarkeit. Wichtig ist hier die Methode returnValue(). Diese gehört zur Klasse SpyStrategy von Jasmine. Mit dieser teilst du deinem Spion mit, welchen Wert er durch den Methodenaufruf zurückgeben soll. Du brauchst aber nicht explizit eine neue Instanz von SpyStrategy erzeugen, da du diese implizit durch spyOn() aufrufst.

Du kannst auch festlegen, dass eine Funktion nur mit bestimmten Werten aufgerufen werden darf, also beispielsweise:

class SomeClass {
        double(n: number): number {
                return 2 * n;
        }
}
[...]
let someObject = new SomeClass();
spyOn(someObject, "double").withArgs(3).and.returnValue(5);
[...]
let value = someObject.double(3);
expect(value).toBe(5);

Hier ist es erforderlich, dass du jeden zulässigen Wert gesondert einrichtest. In diesem Fall darf someObject.double() nur mit der Zahl 3 als Argument aufgerufen werden. Würdest du die Funktion mit anderen Argumenten aufrufen, würde dein Test fehlschlagen. Du könntest aber auch festlegen, dass er eine ganz andere Funktion aufrufen soll:

class SomeClass {
        double(n: number): number {
                return 2 * n;
        }
}
[...]
let someObject = new SomeClass();
spyOn(someObject, "double").and.callFake((n: number) => 3 * n);
[...]
let value = someObject.double(3);
expect(value).toBe(9);

In diesem Fall überschreibst du die Originalfunktion so, dass sie die angegebene Zahl nicht verdoppelt, sondern verdreifacht.


Plus de ça

Es gibt natürlich noch viel mehr Möglichkeiten in Jasmine. Da ich dir hier nur die Grundlagen zeigen möchte, lade ich dich dazu ein, im Anschluss gerne meine Quellenangaben durchzugehen und gerne auch durch Ausprobieren herauszufinden, welche weiteren Möglichkeiten Angular und Jasmine bieten.

Komponenten-Integration

Lassen wir uns nun anschauen, wie zwei Komponenten miteinander interagieren können. In unserem kleinen Projekt sind es die FriendBoxComponent und die ProfileRowComponent, die miteinander agieren. Der AddFriendService dient dabei als Vermittler. Wenn du beispielsweise bei einem Profil auf „Folgen“ klickst, übermittelt das Profil über den Service seine Informationen an die FriendBoxComponent. Dadurch weiß diese, welches Bild angezeigt werden soll. Vor der Anzeige muss jedoch geprüft werden, ob nicht bereits drei Profile vorhanden sind. Anschließend wird der „Folgen“-Link ausgeblendet.

Wir werden hierfür folgende Schritte durchgehen:

  • Ordner tests für die Integrationstests anlegen.
  • Weitere Unterordner für benötigte Klassen, Komponenten und Dienste nach dem Page Object Model anlegen.
  • Die eben erwähnten Klassen, Komponenten und Dienste implementieren.
  • Die entsprechenden Integrationstests implementieren.

Neue Ordnerstruktur

Lass uns die ersten beiden Schritte angehen. Du weißt bereits, dass sich unterhalb des Ordners src die Unterordner app und assets befinden. Erstelle nun einen weiteren Ordner direkt unter src mit dem Namen tests. Erstelle innerhalb dieses Ordners die Unterordner classes, components und services. Nach diesen Schritten sollte die Ordnerstruktur in etwa folgendermaßen aussehen:

angular-demo-application
├── src
│   ├── app
│   ├── assets
│   ├── tests
│   │   ├── classes
│   │   ├── components
│   │   ├── services
[...]

Der Unterordner classes wird die Mutterklasse Components enthalten, die gemeinsame Funktionen für die Freundschaftsbox und die Profile bereitstellt. In components werden die Klassen für die Freundschaftsbox und die Profile nach dem Page-Object-Modell abgelegt. Der AddFriendService wird im Ordner services zu finden sein.


Klassen-Implementierung

Als Nächstes legen wir die entsprechenden Klassen, Komponenten und Dienste an. Die erste Klasse ist die Mutterklasse der Komponenten. Der Dateiname lautet Component.ts und kommt direkt unterhalb von tests/classes. Der Inhalt sieht wie folgt aus:

import { ComponentFixture, TestBed } from "@angular/core/testing";
import { Type } from "@angular/core";

export class Component<T> {
        protected readonly fixture: ComponentFixture<T>;

        constructor(component: Type<T>) {
                this.fixture = TestBed.createComponent(component);
                this.update();
        }

        get(): ComponentFixture<T> {
                return this.fixture;
        }

	getElement(): any {
		return this.fixture?.nativeElement;
	}

        getInstance(): T {
                return this.fixture?.componentInstance;
        }

        public update() {
                this.fixture?.detectChanges();
        }

        public static update(...components: Component<any>[]) {
                for(let component of components) {
                        component?.get().detectChanges();
                }
        }
}

Diese generische Klasse ermöglicht es uns, mithilfe von TestBed eine neue Komponente zu erstellen und Änderungen zu aktualisieren. Die Methode get() gibt dir die Komponente selbst zurück. getElement() ermöglicht den Zugriff auf den DOM-Baum (das HTML-Element). getInstance() gibt dir Zugriff auf deine eigene Implementierung der Klasse, also auf Eigenschaften und Methoden. Die Methode update() aktualisiert Änderungen in der Komponente, ruft also um Prinzip detectChanges() der Komponente auf. Ich finde, das klingt einfach direkter. Weil genau das macht detectChanges() auch. Die statische Methode update() aktualisiert ebenfalls Änderungen, jedoch kannst du hier mehrere Komponenten angeben, damit du nicht jede Komponente einzeln updaten musst.

Die Spy-Klasse wird als Datei Spy.ts ins gleiche Verzeichnis abgelegt. Sie ist relativ minimalistisch und sieht wie folgt aus:

export class Spy {
        static on(object: any, functionName: string) {
                let spyObject = object;
                if("get" in object) spyObject = object.get();
                spyOn(spyObject, functionName);
        }
}

Die Zeile if("get" in object) spyObject = object.get(); ermöglicht es, die Methode get(), falls sie existiert, aufzurufen, und die Komponente oder den Dienst selbst als Objekt zu verwenden. Das benötigen wir, da der AddFriendService, ähnlich wie die Klasse Component, auch eine get()-Methode haben wird. Weil das der einzige Dienst ist, habe ich die Funktionen nicht in eine separate Klasse ausgelagert.


Dienst-Implementierung

Bevor du die Komponenten und die zugehörigen Tests implementierst, kümmern wir uns vorher noch kurz um den Freundschaftsdienst. Leg hierzu bitte die Datei AddFriendService im Verzeichnis services mit folgendem Inhalt an:

import { AddFriendService } from "src/app/add-friend.service";
import { TestBed } from "@angular/core/testing";

export class AddFriends {
        private readonly service: AddFriendService;

        constructor() {
                this.service = TestBed.inject(AddFriendService);
        }

        static getService(): typeof AddFriendService {
                return AddFriendService;
        }

        get(): AddFriendService {
                return this.service;
        }
}

Ähnlich wie bei der Komponenten-Klasse legt AddFriendService mithilfe der Methode TestBed.inject() einen neuen Dienst an. Auf die Dienstinstanz kannst du dann später über get() bzw. getService() zugreifen.


Implementierung der Komponenten

Als erstes implementieren wir die Freundschaftsbox als Klasse. Leg dazu bitte im Unterordner components die Datei Friendbox.ts mit folgendem Inhalt an:

import { Component } from "../classes/Component";
import { FriendBoxComponent } from "src/app/friend-box/friend-box.component";

export class FriendBox extends Component<FriendBoxComponent> {
        constructor() {
                super(FriendBoxComponent);
        }

        static getComponent(): typeof FriendBoxComponent {
                return FriendBoxComponent;
        }

        getFriendList(): HTMLDivElement {
                return this.getElement().querySelector("div.friend") as HTMLDivElement;
        }

        getFriendNodes(): NodeList {
		return this.getElement().querySelectorAll("div.friend") as NodeList;
	}

        beingEmtpy(): boolean {
                return this.lengthBeing(0);
        }

        lengthBeing(length: number): boolean {
                this.update();
                let instance = this.fixture?.componentInstance;
                let namesLength = instance.friendService.names.length == length;
                let textsLength = instance.friendService.texts.length == length;
                let imagesLength = instance.friendService.images.length == length;
                return namesLength && textsLength && imagesLength;
        }
}

Der Konstruktor ruft einfach die Mutterklasse auf. Eine wichtige Methode hier ist getComponent(), die auch in der Klasse Profile.ts implementiert ist. Diese Methode wird später bei der Implementierung der Integrationstests benötigt. Beim Verwenden von TestBed gibst du mithilfe von declarations und providers die Komponenten und Dienste an, die du importieren willst. Die beiden Klassen und der Dienst im Page-Object-Model können jedoch leider nicht direkt dort angegeben werden. Daher habe ich die Methoden getComponent() und getService() implementiert. Diese erlauben es, die entsprechenden Komponenten- und Dienst-Instanzen zu erhalten.

Anmerkung: Ich wollte die Methode getComponent() eigentlich ebenfalls in der Mutterklasse Component<T> implementieren. Jedoch erlaubt es TypeScript nicht, generische Datentypen als Return-Value zurückzugeben. Und da es sich hier nur um eine Minimal-Anwendung handelt, ging es schneller, die Methode zweimal gesondert zu implementieren, als eine generelle Lösung zu finden. In größeren Projekten kann dies aber notwendig sein, eine entsprechende Lösung zu erarbeiten.

In den Methoden getFriendList() und getFriendNodes() gibt es etwas Neues. Und zwar eine Abfrage mit einem CSS-Selektor. Da wir nämlich nach dem Page-Object-Model arbeiten, und wir eine Web-Anwendung haben, suchen wir auch Elemente aus dem DOM-Baum. In anderen Worten, wir suchen konkrete HTML-Elemente heraus, also bspw. über Tag-Name oder CSS-Klasse. Um solche Elemente zu finden, stehen dir in Angular die Methoden querySelector() und querySelectorAll() zur Verfügung. Der erste davon findet immer nur das erste Element und der zweite findet alle Elemente. Dort kannst du einen beliebigen String angeben, der das entsprechende HTML-Element eindeutig identifiziert. Es gibt auch noch andere Möglichkeiten HTML-Elemente zu suchen, bspw. über die Komponenten-Eigenschaft debugElement, welche mehrere query...-Methoden hat. Du könntest alternativ auch klassisch document.getElemementByXXX() verwenden. Doch kann es bei letzterem sein, dass er das entsprechende Element nicht immer  findet, insbesondere wenn du spezielle Angular-Komponenten suchst.

Das Element, dass wir suchen, ist übrigens ein div-Container mit der CSS-Klasse friend. Damit du weißt, worum es sich genau handelt, zeige ich dir hier den Quellcode der Komponente:

<div class="default-card padding-15 friend-row">
        <div *ngIf="friendService.names.length == 0" class="empty">
                Du hast leider noch keine Freunde. 😢
        </div>
      <div class="friend" *ngFor="let name of friendService.names; let i = index">
                <img src="{{friendService.getImage(i)}}"/> {{name}}
        </div>
</div>

Mit den beiden Methoden beingEmpty() und lengthBeing() kannst du ermitteln, ob die Freundschaftsliste noch leer ist, bzw. wie viele bereits vorhanden sind. Dazu prüft die Methode einfach über die Eigenschaft length, ob die drei Arrays names, texts und images die gleiche Anzahl an Elementen haben. Vor der eigentlichen Bedingung wird übrigens die entsprechende Komponente noch mal geupdatet, falls irgendwelche Änderungen stattgefunden haben.

Nachdem ich dir die Methoden querySelector() und querySelectorAll() gezeigt habe, kann ich dir jetzt die Implementierung der Profil-Klasse zeigen. Leg dazu unter components die Datei Profile.ts mit folgendem Inhalt an:

import { Component } from "../classes/Component";
import { ProfileRowComponent } from "src/app/profile-row/profile-row.component";

export class Profile extends Component<ProfileRowComponent>  {
        private id: string;
        private name: string;
        private description: string;
        private canFollow: boolean;

        constructor(object: any) {
                super(ProfileRowComponent);
                let instance = this.getInstance();
                let profile = object as Profile;
                this.id = profile.id;
                this.name = profile.name;
                this.description = profile.description;
                this.canFollow = profile.canFollow;

                instance = instance?.with({
                        id: this.id,
                        name: this.name,
                        description: this.description,
                        canFollow: this.canFollow
                });
                this.update();
        }

        static getComponent(): typeof ProfileRowComponent {
                return ProfileRowComponent;
        }

        getImage(): HTMLImageElement {
                return this.getElement().querySelector("img");
        }

        getName(): HTMLDivElement {
                return this.getElement().querySelector("div.name");
        }

        getDescription(): HTMLSpanElement {
                return this.getElement().querySelector("span.description");
        }

        getFollowLink(): HTMLAnchorElement {
                return this.getElement().querySelector("a#follow");
        }

        clickOnFollow() {
                this.getFollowLink().click();
        }
}

Diese Klasse ruft im Konstruktor auch wieder ihre Mutterklasse auf. Da der Konstruktor der Klasse Profile jedoch als Parameter ein Objekt vom Typ Profile mit Profilname, Beschreibung und Bildnummer erwartet, muss der Konstruktor diese Eigenschaften auch setzen und die Komponente updaten. Bei den darauffolgenden Methoden handelt es sich, wie bei der Klasse FriendBox, um Methoden, die aus dem entsprechenden Profil bestimmte HTML-Tags heraussuchen. Hier siehst du den Quellcode der Komponente profile-row-component.ts:

<div class="profile-row margin-bottom-15">
        <img src="/assets/images/cards/{{id}}.webp"/>
        <div class="name">
                <b>{{name}}</b><br/>
                <span class="description">{{description}}</span>
        </div>
        <a id="follow" *ngIf="canFollow" (click)="addFriend(name, description, id)">Folgen</a>
</div>

Des Weiteren klickt die Methode click() auf den „Folgen“-Link. Auch wenn als Event ein spezielles Angular-Event angegeben ist, kannst du hier ganz gewöhnlich die click()-Methode aufrufen, die in allen HTML-Elementen zur Verfügung steht.


Testimplementierung

Nun ist alles vorbereitet, was du für die Durchführung der Komponententests benötigst. Lass uns jetzt zum eigentlichen Test übergehen. Erstelle dafür eine Datei namens custom.spec.ts direkt im Ordner tests. In diesem Fall ist es das einzige Testskript, daher verzichten wir auf weitere Unterverzeichnisse. Bei größeren Projekten könnte es jedoch sinnvoll sein, die Tests entsprechend der fachlichen Anwendung weiter zu strukturieren. Im Repository findest du eine ausführlichere Version dieser Datei, die zusätzliche Komponententests enthält. Ich habe Stellen, an denen ich Inhalte gekürzt habe, mit [...] markiert. Nachfolgend findest du den für dich relevanten Codeausschnitt:

import { AddFriends } from "./services/AddFriends";
import { Component } from "./classes/Component";
import { FriendBox } from "./components/FriendBox";
import { Profile } from "./components/Profile";
import { Spy } from "./classes/Spy";
import { TestBed } from "@angular/core/testing";

describe("Custom component tests:", () => {
        const TEMPLATE = {
                id: "1",
                name: "Robert",
                description: "Das ist mein Testprofil.",
                canFollow: true
        };

        let service: AddFriends;
        let friendBox: FriendBox;
        let profile: Profile;

        beforeEach(async() =>{
                TestBed.configureTestingModule({
                        declarations: [FriendBox.getComponent(), Profile.getComponent()],
                        providers: [AddFriends.getService()]
                }).compileComponents();
        });

        beforeEach(() => {              
                service = new AddFriends();
                friendBox = new FriendBox();
                profile = new Profile(TEMPLATE);
        });

        [...]

        it("should not be able to add more than 3 profiles (Test #4)", () => {
                let profile1: Profile = new Profile(TEMPLATE);
                let profile2: Profile = new Profile(TEMPLATE);
                let profile3: Profile = new Profile(TEMPLATE);
                let profile4: Profile = new Profile(TEMPLATE);

                expect(friendBox.beingEmtpy()).toBeTrue();

                profile1.clickOnFollow();
                profile2.clickOnFollow();
                profile3.clickOnFollow();
                Component.update(friendBox, profile1, profile2, profile3);

                let friends = friendBox.getFriendNodes();
                expect(friends.length).toBe(3);
                expect(profile1.getFollowLink()).toBeFalsy();
                expect(profile2.getFollowLink()).toBeFalsy();
                expect(profile3.getFollowLink()).toBeFalsy();

                profile4.clickOnFollow();
                Component.update(friendBox, profile4);

                friends = friendBox.getFriendNodes();
                expect(friends.length).toBe(3);
                expect(profile4.getFollowLink()).toBeTruthy();
        })
});

Der Einfachheit halber habe ich als Vorlage ein Profil mit dem Namen TEMPLATE erstellt. In meinem Test erzeuge ich dieses Profil später viermal. Du kannst gerne auch vier verschiedene Profile erstellen, jederzeit mit unterschiedlichem Namen, Beschreibung und Bildnummer. Wie du vielleicht bemerkt hast, habe ich mich auch hier an die Best Practice gehalten, die Komponenten, die ich im TestBed erzeuge, in einen asynchronen beforeEach-Block zu platzieren, während die benötigten Variablen in einem synchronen beforeEach-Block definiert werden.

Der Test selbst verläuft insgesamt folgendermaßen: Zuerst erstelle ich die bereits vier erwähnten Profile. Anschließend prüfe ich, ob die Freundesliste am Anfang leer ist. Um Freunde hinzuzufügen, klicke ich bei den ersten drei Profilen jeweils auf den „Folgen“-Link. Anschließend aktualisiere ich die Komponenten, um sicherzustellen, dass die Änderungen erkannt werden. An dieser Stelle hole ich auch die aktualisierte Freundesliste ab. Im nächsten Schritt überprüfe ich, ob tatsächlich drei Freunde zur Liste hinzugefügt wurden, wie erwartet. Zusätzlich prüfe ich, ob der „Folgen“-Link bei den ersten drei Profilen deaktiviert ist. Jetzt teste ich, wie die Anwendung reagiert, wenn ich beim vierten Profil auf den „Folgen“-Link klicke. Auch hier aktualisiere ich die Komponenten, um die Änderungen zu reflektieren. Zum Abschluss überprüfe ich, ob immer noch drei Freunde in der Liste vorhanden sind und ob der „Folgen“-Link noch aktiv ist. Dies stellt sicher, dass die Anwendung korrekt auf die Interaktionen reagiert und die Anzeige der Freunde richtig aktualisiert wird.

Zusätzlich findest du in der Konsolenausgabe auch folgende Meldung:

ALERT: 'Du kannst nicht mehr als 3 Freunde haben.'

Diese Ausgabe wird direkt von der Methode addFriend in der Komponente ProfileRowComponent generiert, die den AddFriendService verwendet. Um das genauer zu verstehen, kannst du dir den folgenden Code-Ausschnitt ansehen:

        [...]
        addFriend(name: string, description: string, id: number) {
                let result = this.friendService.addFriend(name, description, id);
                if(result == 200) {
                        this.canFollow = false;
                }
                else if(result == 400) {
                        alert("Du kannst nicht mehr als 3 Freunde haben.");
                }
                else {
                        alert("Unerwarteter Fehler");
                }
        }
        [...]

Herzlichen Glückwunsch!

Wenn nun alles gut gelaufen ist, sollten alle Tests erfolgreich sein und der Inhalt deines Testrunners in etwa so aussehen (Klicken zum Vergrößern):

Falls deine Tests fehlgeschlagen sein sollten, empfehle ich dir, gründlich nach möglichen Fehlern zu suchen. Sollte der Fehler dennoch wider Erwarten in meinem bereitgestellten Code liegen, dann kontaktiere mich gerne per Kommentar. Ich werde den Fehler baldmöglichst im Repository korrigieren.

Selbstverständlich steht es dir frei, lokal am Repository weiter zu experimentieren und zusätzliche Tests zu implementieren – sei es Unittests oder Integrationstests. Erkunde gerne auch die von mir bereitgestellten Tests, um weitere Einblicke zu erhalten. Probier gerne auch End-to-End-Tests aus, falls dich das interessiert. Das wird von Karma genauso unterstützt. Mit diesem Wissen sind wir nun im Bereich der Testimplementierung gut aufgestellt.


Projekt-Konfiguration

Auf die Konfiguration von Angular, Karma und Jasmine bin ich bewusst nicht eingegangen – der Einfachheit halber. Du kannst in Angular noch alles Mögliche in der Datei angular.json einstellen, z.B. welches Testframework du verwenden möchtest, auch für End-to-End-Tests. Du kannst dort beispielsweise Cypress, Playwright oder auch Selenium einsetzen. Die Einstellungen von Karma und Jasmine kannst du in einer Karma-Konfigurations-Datei vornehmen, z.B. in welchem Browser deine Tests starten sollen. Für die Konfiguration sind mehrere Dateinamen zulässig. Eine Möglichkeit ist karma.conf.js. Falls dich das alles interessiert, bitte ich dich, unten die Quellenangaben durchzustöbern.


Wie geht es weiter?

Ich habe dir nun die grundlegenden Schritte zur Erstellung einer Angular-Anwendung und zum Testen der einzelnen Komponenten vermittelt. Die nächste Phase liegt bei dir – du kannst dein neu gewonnenes Wissen vertiefen oder in deinem eigenen Projekt anwenden. Selbst wenn Angular nicht in deinem Projekt verwendet wird, kannst du die Prinzipien, die ich dir gezeigt habe, sicherlich adaptieren. Wenn du bis hierhin gelesen und aktiv mitgemacht hast, danke ich dir herzlich für deine Aufmerksamkeit und wünsche dir viel Erfolg bei all deinen kommenden Projekten. Bei Fragen und Anregungen freue ich mich auf einen Kommentar von dir, und bin selbstverständlich auch offen für konstruktive Kritik.

Quellenangaben


1 Kommentar

Trackbacks & Pingbacks

  1. […] implementiert hast, ist es an der Zeit, sie zu testen. Dazu lade ich dich auf meinen zweiten Teil Einführung in Angular-Komponenten (Part 2) […]

Hinterlasse einen Kommentar

An der Diskussion beteiligen?
Hinterlasse uns deinen Kommentar!

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert