Beiträge

Anmerkung: In diesem Blog 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 Lesern und Leserinnen mein höchster Respekt.

Einführung in Angular

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.


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


 

Anmerkung: In diesem Blog 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 Lesern und Leserinnen mein höchster Respekt.

Ein kleines Tutorial

Im Rahmen seiner Tätigkeit in der IT kommt man um bestimmte Frameworks nicht herum. Oft werden sogar mehrere Frameworks in einem einzigen Projekt verwendet, da jedes einen anderen Zweck erfüllt. Persönlich bin ich nun schon zum zweiten Mal auf Angular gestoßen. Von daher lade ich dich dazu ein, dass wir hier gemeinsam eine kleine, unkomplizierte Anwendung in diesem Framework erstellen und die verschiedenen Komponenten daraufhin testen. Wir halten uns hierbei an das K.I.S.S.-Prinzip (Keep It Simple and Stupid), was bedeutet, dass ich in diesem Blog nur die grundlegenden Konzepte erläutere, um das Verständnis zu erleichtern. In diesem Beitrag arbeite ich unter Windows, verwende die Eingabeaufforderung (cmd.exe) als Terminal innerhalb von Visual Studio Code als IDE. Selbstverständlich steht es dir frei, unter einem anderen Betriebssystem, mit einem anderen Terminal oder auch mit einer anderen IDE zu arbeiten. Ich bitte dich jedoch, dass du dich selbstständig mit diesen vertraut machst, insbesondere wenn es darum geht, etwas zu installieren.


Für wen ist dieser Artikel?

Dieser Blog-Beitrag richtet sich an dich, wenn du dich zum ersten Mal mit Angular beschäftigst. Hier werden die Grundlagen vermittelt, um eine Angular-Anwendung zu erstellen und sie mit Karma und Jasmine zu testen. Es ist wichtig, dass du über folgende Grundkenntnisse verfügst:

  • Terminologie:
    • Page Object Modell
    • Komponenten
    • Services (Dienste)
  • Technologien:
    • HTML
    • (S)CSS
    • JavaScript / TypeScript
    • Node.js
    • Testing-Frameworks (allgemein)
    • XPath (o.ä.)

Was ist das Ziel?

Das Hauptziel dieses Artikels ist es, am Ende eine kleine, unkomplizierte Angular-Anwendung zu haben, die ein soziales Netzwerk simuliert. In dieser Anwendung können bis zu drei Freunde hinzugefügt werden. Wir werden diesen Vorgang auch durch Integrationstests verifizieren. Da das Thema dennoch sehr umfangreich ist, habe ich es auf zwei Teile aufgeteilt. In diesem hier werden die Grundlagen von Angular und die Implementierung der Anwendung behandelt und im zweiten Teil wollen wir unsere Anwendung dann durch Unit- und Integrationstests testen.


Repository zum Mitarbeiten

Zum Nachverfolgen der Schritte und für den Zugriff auf die im Projekt genutzten Bilddateien benötigst du das zugehörige Repository. Du kannst es mit Git (oder GitHub Desktop) über den Link in den Quellenangaben klonen. Zum selbstständigen Erarbeiten der Schritte empfehle ich dir, zunächst nur die Bilddateien herunterzuladen und sie in den Assets-Ordner (siehe Ordnerstruktur in Angular) zu kopieren.


Einführung in Angular

Angular installieren

Starte nun bitte das Terminal. Bevor du Angular installierst, prüfe bitte sicherheitshalber noch mal die Versionen von Node.js und npm:

C:\Users\Robert>node -v
v18.17.0

C:\Users\Robert>npm -v
9.6.7

Sollte eine Fehlermeldung auftreten, zum Beispiel:

C:\User\Robert>node -v
'node' is not recognized as an internal or external command, operable program or batch file.

C:\User\Robert>npm -v
'npm' is not recognized as an internal or external command, operable program or batch file.

Dann prüfe bitte den Installationspfad von Node.js und die Umgebungsvariable PATH. Falls erforderlich, lade Node.js erneut herunter und installiere es erneut. Der Paketmanager npm wird dabei automatisch mitinstalliert.

Wenn Node.js und npm installiert sind, kannst du nun auch Angular installieren:

C:\Users\Robert>npm install -g @angular/cli

Neben Angular werden automatisch auch noch der Testrunner Karma und das Testframework Jasmine installiert. Dazu kommen wir später. Nach der Installation von Angular steht dir nun der Befehl ng zur Verfügung und du kannst ein neues Projekt anlegen:

C:\User\Robert>ng new angular-demo-application
? Would you like to add Angular routing? (y/N) y
? Which stylesheet format would you like to use?
  CSS
> SCSS [ https://sass-lang.com/documentation/syntax#scss ]
  Sass [ https://sass-lang.com/documentation/syntax#the-indented-syntax ]
  Less [ http://lesscss.org ]
...
CREATE angular-demo-application/... (... bytes)
...
| Installing packages (npm)...

  • Die Option für Angular-Routing ermöglicht dir das Hinzufügen von Unterseiten zur Anwendung. Dies hat jedoch nichts mit den eigentlichen Komponenten zu tun.
  • Die Stylesheet-Sprache kannst du nach Belieben wählen. Meine persönliche Empfehlung ist CSS oder (wie in diesem Projekt) SCSS. SCSS ist eine Erweiterung von CSS, die zusätzliche Funktionen wie Variablen, verschachtelte Selektoren und Mixins bietet. Dadurch wird das Styling deiner Anwendung effizienter und organisierter.

Ordnerstruktur in Angular

Bevor du überhaupt eine Zeile Code schreibst, ist es wichtig, dass du dich mit der Projektstruktur vertraut machst. Im Folgenden skizziere ich dir die grundlegende Ordnerstruktur, wobei ich mich auf diejenigen Ordner und Dateien konzentriere, die für diesen Blogbeitrag relevant sind.

angular-demo-application
├── dist
│   └── angular-demo-application
├── node_modules
├── src
│   ├── app
│   │   ├── example
│   │   │   ├── example.component.html
│   │   │   ├── example.component.scss
│   │   │   ├── example.component.spec.ts
│   │   │   └── example.component.ts
│   │   ├── app-routing.module.ts
│   │   ├── app.component.html
│   │   ├── app.component.scss
│   │   ├── app.component.spec.ts
│   │   ├── app.component.ts
│   │   └── app.module.ts
│   ├── assets
│   ├── favicon.ico
│   ├── index.html
│   ├── main.ts
│   └── styles.scss
├── angular.json
├── package.json
└── tsconfig.json

Kurze Erklärung:

  • angular-demo-application: Jedes Angular-Projekt befindet sich in einem eigenen Ordner, wie du es sicher von anderen Entwicklungsumgebungen kennst.
  • dist/angular-demo-application: Hierhin wird die Anwendung kompiliert. Alle benötigten Source-Files werden hineinkopiert, und die TypeScript-Dateien werden in gewöhnliches JavaScript transpiliert.
  • node_modules: Dieser Ordner enthält die Abhängigkeiten und Pakete, die die Angular-Anwendung benötigt. Diese werden normalerweise automatisch von Angular und npm verwaltet.
  • src: Der Hauptordner deiner Angular-Anwendung, in dem der Großteil des Quellcodes und der Ressourcen enthalten sind.
  • app: Dies ist der Kern der Anwendung. Hier findest du alle Angular-Komponenten, Module, Services und andere wichtige Teile.
  • example: Ein Beispielordner für eine Komponente. Eine Komponente besteht in der Regel aus vier Dateien:
    • example.component.html: Enthält die grobe Struktur der Komponente als HTML-Code.
    • example.component.scss: Enthält die Stylesheet-Informationen in CSS bzw. SCSS.
    • example.component.spec.ts: Enthält die Unittests in TypeScript für die Komponente.
    • example.component.ts: Enthält die Grundlogik der Komponente in TypeScript.
  • app-routing.module.ts: Hier werden die Unterseiten der Anwendung verwaltet. In diesem Blogbeitrag gehe ich nicht näher darauf ein, da wir nur eine Single-Page-Anwendung haben.
  • app.component.html: Die Hauptkomponente der Anwendung. Hier können andere Komponenten eingebettet werden.
  • app.component.scss: Die globale Stylesheet-Datei für die gesamte Anwendung.
  • app.component.spec.ts: Unittests in TypeScript für die Hauptkomponente.
  • app.component.ts: Die Grundlogik der Hauptkomponente.
  • app.module.ts: Das Hauptmodul der Anwendung, das alle Komponenten, Dienste und andere Module zusammenführt.
  • assets: Hier werden statische Dateien, z.B. Bilder, Schriftarten usw. abgelegt, die unsere Anwendung benötigt.
  • favicon.ico: Das Icon der Anwendung, das in der Browser-Registerkarte angezeigt wird.
  • index.html: Die Einstiegs-HTML-Datei für die Anwendung. Hier wird normalerweise das HTML-Tag <app-root> platziert, das die Hauptkomponente einfügt.
  • main.ts: Die Einstiegsdatei für deine Anwendung, in der die Anwendung gestartet wird.
  • styles.scss: Die globale Stylesheet-Datei für die gesamte Anwendung, die auf alle Komponenten angewendet wird.
  • angular.json: Die Konfigurationsdatei des Angular-Projekts, in der verschiedene Einstellungen wie Build-Optionen, Erweiterungen usw. festgelegt werden
  • package.json: Die Datei, die die Metadaten und Abhängigkeiten des Projekts enthält. Hier werden die benötigten Pakete und ihre Versionen aufgeführt.
  • tsconfig.json: Die TypeScript-Konfigurationsdatei, die Einstellungen für den TypeScript-Compiler enthält.

Angular-Anwendung starten

Ist der Start der Anwendung zum jetzigen Zeitpunkt überraschend für dich? Schließlich hast du ja noch gar keinen eigenen Code geschrieben, oder? Ja, das ist richtig. Aber Angular erstellt mit dem Befehl ng new automatisch eine Art Demo-Anwendung. Wenn wir uns die index.html ansehen, steht da lediglich Folgendes drin:

<!doctype html>
<html>
        <head>
                <meta charset="utf-8">
                <title>AngularDemoApplication</title>
                <base href="/">
                <meta name="viewport" content="width=device-width, initial-scale=1">
                <link rel="icon" type="image/x-icon" href="favicon.ico">
        </head>
        <body>
                <app-root></app-root>
        </body>
</html>

Die soll auch so bleiben, denn die eigentliche Anwendung wird durch die beiden Tags <app-root></app-root> eingebettet. Die eingebettete HTML-Datei dazu ist die app.component.html. In der zugehörigen TypeScript-Datei sieht man, dass sie das Tag <app-root> hat:

import { Component, OnInit } from "@angular/core";

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

        ngOnInit() {}
}

Der Dekorator @Component beschreibt die Komponente genauer. Dort siehst du folgende drei Eigenschaften:

  • selector: Hier steht String, mit dem du bestimmen kannst, wie das HTML-Tag heißen soll, mit dem du auf deine Komponenten zugreifst. In diesem Blog halten wir uns an die Default-Konvention, dass vor den Komponentennamen im HTML das Wort app mit Bindestrich steht.
  • templateUrl: Hier steht, zu welcher HTML-Datei das TypeScript, das die Logik implementiert, gehört.
  • styleUrls: Hierbei handelt es sich um ein Array, in welchem alle (S)CSS-Dateien angegeben sind, die das Design der HTML-Komponente beschreiben.
Um die Anwendung zu starten, verwende den Befehl ng serve. Wenn du Visual Studio Code verwendest, empfehle ich dir, das Terminal innerhalb der IDE zu verwenden. Dadurch erkennt die IDE, wenn du Code bearbeitest und speicherst, und kompiliert deine Anwendung automatisch nach jedem Speichervorgang. Das ermöglicht dir eine „Live“-Entwicklung deiner Anwendung. Falls du jedoch ein extra Terminal verwendest, kannst du deine Anwendung auch manuell mit ng build kompilieren lassen. Wichtig ist außerdem, dass du dich stets im Angular-Projektordner befindest. Sollte dies nicht der Fall sein, quittiert Angular dies mit der folgenden Fehlermeldung:
C:\Users\Robert\>ng serve
The serve command requires to be run in an Angular project, but a project definition could not be found.
Falls dies du also nicht in deinem Projektordner bist, wechsle dort hinein, beispielsweise mit:
C:\Users\Robert\>cd angular-demo-application
C:\Users\Robert\angular-demo-application\>
Und dann steht dem Start nichts mehr im Wege:
C:\Users\Robert\angular-demo-application>ng serve --open
√ Browser application bundle generation complete.

Initial Chunk Files | Names | Raw Size
vendor.js | vendor | 2.33 MB |
polyfills.js | polyfills | 333.21 kB |
styles.css, styles.js | styles | 230.94 kB |
main.js | main | 48.15 kB |
runtime.js | runtime | 6.54 kB |

| Initial Total | 2.94 MB

Build at: 2023-08-22T14:03:13.256Z - Hash: 56ce367093804d17 - Time: 5318ms

** Angular Live Development Server is listening on localhost:4200, open your browser on http://localhost:4200/ **


√ Compiled successfully.
√ Browser application bundle generation complete.

Initial Chunk Files | Names | Raw Size
main.js | main | 48.15 kB |
runtime.js | runtime | 6.54 kB |

3 unchanged chunks

Build at: 2023-08-22T14:15:48.519Z - Hash: 637f1efa8a1a1564 - Time: 1340ms

√ Compiled successfully.

Die Befehlsoption --open signalisiert Angular, dass die Anwendung im Standardbrowser geöffnet werden soll. Wenn du die Option weglässt, kannst du die Anwendung nach dem Kompilieren auch manuell öffnen, indem du in der Adresszeile deines Browsers die URL localhost:4200 eingibst und mit der Eingabetaste bestätigst. Jedenfalls sollte das Ganze nun so aussehen (Klicken zum Vergrößern):


Komponenten in Angular erstellen

Angular-Anwendungen bestehen ja hauptsächlich aus Komponenten und Diensten. Zuerst wollen wir also eine neue Komponente erstellen. Wir nennen diese header. Um sie zu erstellen, gibt es den Befehl ng generate component. Also generiere bitte deine erste, eigene Komponente. Dazu kannst du einfach ein weiteres Terminal öffnen. Wichtig ist nur, dass du im Angular-Projektordner bist.

C:\Users\Robert\angular-demo-application>ng generate component header
CREATE src/app/header/header.component.html (21 bytes)
CREATE src/app/header/header.component.spec.ts (559 bytes)
CREATE src/app/header/header.component.ts (203 bytes)
CREATE src/app/header/header.component.scss (0 bytes)
UPDATE src/app/app.module.ts (475 bytes)

Jetzt haben wir einen neuen Ordner in unserer Projektstruktur:

angular-demo-application
├── src
│   ├── app
│   │   ├── header
│   │   │   ├── header.component.html
│   │   │   ├── header.component.scss
│   │   │   ├── header.component.spec.ts
│   │   │   └── header.component.ts
│   │   ├── app-routing.module.ts
│   │   ├── app.component.html
│   │   ├── app.component.scss
│   │   ├── app.component.spec.ts
│   │   ├── app.component.ts
│   │   └── app.module.ts
[...]

Wie erwartet hat Angular für uns einen Unterordner für die Header-Komponente mit den vier benötigten Dateien erstellt. Zuerst wollen wir uns um die HTML-Datei kümmern. Standardmäßig fügt er eine Art Default-Code ein, in diesem Fall:

<p>header works!</p>

Ersetz den kompletten Code in der header.component.html bitte durch folgenden:

<header>
        <img src="assets/images/simplytest.png"/>
</header>

Falls du das Beispiel selbst ausprobierst, ist es spätestens jetzt wichtig, dass du die Bilder in den Assets-Ordner geladen hast, damit die Angular-Anwendung richtig dargestellt wird. Wir wollen das ganze auch ein bisschen schön formatieren. Deswegen trag in die Datei header.component.scss bitte Folgendes ein:

header {
        background-color: lightgray;
        box-shadow: 8px 8px 8px rgba(0, 0, 0, 0.5);
}

Und damit wir den Header auch sehen, ersetzt einfach alles aus der app.component.html erst einmal durch folgenden Code:

<app-header/>
Das wird dann später noch erweitert. Das ganze wollen wir mit einer Hintergrundfarbe versehen, ich habe eine Art helles Gelb-Grau genommen. Du darfst gerne auch mit anderen Farben experimentieren. Trag bitte Folgendes in die app.component.scss ein:
html, body {
        height: 100%;
}

body {
        background-color: rgba(255, 255, 222, 0.75);
        font-family: Arial, Helvetica, sans-serif;
        margin: 0;
}

.default-card {
        background-color: #123456;
        border-radius: 10px;
        box-shadow: rgba(0, 0, 0, 0.5);
        box-sizing: border-box;
        margin: 15px;
        margin-bottom: 10px;
        width: 480px;
}

.margin-bottom-15 {
        margin-bottom: 15px;
}

.padding-15 {
        padding: 15px;
}

Die CSS-Klassen .default-card, .margin-bottom-15 und .padding-15 brauchen wir später noch. Es kommen ja noch weitere Komponenten dazu. Wenn du alles richtig gemacht hast, sollte deine Angular-Anwendung nun wie folgt aussehen (Klicken zum Vergrößern):

Du kannst in der HTML-Datei der Komponente wie gewohnt jeglichen HTML-Code einfügen, um die Struktur genauer zu beschreiben. Selbstverständlich kannst du auch andere Komponenten, die du bereits erstellt hast, dort einbetten. Das Gleiche haben wir ja hier in der Hauptkomponente app.component.html gemacht. Und genauso kannst du die Style-Informationen in der entsprechenden (S)CSS-Datei deiner Komponente anpassen, die dann hierarchisch nur für die Komponente und darunter liegende Elemente gelten.


Quellcode der Komponenten

Schauen wir uns kurz die beiden TypeScript-Dateien an. Dort sollte nur wenig Code drinstehen, der in etwa wie folgt aussehen sollte:

header.component.ts:

import { Component, OnInit } from "@angular/core";

@Component({
        selector: "app-header",
        templateUrl: "./header.component.html",
        styleUrls: ["./header.component.scss"]
})
export class HeaderComponent implements OnInit {
        ngOnInit() {}
}

header.component.spec.ts:

import { ComponentFixture, TestBed } from "@angular/core/testing";
import { HeaderComponent } from "./header.component";

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

        beforeEach(() => {
                TestBed.configureTestingModule ({
                        imports: [],
                        declarations: [HeaderComponent]
                }).compileComponents();
                fixture = TestBed.createComponent(HeaderComponent);
                component = fixture.componentInstance;
                fixture.detectChanges();
        });

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

Falls sie komplett anders aussehen, bspw. könnte es sein, dass im Testscript vorgefertigte Tests drinstehen, dann ersetze sie bitte durch obigen Code. Ersetze auch bitte die beiden TypeScript-Dateien der App-Komponente durch folgenden Code:

app.component.ts:

import { Component, OnInit } from "@angular/core";

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

        ngOnInit() {}
}

app.component.spec.ts:

import { AppComponent } from "./app.component";
import { ComponentFixture, TestBed } from "@angular/core/testing";

describe("AppComponent", () => {
        let component: AppComponent;
        let fixture: ComponentFixture<AppComponent>;
      
        beforeEach(() => {
                TestBed.configureTestingModule({
                        imports: [],
                        declarations: [AppComponent]
                }).compileComponents();

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

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

Für diese Beispiel-Anwendung bitte ich dich, nach dem Erstellen der Komponente jeweils die beiden TypeScript-Dateien wie oben anzupassen. Ich gehe dann später noch auf die entsprechenden Komponenten-Tests ein.

Als Nächstes benötigen wir zusätzliche Komponenten, die in die Anwendung eingebettet werden sollen. Verwende den Befehl ng generate component, um die unten angegebenen vier Komponenten zu generieren. Achte dabei bitte auf die Schreibweise. Hier kannst du noch mal nachsehen, wie das geht.

  • friend-box: Diese Komponente repräsentiert eine Box, in der die Profile angezeigt werden, denen du folgst.
  • card: Diese Komponente stellt eine Profilkarte dar. Sie enthält ein Bild, darunter den Benutzernamen und eine Beschreibung sowie zwei Schaltflächen: „Like“ und „Message“.
  • profile-row: Hierbei handelt es sich um einzelne Profile, die in den Profilvorschlägen angezeigt werden.
  • proposals: Diese Komponente zeigt die zuvor erwähnten Profilvorschläge an.

Dienste in Angular erstellen

Bevor du die Komponenten selbst anpassen kannst, ist erst noch ein weiterer Schritt durchzuführen. Das Ziel ist es, auf unserem kleinen, sozialen Netzwerk Freunde hinzuzufügen. Die Komponenten FriendBox und ProfileRow müssen dabei miteinander kommunizieren können. Wie du es sicherlich aus anderen sozialen Netzwerken kennst, wenn du auf „Folgen“ klickst, sollte der „Folgen“-Link verschwinden oder deaktiviert werden. Schließlich kannst du einem anderen Account nicht zweimal folgen. Außerdem müssen die Profile in der Freundschaftsbox angezeigt werden. Diese Funktionalität kannst du mit einem Dienst realisieren. In Angular erstellst du einen Dienst mit dem Befehl ng generate service:
C:\Users\Robert\angular-demo-application>ng generate service add-friend
CREATE src/app/add-friend.service.spec.ts (373 bytes)
CREATE src/app/add-friend.service.ts (138 bytes)

C:\Users\Robert\angular-demo-application>
Im Gegensatz zu den Komponenten erzeugt Angular hier jetzt nur zwei Dateien, und zwar direkt im app-Verzeichnis: app-friend-service.spec.ts und app-friend-service.spec.ts. Normalerweise ist es vielleicht angenehmer, Dienste in einem speziellen Unterordner zu haben, insbesondere wenn mehrere Dienste vorhanden sind. Doch für unsere kleine Anwendung reicht es vorerst aus. Die erste TypeScript-Datei enthält wieder die Logik, während die zweite die Unittests beinhaltet. Hier ist der Inhalt des Spec-Files, ähnlich wie bei den Komponenten, aber mit einigen Anpassungen:
import { AddFriendService } from "./add-friend.service";
import { TestBed } from "@angular/core/testing";

describe("AddFriendService", () => {
        let service: AddFriendService;

        beforeEach(() => {
                TestBed.configureTestingModule({});
                service = TestBed.inject(AddFriendService);
        });

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

Der Hauptunterschied liegt darin, dass du keine Komponente hast, die mit der Methode createComponent() erzeugt wird. Stattdessen hast du einen Dienst, der mit inject() injiziert wird. Die Datei app-friend-service.ts sollte momentan in etwa so aussehen:

import { Injectable } from "@angular/core";

@Injectable({
        providedIn: "root"
})
export class AddFriendService {
        constructor() { }
}

Im Gegensatz zu den Komponenten steht hier der Dekorator @Injectable, da es sich, wie bereits erwähnt, um einen Dienst handelt. Die Eigenschaft providedIn gibt an, wo der Dienst zur Verfügung steht:

  • „any“: Der Dienst steht der gesamten Anwendung zur Verfügung, und es können auch mehrere Dienstinstanzen vorhanden sein.
  • „root“: Der Dienst steht der gesamten Anwendung zur Verfügung, wobei es nur eine Dienstinstanz gibt – ähnlich einer statischen Klasse.
  • <ModulName>: Wenn du einen Modulnamen als Bezeichner angibst, steht der Dienst nur diesem Modul zur Verfügung.
  • <ServiceName>: Wenn du einen Dienstnamen als Bezeichner angibst, steht der Dienst nur einem anderen Dienst zur Verfügung.

Implementierung des Freundschaftsdienstes

Jetzt wollen wir die Logik des Dienstes implementieren. Er ist dafür verantwortlich, dass du in der Anwendung Freunde hinzufügen kannst:

import { Injectable } from "@angular/core";

@Injectable ({
        providedIn: "root"
})
export class AddFriendService {
        names: string[] = [];
        texts: string[] = [];
        images: number[] = [];

        getImage(index: number) {
                return `/assets/images/cards/${this.images[index]}.webp`;
        }

        addFriend(name: string, text: string, id: number): number {
                if(this.names.length >= 3)
                        return 400;

                this.names.push(name);
                this.texts.push(text);
                this.images.push(id);
                return 200;
        }

        constructor() { }
}

Der Dienst enthält drei Arrays: names, texts und images, in denen die Informationen der Freunde gespeichert sind. Die Methode getImage() verwendet den Index, um den vollständigen Dateinamen mit relativem Pfad abzurufen. Wenn du die Beispiele selbst durchgehst, achte bitte darauf, die Bilddateien korrekt in den Assets-Ordner zu platzieren. Andernfalls könnten sie nicht angezeigt werden oder Fehlermeldungen auftreten. Die Bilder befinden sich im Unterordner assets/images, während die Profilbilder in assets/images/cards zu finden sind. Der Service wird durch die Methode addFriend() aufgerufen, um die entsprechenden Informationen zu den Arrays hinzuzufügen. Ich habe mir überlegt, dass der Service einen Code im Stil eines HTTP-Statuscodes zurückgibt. Vor dem Hinzufügen überprüft er die Array-Eigenschaft length, um festzustellen, ob die maximale Anzahl erreicht wurde. In diesem Fall wird der Fehlercode 400 zurückgegeben. Andernfalls werden die Profilinformationen hinzugefügt, und der Code 200 wird zurückgegeben.


Angular-Spezialitäten

In diesem Beitrag hast du bisher Komponenten als statischen HTML-Code erzeugt. Doch eine der Stärken von Angular liegt darin, Komponenten dynamisch zu erzeugen. Das bedeutet, dass du Komponenten bspw. basierend auf bestimmten Bedingungen anzeigen lassen kannst. Zwei besonders nützliche Funktionen, auf die ich eingehen werde, sind ngIf und ngFor.

Bedingte Anzeige mit „ngIf“

Das Kürzel „ngIf“ steht im Wesentlichen für „Angular-If“ und ermöglicht es, eine Komponente nur dann darzustellen, wenn eine bestimmte Bedingung erfüllt ist. Diese Bedingung kann eine einfache boolesche Variable sein, die entweder true oder false ist. Alternativ kannst du auch eine komplexere Bedingung verwenden. Hierbei können die Variablen auf Eigenschaften zugreifen, auf die die Komponente direkt zugreift. Lass uns jetzt ngIf in der ProfileRowComponent verwenden. Dafür benötigen wir eine kleine Erweiterung im Code. Bitte passe die Datei profile-row.component.ts wie folgt an:
import { AddFriendService } from "../add-friend.service";
import { Component, Input, OnInit } from "@angular/core";

@Component ({
        selector: "app-profile-row",
        templateUrl: "./profile-row.component.html",
        styleUrls: ["./profile-row.component.scss"]
})
export class ProfileRowComponent implements OnInit {
        @Input()
        id: number = 0;

        @Input()
        name: string = "";

        @Input()
        description: string = "";

        @Input()
        canFollow: boolean = true;

        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");
        }

        constructor(public friendService: AddFriendService) {}

        ngOnInit(): void {}

        with(object: any): ProfileRowComponent {
                let profile = object as ProfileRowComponent;
                this.id = profile.id;
                this.name = profile.name;
                this.description = profile.description;
                this.canFollow = profile.canFollow;
                return this;
        }
}
Das Flag canFollow dient hierbei als Bedingung. Der Dekorator @Input() ermöglicht es, auf diese Variablen im HTML-Code deiner Angular-Anwendung ähnlich wie auf Attribute zuzugreifen. Die Methode addFriend() führt den Dienst AddFriendService aus und verarbeitet das Ergebnis. Und jetzt kannst du auch die zugehörige HTML-Datei profile-row.component.html anpassen:
<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>

Das angezeigte Bild in dieser Komponente hängt von der jeweiligen ID ab. Die Stringwerte der Attribute, in diesem Fall src, enthalten die Variable id in doppelten, geschweiften Klammern. Dasselbe Prinzip gilt auch für den Profilnamen und die Beschreibung weiter unten. Jetzt kommt die eigentliche Besonderheit: Mit *ngIf gibst du die Bedingung in einem String an, in unserem Fall "canFollow". Das sagt Angular: „Zeige diese Komponente nur an, wenn die Bedingung erfüllt ist“. Zusätzlich verfügt Angular über eigene Ereignisse, auf die es reagiert. In diesem Fall wird die Methode addFriend() aufgerufen, wenn du auf „Folgen“ klickst. Während man in normalem HTML onclick verwenden würde, verwendet Angular das Ereignis (click).


Dynamisches Generieren mit „ngFor“

Die zweite Besonderheit in Angular, die ich hier hervorheben möchte, ist die Fähigkeit, dynamisch mehrere Komponenten zu erzeugen, ohne die genaue Anzahl im Voraus zu kennen. Dies ähnelt einer Schleife und wird durch *ngFor ermöglicht, was für „Angular-For“ steht. Lass uns auch hier den Code entsprechend anpassen. Zuerst die Datei proposals.component.ts:

import { Component, OnInit } from "@angular/core";

@Component({
        selector: "app-proposals",
        templateUrl: "./proposals.component.html",
        styleUrls: ["./proposals.component.scss"]
})
export class ProposalsComponent implements OnInit {
        names: string[] = [
                "First User",
                "Second User",
                "Third User",
                "Fourth User",
                "Fifth User",
                "Sixth User"
        ];

        texts = [
                "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."
        ];

        images: number[] = [0, 1, 2, 3, 4, 5];

        constructor() {}

        ngOnInit() {}

        get(index: number) {
                return {
                        name: this.names[index],
                        text: this.texts[index],
                        id: this.images[index]
                };
        }
}
Die Benutzernamen und Beschreibungen haben hier keine tiefere Bedeutung. Du kannst hier auch eigene, beliebige Bezeichnungen einsetzen. Als Nächstes die zugehörige HTML-Datei profile-row.component.html:
<app-profile-row [canFollow]="false" [id]="0" [name]="names[0]" [description]="texts[0]"/>
<div class="margin-bottom-15">
        <b>Freundesvorschl&auml;ge</b>
</div>
<app-profile-row
        *ngFor="let i of [1, 2, 3, 4, 5]"
        [id]="i"
        [name]="names[i]"
        [description]="texts[i]">
</app-profile-row>
Eine weitere bemerkenswerte Eigenschaft zeigt sich in der ersten Zeile des Codes. Hier wird deutlich, dass du auch direkt im HTML-Code die Eigenschaften der Komponentenklasse festlegen kannst. In diesem Fall wird das erste Profil mit „deinem“ Profil gefüllt und durch [canFollow]="false" wird sichergestellt, dass du dir selbst nicht folgen kannst. Die Attribute, die du ändern möchtest, werden dabei in eckige Klammern gesetzt. Und dort, wo die Komponente ProfileRow mit dem Tag <app-profile-row> eingebettet wird, kommt jetzt *ngFor zum Einsatz. Im String "let i of [1, 2, 3, 4, 5]" steht genau das Gleiche drin, was du beispielsweise auch in TypeScript schreiben würdest, in der Form for(let i of [1, 2, 3, 4, 5]).

Implementierung der restlichen Komponenten

Wenn du bis hierhin aktiv mitgemacht hast, lade dir gerne den restlichen Teil des Repositorys herunter, da dieselben Prinzipien auch bei den restlichen Komponenten angewandt werden. Darin sind nämlich noch die angepassten SCSS-Dateien enthalten, um der Anwendung ein ansprechendes Design zu verleihen. Außerdem befinden sich in der Anwendung zwei Icons aus dem Material-Design. Dazu installiere bitte folgendes Package:
C:\Users\Robert\angular-demo-application>ng add @angular/material
ℹ Using package manager: npm
✔ Found compatible package version: @angular/material@16.2.1.
✔ Package information loaded.

The package @angular/material@16.2.1 will be installed and executed.
Would you like to proceed? Yes
✔ Packages successfully installed.
? Choose a prebuilt theme name, or "custom" for a custom theme: (Use arrow keys)
> Indigo/Pink [ Preview: https://material.angular.io?theme=indigo-pink ]
  Deep Purple/Amber [ Preview: https://material.angular.io?theme=deeppurple-amber ]
  Pink/Blue Grey [ Preview: https://material.angular.io?theme=pink-bluegrey ]
  Purple/Green [ Preview: https://material.angular.io?theme=purple-green ]
? Set up global Angular Material typography styles? No
? Include the Angular animations module? Do not include
UPDATE package.json (1121 bytes)
✔ Packages installed successfully.
UPDATE angular.json (3098 bytes)
UPDATE src/index.html (604 bytes)
UPDATE src/styles.scss (510 bytes)
Außerdem ist es notwendig, dass du die Datei app.module.ts wie folgt anpasst:
import { AppComponent } from "./app.component";
import { AppRoutingModule } from "./app-routing.module";
import { BrowserModule } from "@angular/platform-browser";
import { CardComponent } from "./card/card.component";
import { FriendBoxComponent } from "./friend-box/friend-box.component";
import { HeaderComponent } from "./header/header.component";
import { MatIconModule } from "@angular/material/icon";
import { NgModule } from "@angular/core";
import { ProfileRowComponent } from "./profile-row/profile-row.component";
import { ProposalsComponent } from "./proposals/proposals.component";

@NgModule({
        declarations: [
                AppComponent,
                CardComponent,
                FriendBoxComponent,
                HeaderComponent,
                ProfileRowComponent,
                ProposalsComponent
        ],
        imports: [
                AppRoutingModule,
                BrowserModule,
                MatIconModule
        ],
        providers: [],
        bootstrap: [AppComponent]
})
export class AppModule { }

Wenn du das Repository erfolgreich geklont oder heruntergeladen hast und alle notwendigen Bibliotheken installiert sind, sollte die Anwendung jetzt wie folgt aussehen (Klicken zum Vergrößern):


Testen der Komponenten

Nun, da du die grundlegende Mini-Anwendung 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) ein.

Quellenangaben