Automatisiertes Testen einer Angular-Applikation
Je später ein Fehler in einer Software-Anwendung entdeckt wird, umso höher wird der Aufwand, diesen wieder zu beheben. Idealerweise ist automatisiertes Testen daher fester Bestandteil der Entwicklung. Getesteter Code erhöht die Qualität und auf lange Sicht die Entwicklungsgeschwindigkeit. Die gewünschte Funktionalität wird sichergestellt und eine Anwendung kann leicht um neue Features erweitert werden. Aufwändiges manuelles Testen wird im Idealfall auf ein Minimum reduziert und Hotfixes und die Notwendigkeit von manuellem Debugging vermieden. Somit steigt das Vertrauen des Endnutzers und schnellere Release-Zyklen werden möglich.
Um den Aufwand automatisierter Tests in einem angemessenen Rahmen zu halten, ist ein Verständnis für die Arten von Tests und deren Anwendungsfall notwendig. Technisches Wissen zum Schreiben von Tests für eine Angular-Anwendung erfordert zudem Verständnis für den richtigen Umgang mit dem TestBed-Helper für Dependency Injection und Erfahrung mit dem asynchronen Komponenten-Lifecycle.
Arten von Tests
Automatisierte Tests für eine Angular-Anwendung werden als Isolierte Unit-Tests, Shallow-DOM-Tests, DOM-Tests und Ende-zu-Ende-Tests klassifiziert. Die gängigen Begriffe Unit-Test und Integrationstest sind bei Angular-Komponenten fließend, denn ein DOM-Test kann als Unit-Test, aber auch als Integrationstest bezeichnet werden. Je isolierter ein Test, desto einfacher und schneller ist dieser zu schreiben. Die isolierte Testbarkeit von Code kann zudem helfen, eine saubere Architektur einzuhalten.
Isolierte Unit-Tests
Wenn Sie wenig Kapazität haben, automatisierte Tests zu schreiben, sollten Sie keinesfalls auf isolierte Unit-Tests verzichten. Der Code dafür ist wenig komplex und im Falle eines Fehlschlags beim Testlauf ist ein Bug in einer konkreten Datei lokalisierbar.
Isolierte Unit-Tests in einer Angular-Anwendung testen eine einzelne Klasse, eine Typescript-Datei. Also einen isolierten Service, eine Direktive, eine Pipe oder den Typescript-Teil einer Komponente. Isoliert bedeutet im Falle von Angular, dass bei einer Komponente deren Template ignoriert wird, zudem werden Child-Komponenten und jegliche Dependencies aus den Konstruktoren durch Test-Doubles ersetzt.
Auf jeden Fall getestet werden sollte der initiale Zustand der Komponenten- oder Service-Klasse, also der Wert jedes einzelnen Attributs beim Initialisieren. Zudem sollte jede logische Verzweigung einer public-Methode einen Test erhalten um die Auswirkung auf den Zustand nach jedem Methoden-Aufruf zu kennen. Eine große Angular-Anwendung wird schnell komplex. Der Zustand der einzelnen Komponenten und Services bildet das Fundament. Eine verlässliche Zustandsbeschreibung erlaubt ein nachvollziehbares Verhalten der Funktionalität der Gesamtanwendung. Idealerweise befindet sich in einer Angular-Komponente kein Code im Konstruktor oder der Lifecycle-Methode ngOnInit, der nicht isoliert testbar ist. Es bietet sich also an, Code darin in Methoden auszulagern, um deren Auswirkung sicherzustellen.
Nehmen wir als Beispiel einen LoginService, hier wäre das zu testende Zustandsattribut loggedIn und die zu testende public-Methode login, welche den Zustand beim Aufruf verändert.
export class LoginService {
loggedIn = false;
login() { this.loggedIn = true; }
}
Testaufbau
In Angular erstellt das Command Line Interface bereits Boilerplate, um Tests zu schreiben. Komponententests können wahlweise in Jasmine oder Jest programmiert werden. Für isolierte Unit-Tests ist es jedoch gar nicht notwendig, den zunächst komplex erscheinenden Helper TestBed zu verwenden. Ein einfacher Testaufbau mit Jasmine ist mit wenig Aufwand möglich. Eine Test-Suite mit Jasmine besteht aus einem describe-Block und einzelnen Testfällen in it-Blöcken. Helper wie beforeEach und afterEach erlauben den Testcode zu strukturieren und Duplikate zu vermeiden. Wichtig zu wissen ist, dass der Scope innerhalb eines Callbacks im beforeEach-Block der gleiche wie der innerhalb eines it-Blocks ist. Der Scope innerhalb des describe-Blocks ist jedoch ein anderer, weshalb Objekte im describe-Block immer nur deklariert, nicht jedoch zugewiesen werden dürfen.
Strukturierte Testfälle gliedern sich stets in das Pattern AAA, also arrange, act und assert. Zunächst wird eine Instanz des zu testenden Services oder Komponente erstellt, dann werden Methoden aufgerufen und danach wird der Zustand eines Attributs mit erwarteten Werten verglichen. Idealerweise ist jeder Testfall in sich semantisch lesbar und testet nur eine Funktionalität. Lesbarkeit ist bei Tests wichtiger als die Vermeidung von Code-Duplikation.
Zur Illustration der isolierte Unit-Test für den Login Service: Wir deklarieren die zu testende Unit im describe-Block, initialisieren diesen aber erst im beforeEach-Callback. Zu testen wäre der initiale Zustand, sowie die Änderung nach Aufruf der public-login-Methode.
describe('LoginService', () => {
let service: LoginService; // nur Deklaration
beforeEach(() => {
service = new LoginService(); // arrange
});
it('has the expected initial state', () => {
expect(service.loggedIn).toEqual(false);
});
it('changes loggedIn state on login call', () => {
service.login(); // act
expect(service.loggedIn).toEqual(true); // assert
});
});
Dependencies
Der oben beschriebene Test für den LoginService ist einfach, da der Service im Konstrukt keine dependencies benötigt. Normalerweise haben Services und Komponenten jedoch Abhängigkeiten im Konstruktor. Bei einem isolierten Unit-Test sind diese nicht von Interesse, somit werden diese durch möglichst einfache Test-Doubles ersetzt. Im einfachsten Fall ist es möglich, null als Ersatz zu verwenden. Dies ist jedoch nur möglich, wenn keine Aufrufe auf Attribute oder Methoden der dependency erfolgen. Alternativ ersetzt man die dependency durch einen Spy oder Mock und definiert im Austauschobjekt lediglich die Teile, die der Code des zu testenden Service oder Komponente verwendet.
Hat der LoginService beispielsweise eine Abhängigkeit auf einen AuthService und ruft dessen Methode getLoggedIn auf, so reicht dank dem Prototypen-Konzept in Javascript ein Austauschobjekt des Typs AuthService mit der verwendeten Methode. Alle weiteren Methoden oder Attribute des echten AuthService-Objekts sind nicht von Relevanz. Im Konstrukt des zu testenden Services wird dann einfach das Austauschobjekt eingesetzt.
const fake = { getLoggedIn: () => true };
masterService = new LoginService(fake as AuthService);
Jasmine bietet hier die Hilfsfunktion createSpyObj, mit der ein Spy als Austauschobjekt erzeugt wird, also nicht nur mögliche Rückgabewerte definiert werden können, sondern auch die Option besteht, Aufrufe auf den injizierten Service zu testen. Jasmine erlaubt calls für eine solche Spy-Objekt-Methode zu zählen und die Rückgabewerte zu überprüfen. So kann ein Test absichern, dass nicht versehentlich doppelt ein Service angesprochen wird, der zum Beispiel Daten von einem Backend-Server anfragt.
export class LoginService {
constructor(authService: AuthService) { }
getGlobalState() {
return this.authService.getLoggedIn();
}
}
(Test-Code)
// arrange
const authServiceSpy = createSpyObj('AuthService', { 'getLoggedIn': true
});
loginService = new LoginService(authServiceSpy);
// act
loginService.getGlobalState();
// assert
expect(authServiceSpy.getLoggedIn).toHaveBeenCalled();
expect(authServiceSpy.getLoggedIn.calls.count()).toEqual(1);
expect(authServiceSpy.getLoggedIn.calls.mostRecent().returnValue).toBe(true);
Achtung bei Wiederverwendung des Spies in mehreren Testfällen: entweder können die Aufrufe der Methode mit calls.reset() zurückgesetzt werden, oder der Spy wird erst im Testfall selbst (it-Block) oder im beforeEach-Block instanziiert.
Wird ein Spy auf eine eigene Methode des zu testenden Services oder Komponente erstellt, lassen sich Aufrufe von privaten Methoden überprüfen und der innere Code überspringen. Hierzu dient der Jasmine-Helper spyOn. Soll der innere Code dennoch ausgeführt werden, muss der Spy mit and.callThrough() versehen werden.
methodSpy = spyOn(loginService, 'getGlobalState').and.callThrough();
(Shallow) DOM-Tests
Komponenten in Angular bestehen jedoch nicht nur aus der Typescript-Datei für deren Funktionalität. Um den DOM im Browser zu erzeugen, muss deren HTML-Template mit evaluiert werden. Hierzu ist ein Verständnis von Dependency Injection in Angular essentiell, damit die zu testende Komponente kompiliert.
In Angular werden Komponenten in Modulen per NgModule bereitgestellt, welches alle möglicherweise zugehörigen Pipes, Direktiven, Services und Komponenten in einem Dependency-Pool bereitstellt. Damit eine Komponente in einem Test kompiliert werden kann, gibt es aus der Angular Core Testing Library den Helper TestBed, ein Pendant der gleichen Syntax wie NgModule.
Mit imports werden andere Module und Bibliotheken eingebunden, providers liefern Services und andere Abhängigkeiten, in declarations werden die zu testende Komponente und potentiell verschachtelte Kinderkomponenten angegeben. Shallow DOM-Tests ignorieren in der Regel alle Kinderkomponenten, sowie sämtliche Direktiven und Pipes. Es sind daher für einen solchen Test Stubs als Ersatz einzusetzen.
Von der Verwendung des Helpers NO_ERRORS_SCHEMA ist abzuraten. Dies scheint zwar wie die einfachste Methode, um Fehler beim Evaluieren unbekannter Keywords im Template zu unterdrücken, maskiert jedoch potentielle Fehler.
Die einfachste Methode, Kinderkomponenten zu ersetzen, ist, diese in der Testdatei neu zu deklarieren und das Template auf einen einfachen HTML-Knoten oder einen leeren String zu reduzieren. Beispielsweise eine Login-Komponente, deren Template den Tag der Kinderkomponente <banner></banner> enthält.
@Component({selector: 'banner', template: '<div id="banner"/>‘})
class BannerStubComponent {}
TestBed.configureTestingModule({
declarations: [
LoginComponent,
BannerStubComponent
]
});
Den Komponenten-DOM zu testen erfordert ein Verständnis für die Lifecycle und den Rendermechanismus von Angular. Ein Template enthält bindings, die den Zustand der Komponente widerspiegeln. Ebenso kann per Event Handler binding ein Klick oder ein Tippen des Nutzers im DOM den Zustand der Komponente und damit deren Attribute verändern. Two-way binding in Angular – wie es üblicherweise bei Formularen verwendet wird – führt dabei oft zu mehreren Feedback-Zyklen und diese müssen im Testcode abgewartet werden.
Das konfigurierte Testbed ermöglich mit compileComponents einen finalen Pool von Angular-Objekten. Mit TestBed.createComponent lässt sich eine Instanz der zu testenden Komponente erzeugen. Deren Typ ist jedoch nicht die Komponente selbst, sondern ein Wrapper, die fixture aus der Angular-Core-Testing-Bibliothek.
Die Komponente ist darin enthalten und per fixture.componentInstance zugänglich. Um den final evaluierten DOM einer AngularKomponente zu testen, muss die Lifecycle-Methode ngOnInit aufgerufen worden sein. Im Testcode wird dies beim ersten Aufruf mit Hilfe von fixture.detectChanges bewirkt. Erst wenn das Setup des Tests final ist, sollte die Methode bewusst verwendet werden. Es ist nicht ratsam, ngOnInit von Hand aufzurufen, eventueller Initialisierungscode im ngOnInit wird sonst doppelt aufgerufen, was zu Inkonsistenzen im State führen kann. Das Aufrufen von fixture.detectChanges führt zudem zur Überwachung eventueller Änderung im State des Komponentenzustands oder deren DOM-Repräsentation.
Es bietet sich an, in DOM-Tests zusätzlich zum isolierten Unit-Test einen Testfall zu schreiben, der den Zustand einer Komponente im Zusammenspiel mit deren evaluierter Templates überprüft. Somit wird die Methode ngOnInit und deren Auswirkung auf den Komponentenzustand indirekt getestet. Nach Aufruf von fixture.detectChanges kann man den evaluierten DOM per fixture.nativeElement überprüfen.
it('should have expected html content', () => {
component.title = 'Welcome';
fixture.detectChanges();
const bannerElement: HTMLElement = fixture.nativeElement;
const h1 = bannerElement.querySelector('h1');
expect(h1.textContent).toEqual('Welcome');
});
Helper wie .click() erlauben das Testen von Nutzerinteraktion mit HTML-Elementen. Möchte man jedoch komplexere DOM-Elemente wie etwa ein Formular mit 2-way-Binding testen, so ist zu berücksichtigen, dass ein Rerenderzyklus in Angular abgewartet werden muss. Ändert sich der Wert in einem input-Feld, so muss im Testcode manuell ein input-Event ausgelöst werden. Zudem muss per fixture.detectChanges die erneute Evaluation der Bindings initiiert und per tick sichergestellt werden, dass der geänderte DOM "stabil" ist. Wenn die DOM-Repräsentation der Komponente nun eine andere ist, muss zudem fixture.nativeElement erneut aufgerufen werden.
Hierbei helfen die Hilfsmethoden fakeAsync als Wrapper für den Callback im Testfall und tick:
<input [(ngModel)]="myName" name="Name" />
(Test-Code)
it('should display error message with missing required fields', fakeAsync(() => {
// arrange
fixture.detectChanges(); // update bindings
tick(); // stable
const nameInput = fixture.nativeElement
.querySelector('input[name="Name"]').nativeElement;
// act
nameInput.value = '';
nameInput.dispatchEvent(new Event('input'));
fixture.detectChanges(); // update bindings
tick(); // stable
// assert
let errorMessages = fixture.nativeElement.querySelectorAll('.t-error');
expect(errorMessages).not.toBeNull();
expect(errorMessages.length).toBe(1);
expect(errorMessages.item(0).id).toBe('errorNameInput');
});
Asynchrone Daten
Häufig bekommt eine Komponente beim ersten Aufruf asynchrone Daten in ihren Zustand, die meist im ngOnInit als Service-Anfrage mit dadurch ausgelöstem Backend-Request umgesetzt sind. Asynchrone Funktionsaufrufe mit Promise-Syntax oder asynchrone Zustandsattribute in Form von Observables lassen sich leicht im Test erzeugen. Hierbei hilft die Bibliothek RXJS mit der Hilfsmethode of, die ein beliebiges Objekt in eine asynchrone Observable wrappt. Der Testcode braucht hiermit nur noch einen Mock für den Service, der als Antwort auf die Methode für das gewünschte Datenobjekt eine Observable liefert, die "sofort" zurückgegeben wird, somit also im Test wie synchron funktioniert.
const currentUser = new UserProfile('id', 'name');
const userServiceMock = createSpyObj('UserService', {
'getCurrentUser': of(currentUser)
});
Ende-zu-Ende-Tests
Ende-zu-Ende-Tests sind die aufwändigste Form automatisierten Testens. Sie sollten spärlich eingesetzt werden. Nur, wenn eine Funktionalität nicht durch DOM-Tests abgedeckt werden kann, ist es anzuraten, einen solchen Test zu schreiben. Der Grund dafür ist die Anfälligkeit für Instabilitäten, der Wartungsaufwand, die Laufzeit, Komplexität des Testcodes und oftmals auch Kosten für Ressourcen.
Ist beispielsweise ein Webshop zu testen, so braucht man für einen realistischen Ende-zu-Ende-Test Kreditkarten um Testkäufe durchzuführen oder aber Sandbox-Accounts mit Testkarten, die die Schnittstellen für Zahlungsdienstleister ersetzen. In jedem Fall sind Test-Accounts aufzusetzen und eine Umgebung mit möglichst produktionsnahen Kopien aller Ressourcen (Backend, Frontend, Datenbank) bereitzustellen. Ein Test soll reproduzierbar wiederholt ausgeführt werden können, falls während des Testlaufs also neue Accounts oder Bestellungen erzeugt werden, braucht es ein Konzept, diese hinterher zu stornieren und wieder aus dem System zu entfernen.
Es bietet sich an, das Zusammenspiel verschiedener Backendsysteme, der Datenbank und möglicher Frontend-Applikationen Ende zu Ende zu testen. Dabei können Konfigurations-Probleme vermieden werden, die Kommunikation zwischen den einzelnen Schnittstellen wird somit gewährleistet. Eine Standardregel ist es zudem, nur die erfolgreichen Testfälle in einem Ende-zu-Ende-Szenario abzudecken und eventuelle Fehler in anderen Testarten zu überprüfen.
Ende-zu-Ende-Tests laufen automatisiert im Browser, sie testen somit indirekt auch die UI. Für Angular gibt es hier Frameworks wie Protractor oder Cypress, für die Boilerplate generiert werden kann. Protractor ist ein Wrapper für Selenium mit Hilfsmethoden, die den Zustand einer Angular-Anwendung berücksichtigen, also etwa sicherstellen können, ob alle Lifecycle-Zyklen final durchlaufen sind und der evaluierte DOM stabil.
Es bietet sich an, für bessere Lesbarkeit des eigentlichen Tests, der eine Story beschreiben sollte, den Code in Page und Test zu trennen. Somit befinden sich Selektoren für DOM-Elemente und Helper zum Aufruf und Interaktion mit einer Seite in der Page-Datei. Ändert sich der Produktivcode, etwa DOM-Elemente und deren Identifier, ist im Idealfall nur die Page-Datei anzupassen und der eigentliche Testfall, der die Funktionalität beschreibt, kann bestehen bleiben.
Generierter Boilerplate für Protractor-Code verwendet den Selenium-Promise-Manager. Er erlaubt somit das scheinbar synchrone Schreiben von Testcode. In Protractor-Tests ist vieles asynchron: das Aufrufen von Routen und URLs, das Auffinden von Elementen auf Seiten, sowieso Nutzerinteraktionen wie etwa ein Klick auf einen Button. Dieser sogenannte "Control Flow" sollte jedoch per Konfiguration deaktiviert werden und stattdessen Testcode explizit mit async / await programmiert werden.
// protractor.conf.js
exports.config = {
SELENIUM_PROMISE_MANAGER: false,
(Test-Code)
// page object
async navigateTo() {
return await browser.get(browser.baseUrl);
}
// test
it('shows welcome message after redirect', async () => {
await page.navigateTo();
expect(await page.getTitleText()).toEqual('Welcome');
})
Ein häufiges Problem der langen Laufzeit von Ende-zu-Ende-Tests besteht darin, dass auf Redirects oder andere asynchrone Events gewartet werden muss. Beispielsweise löst der Klick auf einen Button einen langläufigen Backendrequest aus, da Daten aus eine Datenbank geholt oder geschrieben werden müssen. Oder der Button wird erst enabled, wenn ein vorheriges Event abgeschlossen ist. Programmatisch mehrere Sekunden zu warten ist unsauber und kann bei langsamer Performance dennoch fehlschlagen. Mit Hilfe des Protractor Helpers ExpectedConditions ist es möglich, nur so lange zu warten wie notwendig und dennoch ein Toleranz-Timeout zu konfigurieren.
const button = element(by.id('loginSubmit'));
const isClickable = condition.elementToBeClickable(button);
await browser.wait(isClickable, 5000);
await button.click();