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


 

Angular Applikationen auf Ebene von Unit Tests und Integrationstests durchzutesten, ist eine relativ bequeme Geschichte. Gerüchten zu Folge wurde Angular bei Google von einem Test Team entwickelt und so ist die Testbarkeit der Applikation sozusagen direkt in der DNA der Architektur integriert.
Aber wie sieht es mit den E2E Tests aus? Wie werden Cross-Browser Tests im Rahmen einer Angular Applikation realisiert? Dieses Tutorial wird diese Punkte beleuchten.

Einrichtung der Umgebung

Als erstes sollten wir uns die Entwicklungsumgebung einrichten, Angular installieren, uns ein neues Angular Projekt erstellen und uns ansehen, welche Möglichkeiten zur Qualitätssicherung Angular von Haus aus mitbringt und wie speziell die E2E UI Testskripte aufgebaut sind.

Entwicklungsumgebung

Am sinnvollsten ist es, den Code der Applikation in einer IDE (Integrated Development Environment) zu öffnen. Eine IDE ist zwar nicht zwingend notwendig, um mit Angular oder automatisierten Tests zu arbeiten, bietet aber einige Vorteile im Vergleich zu einem simplen Editor (wie Notepad++), z.B. integrierte Git Anbindung, Organisation des Codes und der dazugehörigen Pakete, integrierte Autovervollständigung (Intellisense), integrierter Terminal (Batch / Powershell) und vieles mehr.  Solltest du mit Windows, Mac oder Linux arbeiten, eignet sich die Open Source Entwicklungsumgebung Visual Studio Code gut für diese Aufgabe, aber auch mit der kostenlosen Version von Visual Studio Community für Windows oder Mac, macht man nicht viel falsch. Ich verwende für dieses Tutorial Visual Studio Code.

Um Visual Studio Code zu installieren, lade es einfach  von der offiziellen Webseite runter, installiere und starte die Applikation: https://code.visualstudio.com/

Füge anschließend den Ordner „C:\tutorial\“ im Explorer zu dem Arbeitsbereich hinzu.

Angular Projekt in Visual Studio Code

Schon kannst du den Inhalt von dem Ordner und seine demnächst folgenden Source Dateien einsehen und bearbeiten. Ein weiterer Vorteil ist auch das integrierte Terminal unten, so können wir dort anschließend bequem Pakete installieren oder Tests starten:

  Tipp: Sollte Terminal nicht sichtbar sein, einfach „STRG+ö“ drücken bzw über das Menü View>Terminal auswählen.

Installation von Angular auf dem Rechner

Solltest du davor Angular noch nie auf deinem Rechner installiert haben, benötigst du zwei Sachen:

1.Intalliere Nodejs mit dem dazugehörigen NPM (Node Package Manager). Diesen findest du auf der offiziellen Webseite von NodeJS: https://nodejs.org/en/download/ – wichtig bei NodeJS ist natürlich die Installation vom npm package manager und das „Add to Path“, damit der npm Befehl aus dem Terminal bzw. der Konsole funktioniert

Node.JS Setup
Nodejs Installation

2.Installiere anschließend über NPM die Angular Client Applikation global auf deinem Rechner. Dazu kannst du nach der NodeJs Installation einfach folgende Anweisung in deiner Console bzw. in dem Visual Studio Code Terminal ausführen:

 npm install -g @angular/cli 

Erstellung des ersten Angular Projektes

Nach der Installation von Angular können wir unser erstes kleines Projekt erstellen. Dazu einfach den „ng new <projekt-name>“ aufrufen. Dieses habe ich bei mir project2 genannt.

 ng new project2 

Mit dem Projekt wird ein neuer Ordner mit der kompletten Angular Applikationsstruktur angelegt. Nach der Anlage ist das Angular Projekt sofort lauffähig. In der Ordnerstruktur wird dir vielleicht direkt unter dem Hauptverzeichnis bereits ein Ordner e2e Tests auffallen. Die Testdateien werden bei der Neuanlage eines Angular Projektes immer mit angelegt und enthalten bereits vorkonfigurierte Karma Unit Tests sowie fertig vorkonfigurierte Protractor Tests. Bequemer kann man Testautomatisierung einem Entwickler nicht näher bringen! 🙂

Du kannst auch sofort nach der Anlage aus dem Root Verzeichnis den Befehl „ng e2e“ bzw „npm run e2e“ ausführen.

 ng e2e 

Das Ergebnis sieht bei einem Erstprojekt etwa so aus:

Angular E2E Tests starten

Wie du schon mitbekommen hast, wurde auf dem Rechner die Angular Applikation gebaut, der Chrome Browser gestartet und die Applikation aufgerufen, außerdem wurde ein erster Check auf einen Titel durchgeführt. Der Aufruf ng e2e ist wirklich sehr bequem, da es uns eine Menge Arbeit abnimmt. Würden wir die gleiche Kette „manuell“ machen wollen, müssten wir folgende Aktionen unternehmen:

  1. Angular Applikation bauen und starten mit dem Befehl ng serve in einer eigenen Konsole
  2. Start vom Webdriver Manager mit webdriver-manager start in einer eigenen Konsole (evtl. sollte davor noch webdriver-manager update durchgeführt werden)
  3. Start der Testfälle mit dem Befehlt protractor e2e/protractor.conf.js in einer weiteren Konsole

Was die einzelnen Befehle bedeuten und welche weiteren Möglichkeiten existieren, Testfälle auszuführen, schauen wir uns etwas weiter unten an.

Struktur und Aufbau der E2E Tests in Angular

Der erste UI Test war ja schon erfolgreich, obwohl wir dazu auch noch überhaupt nichts beigetragen haben. Aus diesem Grund schauen wir uns jetzt die einzelnen Komponenten der E2E Testfälle an und wie diese in den Testskripten realisiert werden.

Als erstes schauen wir uns die beteiligten Komponenten an. Das Standard Testframework in Angular ist Jasmine. Aus diesem werden die Testfälle über das Testautomation Tool Protractor mit einem Selenium Webdriver im Hintergrund durchgeführt.

Jasmine

Jasmine ist ein sogenanntes Behavior-Driven Development Testing Framework, dass viele hilfreiche Funktionalitäten bereitstellt, die im Rahmen der Testdurchführung auf unterschiedlichen Schichten (Unit Tests / Integrationstests und auch E2E UI Tests) immer wieder benötigt werden, z.B. zur Verifizierung von Soll / Ist Ergebnissen.

Behavior Driven bedeutet, dass die Test Suiten und Test Cases so spezifiziert werden, dass sie das fachliche Soll-Verhalten der Applikation beschreiben, wodurch es auch für außenstehende einfacher ist, die Testinhalte und Ziele zu verstehen.

Der Aufbau einer typischen Jasmine „Test Suite“ sieht eigentlich immer so aus:

describe("A suite", function() {
it("contains spec with an expectation", function() {
expect(true).toBe(true);
});
});

Describe entspricht einer TestSuite und kann in sich mehrere it’s enthalten.
Ein it entspricht einem Testfall welches in sich eine Logik enthält, die z.B. prüft ob etwas dem erwarteten Wert entspricht
Die Struktur ist etwas gewöhnungsbedürftig, lässt sich aber nach einer Zeit tatsächlich ganz gut lesen.

Protractor

Protractor ist die offizielle UI Bibliothek für Angular UI Testfälle. Protractor ist eigentlich nichts anderes, als ein Wrapper für den Selenium Webdriver. Im Endeffekt benutzt man also Selenium zur Browserautomatisierung, mit dem Vorteil, die für Angular spezifischen Methoden von Protractor als Wrapperfunktionen nutzen zu können.

E2E Ordnerstruktur

Wenn wir uns den erstellten Ordner ansehen, finden wir darin folgende Dateien

C:\tutorial\project2\e2e\protractor.conf.js
C:\tutorial\project2\e2e\tsconfig.e2e.json
C:\tutorial\project2\e2e\src\app.e2e-spec.ts
C:\tutorial\project2\e2e\src\app.po.ts

Die tsconfig.e2e.json werden wir an dieser Stelle vorerst auslassen, da diese lediglich für die TypeScript Konfiguration zuständig ist (eine kurze Anleitung dazu kommt demnächst). Die anderen Dateien sollten wir uns aber detaillierter anschauen:

Testkonfigurationsdatei protractor.conf.js

Die Datei protractor.conf.js ist der Kern der E2E Automatisierung. Diese ist für die Konfiguration der Protractor Ausführung verantwortlich und definiert u.a. welche Testspezifikationen ausgeführt werden, z.B.:

specs: [
  './src/**/*.e2e-spec.ts'
],

Mit welchen Capabilities (Art des Browsers, Einstellungen des Browsers) z.B.:

capabilities: {
'browserName': 'chrome'
},

welche Jasmine Optionen gelten, welcher Reporter verwendet wird, welche Timeouts die Skripte enthalten und vieles vieles mehr. Eine genaue Auflistung der unterschiedlichen Möglichkeiten findest du unter: https://github.com/angular/protractor/blob/master/docs/api-overview.md#config-file

Diese Datei werden wir dann noch in weiteren Szenarien anpassen und erweitern.

Jasmine Testfallspezifikationsdatei src/app.e2e-spec.ts

Die Datei enthält die Jasmine Spezifikation der „Testsuiten“ und „Testfälle“. Der Verweis auf app.po ist interessant, weil dort die sogenannte Page Object Implementierung hinterlegt ist. Mehr dazu im nächsten Abschnitt

import { AppPage } from './app.po'; //Import von Page Object AppPage aus der datei src/app.po.ts
 
describe('workspace-project App', () => { // neue TestSuite
  let page: AppPage; //globale AppPage Variable, verfügbar für alle Its
 
  beforeEach(() => { //Jasmine "Init" Klasse, diese wird jedes mal ausgeführt, bevor ein 'it' ausgeführt wird
    page = new AppPage(); //Erstellung neuer Instanz der der Page Object Klasse AppPage
  });
 
  it('should display welcome message', async () => { //neuer Testfall
    await page.navigateTo(); //Aufruf einer Page Object Funktion, mit der zu der Startseite navigiert wird
    expect(await page.getParagraphText()).toEqual('Welcome to testapp!'); //überprüfung ob der Paragraph Text dem erwarteten Wert "Welcome to testapp!" entspricht
  });
});

Page Object Implementierung src/app.po.ts

Die Datei stellt das Page Object bzw. „funktionale Abstraktion“ der tatsächlichen Protractor Zugriffe auf die Elemente einer Webseite dar. In dieser werden also die einzelnen Elemente gesucht und über Protractorfunktionalitäten angesteuert.

import { browser, by, element } from 'protractor'; // importiert benötigte Funktionalitäten aus dem Protractor Modul

export class AppPage { // exportiert die Klasse, die von anderen Klassen konsumiert und genutzt werden kann
async navigateTo() { //Navigationsmethode
await browser.get('/'); //Ruft über protractor die "baseadress" Adresse auf, die in protractor.conf.js hinterlegt ist
}

async getParagraphText() {
await element(by.css('app-root h1')).getText(); //sucht mit Hilfe von css Identifier ein Element mit dem Tag h1 und gibt den Text von diesem Element zurück.
}
}

Aufbau des Angular Tutorial Projektes

Nachdem wir den grundsätzlich Aufbau eines E2E Tests angeschaut haben, möchten wir eine etwas komplexere Anwendung nehmen, mit der wir anschließend unsere Testfälle entwickeln und anschließend mit unterschiedlichen Konfigurationen testen werden. Dazu nehmen wir einfach das auf der Angular Hauptseite https://angular.io/tutorial/toh-pt0 aufgeführte Beispiel. Da es bei diesem Tutorial nicht um den Aufbau einer Angular Anwendung geht, laden wir einfach die fertige Angular Applikation von der Tutorialseite runter:

Links zu dem finalen Review findest du hier: https://angular.io/tutorial/toh-pt6#final-code-review
Und die Zip Datei kann hier bezogen werden: https://angular.io/generated/zips/toh-pt6/toh-pt6.zip

Solltest du die komplette Anwendung selbst aufbauen möchten, folge einfach dem sehr gut beschriebenen Tutorial auf der angular.io Webseite.

Ich habe für dieses Tutorial ein neues Verzeichnis auf C: mit dem Namen „tutorial“ angelegt, dorthin die zip Datei runtergeladen und anschließend die zip Datei in einen weiteren Unterordner mit der Bezeichnung „angular_heroes“ entpackt.

Anschließend wechseln wir in das Anwendungsverzeichnis und führen npm install aus, um die benötigten Pakete für unsere Angular Anwendung zu installieren:

 cd C:\tutorial\angular_heroes
npm install

Damit sollten sich die erforderlichen Pakete installieren lassen.  Am Ende müsste in etwa folgende  Meldung erscheinen:

 
added 1113 packages from 1279 contributors and audited 34260 packages in 70.961s
found 14 vulnerabilities (9 low, 5 high)
run `npm audit fix` to fix them, or `npm audit` for details
 

Tipp: Sollte npm install mal nicht beim ersten mal erfolgreich durchlaufen, einfach noch einmal probieren. Ab und zu gibt es da Probleme beim Download von phantomjs oder anderen Paketen.

Nun müssten wir die Applikation starten können, dazu einfach in den angular_heroes Ordner gehen und mit ng-serve starten:

ng serve --open

Mit dem –open Parameter wird die Applikation nach Start in dem Standard Browser geöffnet.

Nach diesem Befehl sollte sich dein Browser mit der URL http://localhost:4200/dashboard öffnen und du müsstest das im Angular Tutorial gezeigte „Dashboard“ sehen. Mach Dich ruhig ein paar Minuten mit der Applikation vertraut, bevor wir mit der automatisierten Qualitätssicherung dieser Applikation loslegen.

Wichtig: Da die Angular Leute ja bekannt für ihre Begeisterung für testbare Anwendungen / Testautomatisierung sind, haben sie natürlich schon etliche Testfälle für das Tutorial Projekt hinzugefügt. Du kannst dir natürlich gerne die E2E Testfälle der Entwickler anschauen und diese auch gerne mit npm run e2e ausprobieren. Ich würde allerdings kurz bitten den Ordner „src“ zu löschen bzw. außerhalb der Applikation zu verschieben und einen neuen, leeren Ordner src anzulegen, damit wir die Testfälle gleich komplett neu in der Page Objects Struktur aufbauen können.

Aufbau der UI Testautomatisierung für das Angular Projekt

Da wir nun ein funktionierendes „System Under Test“ haben, können wir uns überlegen, welche Testfälle dafür relevant sind. Am besten geht das natürlich direkt in der Jasmine BDD Notation. Daher starten wir hier einfach mit dem „Verhalten“ in dem Spec File.

Erstellung der Jasmine Spec File(s)

Wir legen uns in dem Ordner src eine Datei an. Im Visual Studio Code klicke einfach rechts auf den Ordner im Explorer und wähle „New File“ aus.

Wir betrachten einfach die beiden Views der Appliaktion und unterteilen sie in „Verhalten / Eigenschaften, die wir Testen möchten.

Beim Dashboard könnten wir z.B. prüfen ob:

  • der Titel „Tour of Heroes“ ist
  • die 4 Top Heroes angezeigt werden
  • die Navigation verfügbar ist und aus Dashboard und Heroes besteht

Bei der Hero View könnten wir prüfen ob:

  • Die Liste der Helden existiert
  • wir einen neuen Helden anlegen können
  • wir einen Helden löschen können

Die dazugehörige Jasmine Struktur könnte daher so aussehen:

describe('Heroes Dashboard', ()=> {
    it(' should have "Tour of Heroes" as title',()=> {
 
    });
    it(' should contain 4 Top Heroes',()=> {
 
    });
 
    it(' should have a navigation',()=> {
 
    });
 
});
 
describe('Heroes ', ()=&amp;gt;{
    it(' view contains a list of heroes',()=> {
 
    });
 
    it(' creation is possible',()=> {
 
    });
 
    it(' deletion is possible',()=> {
 
    });
});

Wenn wir jetzt npm run e2e ausführen, sollte die Ausgabe so aussehen:

Jasmine started
Heroes Dashboard
√  should have "Tour of Heroes" as title
√  should contain 4 Top Heroes
√  should have a navigation

Heroes
√  view contains a list of heroes
√  creation is possible
√  deletion is possible
Executed 6 of 6 specs SUCCESS in 0.023 sec.

Die Testfälle machen an der Stelle natürlich überhaupt nichts. Aber wir sehen schon mal, dass Jasmine mit unserer Struktur arbeiten und die „it“ Anweisungsblöcke problemlos aufrufen kann.

Erstellung der dazugehörigen PageObjects

Die Page Objects Dateien sollten Funktionen und Hilfsfunktionen der Businesslogik einer View abbilden. Deshalb legen wir uns nun eine weitere Datei in das Verzeichnis e2e/src an, die wir  dashboard.po.ts nennen. Ich werde die verwendeten Funktionen mit den dazugehörigen Links zu der Protractor Hilfe versehen, damit du dir gleich die offizielle Beschreibung ansehen kannst inkl. der Rückgabewerte der Funktionalität und dir natürlich auch angewöhnst, bei neuen Funktionen die zugehörige Hilfe zu verwenden.

Page Objects Datei dashboard.po.ts

Um das Verhalten der View testen zu können, könnten wir z.B. in der dazugehörigen Page Object Datei folgende Funktionen anlegen:

  1. Zu der View navigieren
  2. Liste mit Top Helden zurückgeben
  3. Title auf dem Dashboard zurückgeben
  4. Die Namen der Navigationselemente als Array zurückgeben

Rückgabe vom Titel und die Navigation zur Seite ist an sich relativ einfach, wir könnten uns eigentlich komplett den beiden Funktionen navigateTo und getTitle aus dem neuen e2e Projekt bedienen.
Die navigateTo Methode ruft lediglich browser.get(‚/‘) auf, womit der Browser angewiesen wird, die baseAddress (aus protractor.conf.js) aufzurufen.
Die getTitle Methode sieht schon etwas komplizierter aus: dort wird mit element(by.css(‚app-root h1‘)) ein Element gesucht und mit getText() anschließend der innerText von diesem Element zurückgegeben.

Auch die beiden anderen Funktionen sehen ähnlich aus, nur verwenden wir dafür dann die Suche nach mehreren Elementen mit dem Befehl element.all. Dieser Befehl liefert uns eine Art Array zurück (zur Synchronisierungt kommen wir gleich), welches 0 bis n Elemente enthalten kann, die mit den Kriterien gefunden bzw nicht gefunden wurden. Bei der Liste mit den Navigationsnamen nutzen wir die gleiche element.all Funktion, nur bekommt diese am Ende noch die Map Funktion, die eben nur die Texte aus den Daten extrahiert und uns den dazugehörigen „Texte“ zurück gibt.


import { browser, by, element, ElementFinder } from 'protractor';
  
export class DashboardPage {
 async navigateTo() {
    await browser.get('/');
  }

 async getTopHeroes(){
    return await element.all(by.css('app-root app-dashboard > div h4'));
  }
  
  async getDashboardTitle() {
    return await element(by.css('app-root h1')).getText();
  }

  async getNavigationItemNames() {
    return await element.all(by.css('app-root nav a')).map((el: ElementFinder) =><strong>;</strong> el.getText()); 
  }
}

Page Objects Datei heroes.po.ts

Um die Funktionen der Heroes Sicht abdecken zu können, würden sich folgende Methoden anbieten:

  • Liste von den Heroes zurückgeben
  • Einen neuen Hero anlegen
  • Einen Hero löschen
import { browser, by, element, ElementFinder } from 'protractor'; 
  
export class HeroesPage { 
  async navigateTo() { 
    await browser.get('/heroes'); 
  }
 
  async getHeroList(){
    return await element.all(by.xpath('//app-heroes/ul'));
  }
  
  async addHero(name:string) { 
    await element(by.xpath('//app-heroes//input')).sendKeys(name);
    await element(by.xpath('//button[text()=" add "]')).click();
    return await element(by.xpath('//app-heroes/ul//*[contains(text(),"'+name+'")]'));
  }
 
  async deleteHero(name:string) { 
   await element(by.xpath('//app-heroes/ul//*[contains(text(),"'+name+'")]/../button[@title="delete hero"]'));
    return await element.all(by.xpath('//app-heroes/ul//*[contains(text(),"'+name+'")]'));
  }
}

Implementierung der automatisierten E2E Testfälle

Nachdem die Funktionalität implementiert ist, wollen wir diese aus unseren Jasmine Spec Testfällen aufzurufen. Aber erst mal wird es etwas theoretischer:

Asynchronität in der Testautomatisierung mit JavaScript / Protractor

Solltest du bisher mit Selenium in Java oder C# gearbeitet haben, sollte dir der Code sehr bekannt vorkommen. Der vertraute Eindruck täuscht an dieser Stelle aber. Wir arbeiten mit Javascript / Typescript und natürlich auch mit Protractor asynchron. Wie die Hilfe von element(by.css(‚app-root h1‘)) schon anmerkt, gibt die Suche einen sogenannten „ElementFinder“ und nicht ein WebElement wie in C# / Java zurück. Dieses kann zwar für die meisten Funktionen wie ein WebElement verwendet werden, enthält aber auch die Besonderheit, dass die Funktionen eben nicht synchron ausgeführt werden, sondern erst dann, wenn der ElementFinder das dazugehörige Element auch gefunden hat. Wenn du dir auch erneut z.B. getText() als Funktion anschaust, wirst du feststellen, dass diese keinen String Wert zurückliefert, sondern einen Promise, der „verspricht“ irgendwann einen String Wert zu liefern. In diesem Fall heißt es, dass wenn du z.B. den Wert von getText() in die Konsole ausgeben möchtest, dieser zwingend einen await bzw. .then zur Synchronisierung benötigt, z.B.:

await element(by.tagName('h1')).getText().then(function(wert){ console.log(wert); })

Das muss man im Hinterkopf behalten, funktioniert aber problemlos, wenn man etwas javascript / typescript Erfahrung mitbringt bzw. Interesse hat, sich in dieses Thema einzuarbeiten.

Spec File mit Aufrufen der Page Objects

Aber nun kommen wir zu der Spec Datei, diese würde nun am Ende so aussehen:

import { DashboardPage } from './dashboard.po';
import { HeroesPage } from './heroes.po';
 
 
describe('Heroes Dashboard', () => {
    let dashboardPage;
 
    beforeAll(() => {
        dashboardPage = new DashboardPage();
    });
    beforeEach(async () => {
        await dashboardPage.navigateTo();
    });
 
 
    it('should have "Tour of Heroes" as title', async () => {
        expect(await dashboardPage.getDashboardTitle()).toEqual("Tour of Heroes");
    });
 
    it('should contain 4 Top Heroes', async () => {
       await dashboardPage.getTopHeroes().then(function (arr) {
            expect (arr.length).toEqual(4);
        })
    });
 
    it('should have Dashboard and Heroes in Navigation List', async () => {
       await dashboardPage.getNavigationItemNames().then((arr) => {
            expect (arr.length).toEqual(2);
            expect (arr[0]).toEqual("Dashboard");
            expect (arr[1]).toEqual("Heroes");
        })
 
    });
 
});
 
describe('Heroes ', () => {
    let heroesPage;
 
    beforeAll(() => {
        heroesPage = new HeroesPage();
    });
 
    beforeEach(async() => {
        await heroesPage.navigateTo();
    });
 
 
    it('view contains a list of ten heroes', async () => {
       await heroesPage.getHeroList().then((arr) => {
            expect(arr.length).toEqual(10);
 
        })
    });
 
    it(' creation is possible', async () => {
        //neuen Helden anlegen
        let neuerHeld = heroesPage.addHero("SuperTester");
        //prüfen ob der Held neben einer neuen ID auch den richtigen Namen enthält
        expect(await neuerHeld.getText()).toContain("SuperTester");
 
        //Bonus: Asyonchronität, hier geben wir die Liste aller Helden aus:
        heroesPage.getHeroList().then((helden) =&amp;gt; {
            for (let held of helden) {
                held.getText().then((heldenname) =&amp;gt; {
                    console.log(heldenname);
                })
            }
        });
 
        //und hier die Demonstration der Asynchronität von Protractor:
        console.log("Dieser Text wird in der Console früher ausgegeben, als die Liste der Helden ;-)");
 
    });
 
    it(' deletion is possible', async () => {
        await heroesPage.deleteHero("SuperTester").then((arr) => {
            expect(arr.length).toEqual(0);
        })
 
    });
});

Damit sind unsere ersten automatisierten E2E Testfälle der Angular Applikation fertig.

Starte die Testdruchführung mit

 ng e2e 

und überprüfe die Testergebnisse.

Der Test-Code ist natürlich noch nicht perfekt und könnte noch weiter verfeinert werden. Er demonstriert aber ganz gut, wie die Testautomatisierung mit Protractor in einem Angular Umfeld grundsätzlich realisiert werden kann. Ich habe auch eine kleine Demo der Asynchronität in die Specs eingebaut, um zu verdeutlichen wie der „Executor“ vorgeht.

Zusammenfassung

In diesem Tutorial haben wir gelernt, wie man sehr schnell eine funktionierende state-of-the-art  E2E Testautomatisierungslösung einer Angular Applikation mit Hilfe der automatisch generierten Vorlage in einem Angular Projekt aufsetzen kann. In dieser Vorlage haben wir Jasmin Framework verwendet, um eine Testspezifikation mit Hilfe der behavior-driven Notation zu definieren. Anschliessend haben wir das selenium-basierte Testautomatisierungsframework Protractor verwendet, um die konkreten Aktion dieser Testspezifikation zu automatisieren. Am Schluss haben wir die integrierte Testlaufzeitumgebung von Angular verwendet, um bequem die Testdurchführung unserer Tstsuite mit einem einzigen Befehl zu starten.

Ich werde demnächst weitere Beiträge zu dem Thema Testautomatisierung im Angular Umfeld hinzufügen, u.A.

  • Durchführung von E2E Protractor Tests in der CI Pipeline
  • Durchführung der E2E Testfälle in der Cloud (z.B. mit BrowserStack)
  • Mocken von Rest API um isoliert Oberflächen testen zu können
  • und evtl. einiges mehr.

Schaue daher regelmäßig vorbei 😉


Hat dir dieses Tutorial gefallen? Fehlt noch etwas oder du kommst an einigen Stellen nicht weiter? Bitte teile uns dein Feedback in den Kommentaren mit.