Protractor – Automatisiert Testen mit Angular

Kritische Fehler, die erst im Rahmen des Live-Betriebes öffentlich werden, stellen ein großes finanzielles Risiko und nicht zuletzt eine negative Werbung für ein Produkt und die beteiligten Unternehmen dar. Deshalb ist das Thema Test in der modernen Softwareentwicklung ein grundlegender und integraler Bestandteil. Durch eine hohe Testabdeckung und der zeitnahen Rückmeldung der Testergebnisse lässt sich die Qualität und Reife des Produktes ausreichend genau nachweisen und bestätigen.

Eine Lösung, die eine schnelle Durchführung dieser Tests ermöglicht und den Anforderungen moderner Entwicklungsprojekte entspricht, ist der Einsatz von Testautomatisierungswerkzeugen. Diese Werkzeuge arbeiten nach dem Prinzip der toolgesteuerten Aufnahme von Informationen über die grafische Oberfläche des Testobjekts und der damit möglichen automatisierten Durchführung von skriptgebundenen Interaktionen sowie der daraus resultierenden Prüfung der jeweiligen Applikation.

Testautomatisierungswerkzeuge sorgen für eine schnelle und kontinuierliche Rückmeldung über den Stand der Qualität der zu testenden Software. Aber bei ihrem Einsatz müssen einige Punkte beachtet werden. Es gibt verschiedene Werkzeuge auf dem Markt die unterschiedliche Ansätze wählen, wie sie sich in den Entwicklungs- und Testprozess integrieren oder welche Technologien sie unterstützen. Der effektive Einsatz einer Testautomatisierungslösung steht und fällt mit der verwendeten Engine, die zur Ansteuerung der grafischen Oberfläche genutzt wird. Diese muss die zu testende Technologie optimal unterstützen. Besonders Entwicklungsprojekte, die “neue” Technologien wie Angular2 einsetzen, haben die Herausforderung, dass die vorhandenen und bekannten Werkzeuge nicht immer auf dem gleichen Stand sind wie ihr Arbeitsgegenstand.

Projekt CLINTR und Tests mit Protractor

In unserem aktuellen Softwareentwicklungsprojekt Clintr nutzen wir Angular2 als Entwicklungsframework und wollten von Beginn an eine hohe Dichte von automatisierten Testfällen. Clintr ist eine Web-Anwendung, die Dienstleister auf potenzielle Kunden in ihrem Kontaktnetzwerk aufmerksam macht. Dazu werden Daten der angebotenen XING-API verwendet und analysiert, um nach bestimmten Kriterien vollautomatisiert einen Dienstleistungsbedarf bei Firmen abzuleiten. Wurde ein Dienstleistungsbedarf einer Firma identifiziert, sucht Clintr im Kontaktnetzwerk (z.B. XING oder CRM-Systeme) des Dienstleisters nach Kontaktpfaden zum potenziellen Kunden. Im Backend kommen Spring Boot basierte Microservices mit Kubernetes als Container Cluster Manager zum Einsatz, während im Frontend Angular (>2) eingesetzt wird. Um hochfrequent neue Versionen der Anwendung veröffentlichen zu können, wurde eine Continuous Delivery Pipeline in die Google-Cloud etabliert und das für Test- bzw. Produktionsumgebung.

Wir haben uns durch den Einsatz von Angular2 für das Automatisierungs-Testwerkzeug Protractor entschieden. Protractor baut auf Selenium und dem WebDriver Framework auf. Wie gewohnt laufen die Oberflächentests im Browser ab und simulieren das Verhalten eines Nutzers, der die Anwendung verwendet. Da Protractor direkt für Angular geschrieben wurde, kann es auf alle Angular-Elemente ohne Einschränkungen zugreifen. Darüber hinaus werden keine zusätzlichen Anweisungen für das Warten auf Komponenten wie “sleeps“ oder “waits“ benötigt, da Protractor selbst erkennt, in welchem Status die Komponenten sich befinden bzw. ob sie für die anstehende Interaktion zur Verfügung stehen.

How To

Für die Inbetriebnahme benötigt man AngularCLI und NodeJS. Danach können im Projekt die Oberflächentests (end-to-end oder e2e) erstellt werden. Zur Vorbereitung der lokalen Testausführung wechselt man mit der Konsole in das Projekt-Verzeichnis und gibt “ng serve” ein. Nach der Eingabe von “ng e2e” werden die Testfälle dann auf dem localhost ausgeführt.

Die end-to-end Tests bestehen aus Type Script Dateien mit der Endung .e2e-spec.ts, .po.ts oder nur .ts. In den Dateien, die mit .e2e-spec.ts enden, werden die Testfälle beschrieben. Nur Tests die in diesen Dateien stehen werden ausgeführt. In dem folgenden Beispiel sieht man den Kopf einer .e2e-spec.ts-Datei:

    import { browser, by, ElementFinder } from 'protractor';
    import { ResultPage } from './result-list.po';
    import { CommonTabActions } from './common-tab-actions';
    import { SearchPage } from './search.po';
    import { AppPage } from './app.po';
    import { CardPageObject } from './card.po';
    import * as webdriver from 'selenium-webdriver';
    import ModulePromise = webdriver.promise;
    import Promise = webdriver.promise.Promise;
     
    describe('Result list', function () {
     
     let app: AppPage;
     let result: ResultPage;
     let common: CommonTabActions;
     let search: SearchPage;
     
     beforeEach(() => {
     app = new AppPage();
     result = new ResultPage();
     common = new CommonTabActions();
     search = new SearchPage();
     result.navigateTo();
     });

Diese wird wie auch die anderen Dateitypen mit den Importen eröffnet. Darauf folgt der Beginn der Testfälle mit describe. In dem String in der Klammer wird angeben, welcher Bereich getestet werden soll. Darunter werden die einzelnen .po.ts Dateien angelegt und instanziiert, die für die darauffolgenden Tests benötigt werden. Durch die beforeEach Anweisung lassen sich Vorbedingungen für den Test definieren. Zum Zweck der Wiederverwendbarkeit lassen sich die Tests auch in Module auslagern (siehe nachfolgendes Code-Beispiel):

    it('should display the correct background-image when accessing the page', require('./background'));
    it('should send me to the impressum page', require('./impressum'));
    it('should send me to the privacy-policy page', require('./privacy-policy'));
     
    it('should open the search page after clicking clintr logo', require('./logo'));

Im nachfolgenden Code sind normale e2e Tests aufgeführt. Dort steht zuerst, was erwartet wird und danach die Ausführung des Tests. Hierbei sollte man sich merken, dass die e2e Tests in der .e2e-spec.ts nur die Methoden der .po.ts aufruft, und dann das Ergebnis zurückerwartet. Die ausführenden Methoden gehören in die .po.ts.

    it('should still show the elements of the searchbar', () => {
     expect(result.isSearchFieldDisplayed()).toBe(true);
     expect(result.isSearchButtonDisplayed()).toBe(true);
    });
     
    it('should show the correct Search Term', () => {
     expect(result.getSearchTerm()).toBe(result.searchTerm);
    });

Das nachfolgende Code-Beispiel zeigt die zu der vorherigen .e2e-spec.ts gehörigen .po.ts. Es ist nicht zwingend notwendig, dass jede .e2e-spec.ts ihre eigene .po.ts hat oder umgekehrt. Zum Beispiel kann eine .po.ts Aktionen von Tabs enthalten, wie Tab wechseln oder schließen. Solange eine .e2e-spec.ts nur Methoden von anderen .po.ts benutzt, benötigt sie nicht zwingend eine eigene .po.ts. Wie vorher erwähnt, beginnt die .po.ts mit den Importen und danach wird Klasse (im Beispiel ResultPage) erstellt.

Die navigateTo Methode lässt den Test bei ihrem Aufruf auf die vorgesehene Seite springen. Da der Test das in diesem Fall nicht direkt machen soll, geht er zuerst auf die Search Seite. Dort wird ein Suchbegriff eingegeben und die Suche gestartet. Somit kommt der Test auf die result_list Seite, wo dann die Tests ausgeführt werden.

    import { element, by, ElementFinder, browser } from 'protractor';
    import { SearchPage } from './search.po';
    import * as webdriver from 'selenium-webdriver';
    import { CardPageObject } from './card.po';
    import ModulePromise = webdriver.promise;
    import Promise = webdriver.promise.Promise;
     
    export class ResultPage {
     
     public searchTerm: string = 'test';
     
     search: SearchPage;
     
     navigateTo(): Promise<void> {
     this.search = new SearchPage();
     return this.search.navigateTo()
     .then(() => this.search.setTextInSearchField(this.searchTerm))
     .then(() => this.search.clickSearchButton());
     }

In den drei nachfolgenden Methoden wird jeweils ein Element der Seite abgefragt. Die ersten zwei Tests haben als Rückgabewert einen Union Type. Das heißt, dass entweder ein boolean oder ein Promise<boolean> zurückgegeben werden kann. Also entweder ein Boolean oder das Versprechen auf einen Boolean. Wenn man mit dem Rückgabewert Promise arbeitet, sollte darauf immer ein then folgen, da es sonst zu asynchronen Fehlern kommen kann.

    isSearchButtonDisplayed(): Promise<boolean> | boolean {
     return element(by.name('searchInputField')).isDisplayed();
    }
     
    isSearchFieldDisplayed(): Promise<boolean> | boolean {
     return element(by.name('searchButton')).isDisplayed();
    }
     
    getSearchTerm(): Promise<string> {
     return element(by.name('searchInputField')).getAttribute('value');
    }

Beispiel

Ein Umsetzungsbeispiel für einen Testfall in ClintR ist der Test des Impressumlinks. Er soll zuerst den Link drücken. Danach soll der Test auf den neu entstandenen Tab wechseln und bestätigen, ob die URL /legal-notice enthält. Als letztes soll er diesen Tab wieder schließen. Dieser Test wurde erst nur für die Startseite erstellt.

    it('should send me to the impressum page',() => {
     impressum.clickImpressumLink();
     common.switchToAnotherTab(1);
     expect(browser.getCurrentUrl()).toContain('/legal-notice');
     common.closeSelectedTab(1);
    })

Da das Impressum, laut Akzeptanzkriterium, von allen Unterseiten erreichbar sein muss, wurde dieser später in alle anderen Specs übertragen. Um den Code übersichtlich zu halten, wurde entschieden, diesen Test in ein Modul (impressum.ts) auszulagern.

    import { browser } from 'protractor';
    import { AppPage } from './app.po';
    import { CommonTabActions } from './common-tab-actions';
     
    module.exports = () => {
     let common: CommonTabActions = new CommonTabActions();
     new AppPage().clickImpressumLink().then(() => {
     common.switchToAnotherTab(1);
     expect(browser.getCurrentUrl()).toContain('/legal-notice');
     common.closeSelectedTab(1);
     });
    };

Die Verwendung in der e2e-spec.ts erfolgt auf diesem Wege:

    it('should send me to the impressum page', require('./impressum'));

Besonderheiten, Hinweise & Probleme

In jeder e2e-spec.ts können bestimmte vorgegebene Anweisungen geschrieben werden – z.B. beforeEach, beforeAll oder afterEach und afterAll. Wie die Namen schon sagen, wird der Code, der in einer dieser Anweisung steht, vor bzw. nach jedem oder allen Tests ausgeführt. In unserem Beispiel sollte jeder Test seinen eigenen Seitenaufruf haben. Somit kann z.B. die navigateTo Methode in die beforeEach Anweisung geschrieben werden. afterEach kann z.B. dafür genutzt werden, Tabs, die während der Tests geöffnet worden sind, wieder zu schließen.

Jeder Test beginnt mit dem Wort it. Wenn man vor diesem Wort ein x schreibt, also xit, wird dieser Test bei der Testausführung übersprungen. Es wird dann aber bei der Testausführung anders als bei einem auskommentierten Test mitgeteilt, dass ein oder mehrere Tests übersprungen worden sind. Sollte man einen Testfall mit f schreiben, also fit, werden nur noch Tests bei der Ausführung berücksichtigt, die auch ein fit davor stehen haben. Das ist nützlich, wenn man sehr viele Testfälle hat und man nur einige von ihnen laufen lassen will.

Beim Arbeiten mit Promise, die man aus manchen Methoden erhält, sollte man darauf achten, dass es bei falscher Handhabung zu asynchronen Fehlern kommen kann. Viele Ereignisse wie das Drücken eines Buttons oder die Abfrage, ob ein Element angezeigt wird, erzeugen als Wiedergabewert ein solches Promise. Selbst das Öffnen einer Seite gibt ein Promise<void> zurück. Um Fehler zu vermeiden sollte auf jedes Promise, welches weitere Aktionen nach sich zieht, wie das Drücken eines Buttons und die Ausgabe von einem dadurch entstandenen Wert, explizit mit then reagiert werden. Zum Beispiel:

    drückeButton().then( () => {
     gibMirDenEntstandenenWert();
    });
    //Wenn dieser Wert wieder ein Promise ist, der etwas auslösen soll, dann würde das Ganze so aussehen:
    drückeButton().then( () => {
     gibMirDenEntstandenenWert().then( () => {
     machNochEtwas();
     });
    });
    // oder etwas kürzer
    drückeButton()
     .then(gibMirDenEntstandenenWert)
     .then(machNochEtwas);

Für weitere Informationen zum Thema Promise siehe hier.

Fazit

Protractor eignet sich sehr gut für die Automatisierung von Oberflächentests in einem Softwareentwicklungsprojekt mit Angular2. Die Dokumentation seitens des Projektes ist sehr ausführlich und umfangreich. Durch die Nutzung von Selenium lassen sich die Tests ohne Probleme in den Buildprozess einbinden.