E2E-Testing mit Protractor – Teil 1: Einstieg und Grundlagen

Um die korrekte Funktionalität einer Anwendung sicherzustellen, sind Tests unabdingbar – sowohl Unit- als auch Integrationstests sollen sicherstellen, dass sie unter möglichst allen Umständen ein definiertes und damit erwartetes Verhalten an den Tag legt. Der kleine, aber nicht zu unterschätzende Haken beider Test-Kategorien ist, dass sie nicht unmittelbar auf die Sicht eines Benutzers auf das System eingehen – sprich, sie testen keine Oberfläche und damit höchstens mittelbar Benutzerinteraktionen.

Selbige werden – auch in größeren Projekten – nach wie vor primär in manuellen Tests abgehandelt, im Rahmen des sogenannten User-Acceptance-Testing. Dies ist insbesondere bei Web-Anwendungen keine triviale Arbeit, da die breite Browserpalette leider auch ein breites Feld von Verhaltensmustern nach sich zieht – vor allem, wenn man auch eher ältere Versionen unterstützen möchte oder muss. Das wirft natürlich die Frage nach dem warum auf – schließlich sind die anderen Test-Varianten auch automatisiert möglich, warum also nicht auch solche für Benutzerinteraktionen?

Eine mögliche Antwort auf diese Frage liefert der WebDriver-Standard, welcher letztlich ein Interface definiert, um Benutzer-äquivalente Interaktionen mit einem Browser via externer Steuerung durchführen zu können. Natürlich ist es den jeweiligen Browsern überlassen, ob und welcher Form sie diese Kontrolle zulassen – grundsätzlich jedoch ist der Ansatz schon einmal vielversprechend.

User Acceptance Tests bedingen letztlich, dass die getestete Anwendung von einem Ende zum anderen, also von der Oberfläche über den Server bis zur Datenhaltung, durchgängig funktionieren. Daher kann man sie in Fällen der reinen Funktionalität (nicht jedoch Darstellung und nur eingeschränkt Workflow) auch durch Tests ersetzen, die eben diese Durchgängigkeit überprüfen – womit wir beim Thema End-to-End-Testing, kurz E2E, wären.

Vorab

Dieser Beitrag ist der erste Teil eines insgesamt sechsteiligen Tutorials, bei dem es schrittweise um folgende Themen gehen wird:

  • Heranführung an Protractor sowie die einfache Definition und Ausführung von Tests inklusive der darin enthaltenen Specs
  • Umsetzung einer sinnvollen Struktur von Tests und zugehörigen Helpern
  • Bearbeitung von eher komplexeren oder spezielleren Themen
  • Erstellung von speziellen Reports
  • Behandlung von „Rough edges“ sowie bekannten Fehlern und Problemen

Die verwendeten Strategien sollten prinzipiell übertragbar sein.

Das Repository zu den im folgenden betrachteten Beispielen ist hier zu finden. Die getaggten Releases stellen dabei weitestgehend die Schritte dar, welche im Rahmen dieses Guides durchgegangen werden.

Intro

Was ist … ?

Bei Protractor handelt es sich um ein Framework für E2E-Tests, welches auf WebDriverJs aufbaut. Selbiges fungiert wiederum als Node.js-Binding für Selenium Webdriver, welches eine Implementierung des eingangs genannten Standards zur automatisierten Interaktion mit Browsern darstellt und entsprechende High-Level-Schnittstellen für Entwickler anbietet. Neben Java und Node.js gibt es u.a. auch Bindings für C#, Ruby, Python sowie weitere Sprachen, die allerdings von anderen Entwicklern maintained werden. Da die jeweiligen Browser stets etwas anderes angesteuert werden müssen, nutzt Selenium Webdriver browser-spezifische Treiber, die abseits von jenen für Firefox durch Dritt-Entwicklern bereitgestellt werden.

Protractor ergänzt die durch WebDriverJs bereitgestellte Funktionalität um angular-spezifische, darunter eine Synchronisierung mit Angular während des Ladens einer Seite oder spezielle Selektoren, um Elemente via Model- oder Binding-Definition aufzuspüren. Protractor selbst dient dabei nur als Test-Runner, welcher die Integration von Test-Frameworks wie JasmineMocha oder Cucumber.js ermöglicht, die unter (Frontend-) Entwicklern verbreitet und gängig sind. Ziel ihrer Bereitstellung soll sein, Entwickler für diesen Abschnitt des Entwicklungsprozesses von Anwendungen zu gewinnen und ihn nicht mehr „nur“ Testern zu überlassen, was im Kontrast zu (ebenfalls auf Selenium aufbauenden) Frameworks wie FitNesse steht.

Warum Protractor?

Es gab natürlich bereits vor dem ersten Protractor-Release etablierte „Mitbewerber“ im Bereich E2E-Tests für Web-Applikationen. Beispiele hierfür wären Watir, welches in Ruby implementiert ist und die entsprechenden Bindings für Selenium nutzt, oder Geb, welches Groovy nutzt und eine vereinfachende DSL für die Java-Bindings anbietet. Natürlich sind diese nicht verschwunden. Allerdings wurden hier zwei wesentliche Aspekte bemängelt, die letztlich zur Entstehung von Protractor geführt haben:

  • Die existierenden Frameworks ermöglichten keine unmittelbare Interaktion und Synchronisierung mit Angular-Anwendungen.
  • Die Oberfläche einer Web-Applikation und die unmittelbar daran anknüpfende Logik, mit welcher die Tests interagieren, wird primär von entsprechenden Frontend-Entwicklern gebaut. Folglich sollten auch selbige, ohne dafür Zusatzkenntnisse in anderen Sprachen zu erwerben müssen, in die Lage versetzt werden, diese Test-Variante einzusetzen.

Während man über den letztgenannten Punkt mit Sicherheit vortrefflich streiten könnte, lässt sich der erstgenannte nicht von der Hand weisen. Gerade wer umfangreichere Angular-Anwendungen mit komplexer (Darstellungs-)Logik entwickelt, wird bei den anderen Frameworks mit dem inhaltlich selben Ziel vor allem auf Synchronisierungs-Probleme stoßen. Vor dem Wechsel auf Protractor hatten wir bspw. das o.g. FitNesse im Einsatz, bei welchem die Synchronisierung letztlich vom jeweiligen Testschreiber via Sleep-Anweisungen abgewartet wurde – immer in der Hoffnung, dass die jeweils angegebene Zeit ausreichen würde.

Neben diesen Aspekten hebt sich auch die umfangreiche Anpassbarkeit u.a. bzgl. des zu nutzenden Test-Frameworks und der generierbaren Reports bei Protractor hervor. Man muss fairerweise sagen, dass dies auch bei den o.g. Frameworks Watir und Geb zutrifft, dort aber umständlicher ist..

Was man vor dem Weiterlesen wissen sollte

Grundsätzlich setzt Protractor nicht allzu viele Vorkenntnisse abseits der „handelsüblichen“ Basics voraus:

  • DOM-Selektoren – Sinn und Zweck sollte klar sein, Abfragen wie „h2 > span.whatever“ sollten keine Angst machen. Wie kompliziert solche Selektoren letztlich werden, hängt natürlich davon ab, wie kompliziert die jeweils zu testende Seite strukturiert ist.
  • AngularJS – sollte offensichtlich sein …
  • Promises – Sollte jedem bekannt sein, der schon einmal etwas umfangreichere Frontend-Software mit Ajax-Requests geschrieben hat und dabei der Callback-Hölle entkommen wollte. Alle anderen finden hier eine ziemlich umfassende Beschreibung, was Promises sind und wie sie prinzipiell verwendet werden können (den Promise A+ Standard). WebDriverJs bringt eine eigene Implementierung mit, welche im Vergleich mit bspw. der verbreiteten Q-Implementierung ein paar Eigenheiten aufweist. Auf diese wird, sofern nötig, an den entsprechenden Stellen noch eingegangen.
  • Jasmine 2 – Frontend-Tests auf Basis von BDD dürften den meisten Entwicklern dieser Sparte aus Jasmine oder dem syntaktisch ähnlichen Mocha vertraut sein. Die hier angegebenen Beispiel nutzen Jasmine 2, da zum einen die Integration mit Protractor etwas besser funktioniert als bei den anderen prinzipiell unterstützen Frameworks, und zum anderen die verwendete Protractor-Version 3 Jasmine 1 nicht mehr unterstützt. Wer noch nicht so ganz mit Jasmine 2 vertraut ist, aber mit Jasmine 1, findet im Upgrading Guide die wichtigsten Informationen zu den damit einher gegangenen Neuerungen. Für die Beispiel-Tests und letztlich auch praktisch werden vor allem die Strukturierungen via describe und it sowie die Setup-/Teardown Hooks mit before{Each|All} und after{Each|All} benötigt.
  • ES6 / ES2015 – wird von Protractor nicht benötigt, bietet aber Vorteile bei der Strukturierung sowohl der Tests als auch der zu testenden Beispielanwendung. Da Node (4.x) nach wie vor nicht einschränkungsfrei die Nutzung von ES6-Features erlaubt, werden wir uns mit Babel und dem entsprechenden Preset behelfen. Wie dies installiert wird, wird an den entsprechenden Stellen noch erklärt.

Für die Nutzung von Protractor ist weiterhin wichtig, dass Angular im Debug-Modus läuft – was die Standardeinstellung wäre. Hierbei werden unter anderem die Klassen ng-scope und ng-binding im DOM zugeordnet, was die Suche via Model- oder Binding-Definition erst ermöglicht. Sollte sich also an irgendeiner Stelle eures Anwendungscodes die Zeile

$compileProvider.debugInfoEnabled(false);

befinden, sorgt bitte dafür, dass sie in der zu testenden Konfiguration nicht ausgeführt wird.

Nur für Angular?

Protractor ist vor allem für E2E-Tests gegen Angular-basierte Anwendungen gedacht und kann in diesem Kontext auch seine Stärken ausspielen. Prinzipiell ist es aber auch möglich, Anwendungen, welche kein Angular.js verwenden, zu testen. Hierfür kann vor Beginn des Tests oder im Rahmen einer beliebigen Suite die Synchronisierung abgeschaltet werden:

browser.ignoreSynchronization = true;

Setzt man diesen Parameter auf false zurück, wird umgehend eine Synchronisierung mit dem potentiell laufenden Angular.js eingeleitet.

Der o.g. Schritt kann innerhalb einer Suite notwendig werden, wenn die zu testende Anwendung auch angular-freie Seiten enthält, bspw. für die Indikation von bestimmten Status-Werten wie 404 oder 500.

Setup

Prinzipielle Dependencies

Für die Ausführung von Tests via Protractor wird eine aktuelle Version von Node.js >= 4 benötigt. Die LTS-Version 4.x bietet sich entsprechend an. Die Installation ist entweder über den Direkt-Download oder über diverse Package-Manager für die entsprechenden Betriebssysteme möglich (siehe hier).

Weiterhin wird für den Selenium-Standalone-Server eine aktuelle Version von Java benötigt. Hier sind sowohl die Oracle- als auch die OpenJDK/JRE-Variante in Ordnung. Die aktuelle Version 8 ist zu empfehlen.

Legt danach ein neues NPM-Projekt in einem Verzeichnis eurer Wahl an (am einfachsten via npm init) und wechselt dorthin. Dann wären wir prinzipiell startbereit und könnten dem Quick-Setup folgen – allerdings werden wir noch etwas mehr tun, um für den weiteren Verlauf besser vorbereitet zu sein…

Task Runner

… und zwar in Gestalt eines Task-Runners. Normalerweise wird Protractor als globales Package installiert und dann mit dem Verweis auf eine Konfigurationsdatei ausgeführt. Dies ist prinzipiell auch in Ordnung, wird aber spätestens, wenn der Build parametrisiert werden soll (bspw. um nur eine bestimmte Submenge der Specs auszuführen, oder um einen Headless-Mode zu aktivieren) zu einer unübersichtlichen Orgie von entsprechend spezialisierten Konfigurationsdateien. Die folgenden Abschnitte werden zeigen, wie man dies mit Hilfe eines Taskrunners umgeht. Neben dem vereinfachten Konfigurations-Handling erspart diese Variante, protractor und den daran hängenden webdriver-manager global installieren zu müssen.

Task-Helper gibt es sowohl für Gulp als auch für Grunt. Nach den bisherigen Erfahrungen – die sich natürlich im Beispielrepository widerspiegeln – hat sich Gulp in diesem Fall als pragmatischere Lösung hervorgetan; grundsätzlich ist allerdings die genaue Wahl egal. Strenggenommen könnte hier auch ein reines NPM-Skript genutzt werden.

Für den weiteren Verlauf dieses Tutorials werden keine Vorkenntnisse bezüglich Gulp vorausgesetzt – an den jeweiligen Stellen wird auf die notwendigen Details eingegangen.

Gulp und Babel einrichten

Zur Installation von Gulp wird

npm install -g gulp
npm install --save-dev gulp

benötigt. Für ersteres wird ggf. sudo benötigt (v.a. unter Linux / Mac OSX).

Um Babel für das Gulpfile – und später auch die Tests – verfügbar zu machen, müssen dann noch

npm install babel-core babel-preset-es2015

installiert werden. Legt dann eine Datei namens .babelrc mit folgendem Inhalt im Verzeichnis an:

{
  "presets": ["es2015"]
}

Dadurch wird Babel für die Nutzung des eben installierten Presets bei jeglichem Compile-Vorgang, der keine andere Konfiguration vorgibt, konfiguriert.

Bevor nun das notwendige Gulpfile angelegt werden kann, kümmern wir uns um die noch offen Abhängigkeit – Protractor:

npm install --save-dev protractor

Um nun Gulp mit Protractor zusammenzubringen, wird ein Plugin benötigt. Da auf dem Stand der Erstellung des Beispiels (18.11.2015) die Versionen der meisten Plugins (bspw. von gulp-protractor) noch nicht mit Version 3.x von Protractor laufen, findet sich im Beispielrepository eine entsprechend angepasste Fassung. Seit dem 01.12. gibt es zumindest vom genannten gulp-protractor eine Version 2.x, welche Protractor 3.x nutzt. Wer lieber damit arbeiten will, muss lediglich den Import-Pfad entsprechend anpassen. Im Beispiel-Repository liegt ein Branch mit den nötigen Anpassungen für den finalen Stand. Die reinen Änderungen sind in diesem Commit einzusehen. Es empfiehlt sich trotz Nutzung des Plugins, Protractor vorher selbst zu installieren, da dann das Plugin die installierte Version nutzt, sofern sie dessen Versions-Anforderungen genügt. Auf diesem Weg kann man die Versionen von Protractor und des genutzten Plugins getrennt voneinander pflegen.

Das Gulpfile

Gulp nutzt liftoff und kann daher mit einer ganzen Reihe von Transpilern genutzt werden, darunter auch das hier eingesetzte Babel. Um die korrekte Abbildung auf Babel als Transpiler zu triggern, legt man ein gulpfile mit der Endung .babel.js anstelle von .js an. Ähnliches kann man bspw. auch für Coffee- (.coffee) oder Typescript (.ts) nutzen.

Legt also nun besagte Datei an und füllt sie zunächst mit Folgendem (bzgl. des zu nutzenden Plugins – siehe Kommentare im Code):

'use strict';
 
import gulp from 'gulp';
 
// With the custom plugin bundled to the repo
import {protractor, webdriver_update} from './test/e2e/helper/gulp-protractor-plugin';
// With gulp-protractor:
import {protractor, webdriver_update} from 'gulp-protractor';

Der genannte Pfad entstammt aus dem Beispiel und müsste entsprechend angepasst werden, solltet ihr das Plugin an einem anderen Ort hinterlegen.

Das Gulp-Plugin bietet neben dem Proxy für den eigentlichen Protractor-Task auch eine Hilfsfunktion zum triggern das Webdriver-Updates an. Letzteres muss einmal initial und später – zumindest prinzipiell – nur hin und wieder ausgeführt werden, um den Selenium Server sowie die zusätzlichen Treiber zu installieren bzw. aktuell zu halten. Hierfür sollte ein separater Task angelegt werden:

// ...
let drivers = ['chrome'];
 
/^win/.test(process.platform) && drivers.push('ie');
 
let webdriverUpdate = webdriver_update.bind(null, {browsers: drivers});
 
gulp.task('webdriver:update', (cb) => {
  webdriverUpdate(cb);
});

Zur Erklärung:

  • Selenium Webdriver und damit auch Protractor unterstützen von Haus aus nur Firefox, alle anderen Browser benötigen separate Treiber.
  • Welche davon Protractor prinzipiell unterstützt, kann aus der config.json im Repository entnommen werden.
  • Im o.g. Code fügen wir initial den Treiber für Chrome hinzu. Nur unter Windows wird die Liste um den Treiber für den Internet Explorer ergänzt (auf anderen Plattformen wäre das auch reichlich sinnlos).
  • Die Update-Funktion nimmt ein Objekt mit entsprechender Konfiguration des gewünschten Updatevorgangs entgegen. Hier ist erst einmal nur wichtig, dass die Liste der zu ladenden Treiber übergeben wird. Daher wird eine Funktion via bind angelegt, welche diesen Parameter per default an selbige weitergibt. Den zweiten Parameter – das Completion-Callback – gibt später der Aufruf durch den Gulp-Task mit.
  • Im letzten Schritt wird der eigentliche Gulp-Task angelegt. Da webdriverUpdate keinen Stream zurückgibt, wird der optionale Callback-Parameter cb mitgenommen und an die Update-Funktion zum späteren Aufruf übergeben.

Für die Unterstützung eines ersten Browsers – wir werden hier zunächst Firefox nutzen – wird eine entsprechende Konfiguration benötigt, welche beim Aufruf referenziert werden muss. Im Beispiel wurde diese im Projekt-Verzeichnis unter test/e2e/configs/firefox.js abgelegt:

var defaultTimeout = 60000;
module.exports = {
  config: {
    capabilities: {
      browserName: 'firefox'
    },
    framework: 'jasmine2',
    getPageTimeout: defaultTimeout,
    allScriptsTimeout: defaultTimeout,
    defaultTimeoutInterval: defaultTimeout,
    jasmineNodeOpts: {
      showColors: true
    },
    onPrepare: () => {
      // E.g., we might add a better structured command line reporter.
      SpecReporter = require('jasmine-spec-reporter');
      jasmine.getEnv().addReporter(new SpecReporter());
    }
  }
};

Auch hier wieder, zur Erklärung:

  • Es fällt sofort auf, dass der o.g. Code nicht vollständig ES6 nutzt. Dies ist hier leider notwendig, da diese Datei von Protractor beim Starten des zugehörigen Prozesses behandelt wird – der einzige Teil des Ablaufs, in welchen wir nicht via Babel eingreifen können. Daher muss hier – mit Ausnahme der Arrow-Notation – auf ES5 zurückgegriffen werden.
  • Protractor erwartet, dass die Datei ein Objekt exportiert, welches den Eintrag config enthält.
  • Die entscheidenden Parameter hierin sind:
    • capabilities – im obigen Beispiel wird nur der Browsername angegeben. Der genaue bzw. auch optionale Inhalt ist browserabgängig – bspw. kann hier für Chrome/Chromium auch eine Liste mit Kommandozeilenparametern angegeben werden, welche dann für dessen Ausführung verwendet werden.
    • framework – hierüber kann das verwendete Framework eingestellt werden. Für das Beispiel wird jasmine2 konfiguriert. Protractor lädt daraus resultierend automatisch die zugehörigen jasminewd2 Ergänzungen (dazu später mehr, hier nur der Vollständigkeit halber erwähnt).
    • onPrepare – Diese Funktion wird von Protractor aufgerufen, nachdem die Konfiguration eingelesen und Jasmine initialisiert wurde, aber bevor die Tests ausgeführt werden. Hier können bspw. weitere Reporter für Jasmine registriert werden. Im obigen Beispiel wird der jasmine-spec-reporter (kann via npm install –save-dev jasmine-spec-reporter installiert werden) registriert, welcher die Ausgabe auf der Kommandozeile modifiziert, sodass erfolgreich durchgelaufene Specs farblich und symbolisch hervorgehoben werden.
      example-jasmine-spec-reporter
    • Wer eine angular-freie Anwendung testen möchte, kann an dieser Stelle bereits die eingangs genannte Abschaltung der Synchronisierung durchführen.
  • Zusätzlich werden mehrere Timeouts gesetzt, welche sich auf das Verhalten des Test-Runners bei Verzögerungen auswirken. Bei der angegebenen Zeit von 60 Sekunden handelt es sich um einen Erfahrungswert, der sich aus Tests mit verschiedenen Browser auf unterschiedlichen Plattformen und Umgebungen (darunter auch einer CI-Integration) speist. Je nach genauer Umgebung kann bzw. sollte dieser Wert natürlich angepasst werden.
    • getPageTimeout – beschreibt die Zeit, welche maximal auf das Laden einer Seite nach deren Aufruf gewartet wird. Dies umfasst auch die Zeit bis zum Laden von Angular (nicht aber bis zur Synchronisierung). Wird in Millisekunden angegeben.
    • allScriptsTimeout – beschreibt die Zeit, welche maximal auf Synchronisierung mit der Seite gewartet wird. Darunter fällt auch das Warten auf asynchrones Nachladen via $http sowie noch offene $timeout-Schedules. Vom Wartevorgang ausgenommen sind Schedules via $interval. Wird in Millisekunden angegeben. (Achtung: Polling sollte mit $http bzw. $timeout zwar prinzipiell ohnehin nicht betrieben werden, könnte hier aber zusätzlich dazu führen, dass die Seite gar nicht mehr korrekt synchronisiert werden kann!)
    • defaultTimeoutInterval – beschreibt die Zeit, welche maximal auf den Abschluss einer Jasmine-Spec gewartet wird (einem it-Block). Wird in Millisekunden angegeben.
  • Weiterhin wird via jasmineNodeOpts noch die Färbung der Ausgabe eingestellt.

Zu guter letzt richten wir noch einen Task ein, um Specs mit Firefox laufen zu lassen:

let firefoxConfig = "test/e2e/configs/firefox.js";
gulp.task('e2e:firefox', ['webdriver:update'], () => {
  return gulp.src('test/e2e/specs/**/*.spec.js')
    .pipe(protractor({
      configFile: firefoxConfig,
      args: [
        '--baseUrl',
        'http://localhost:3333'
      ]
    }));
});

Zur Erläuterung:

  • Der zuvor angelegte Task webdriver:update wird als Abhängigkeit definiert, um vor der Ausführung der Tests sicherzustellen, dass die notwendigen Abhängigkeiten (Webdriver, Selenium Server, Browser-Treiber) aktuell sind. Nach der initialen Ausführung umfasst dieser Task nur bei Updates noch mehr als die lokale Prüfung auf Aktualität (Abgleich installierte Treiber-Versionen mit konfigurierten), nimmt also i.d.R. nicht viel Zeit in Anspruch.
  • Gulp selektiert dann auf Basis eines Globs die auszuführenden Specs, welche hier im Beispiel im Unterordner test/e2e/specs liegen (ggf. noch in weiteren Unterordnern). Auszuführende Specs sind an der Dateiendung .spec.js zu erkennen – eine gängige Konvention für Jasmine-Tests.
  • Die durch den Glob gefundenen Specs werden dann an das Plugin übergeben. Selbiges generiert hieraus einen Parameter –specs für den zu startenden Protractor-Prozess, welcher alternativ auch direkt in der Konfiguration stehen könnte. Die zusätzlich via args angegebenen Parameter werden direkt an Protractor weitergeleitet:
    • configFile – der o.g. Pfad firefoxConfig, über den Protractor die notwendige Konfiguration findet
    • args – Kommandozeilen-Argumente für Protractor
      • baseUrl – eine Ausgangs-URL, welche nach Festsetzung relative URLs zur Ansteuerung erlaubt (hierzu später mehr). Die hier aufgeführte URL stammt vom Entwicklungsserver des noch folgenden Beispiels.

Spätere Abschnitte werden noch weitere Parameter und Optionen behandeln.

„Hello World!“ Beispiel

Für einen ersten Beispieltest wird eine entsprechende Seite benötigt. Hierzu findet ihr unter diesem Tag einen Stand, welcher eine beispielhafte, einfache Angular-Anwendung sowie die oben genannten Abschnitte der Protractor-Integration in den Task-Runner enthält. Dieser Stand wird für die weitere Arbeit benötigt.

Beispiel-Anwendung lokal laufen lassen

Am einfachsten ist es, das Repository zu klonen und dann auf den Tag v2-hello-world zu wechseln. Mit dem enthaltenen Skript install_deps.sh können die benötigten globalen Node-Module jspm und gulp installiert werden – alternativ geht dies natürlich auch manuell. Via npm install und jspm install können dann die notwendigen lokalen Abhängigkeiten nachinstalliert werden. Im Anschluss startet gulp serve den enthaltenen Entwicklungsserver. Wenn alles läuft wie beabsichtigt, findet ihr unter http://localhost:3333 eine Testseite, die erst einmal nicht viel mehr tut als ein Element mit dem Inhalt angular-protractor-test-app-thing darzustellen. Ggf. wird diese Seite auch bereits automatisch im Default-Browser des Systems geöffnet (ist auf dem genannten Stand im Testserver eingestellt, funktioniert aber nicht immer und überall).

Ein erster Test

Im Beispiel findet sich unter test/e2e/specs/home.spec.js ein erster Test für die eben gesehene Seite.

describe('A simple initial testcase', () => {
 
  beforeEach(() => {
    browser.get("/");
  });
 
  it('should display the home`s root element correctly', () => {
    let elem = element(by.id('home-app-name-title'));
    expect(elem.isDisplayed()).toBeTruthy();
  });
 
});

Hier wird vor jeder Spec der Aufruf browser.get ausgeführt. Hierdurch wird die angegebene URL geladen. Da wie o.g. eine Basis-URL angegeben wurde, wird die hier genannte daran angehängt, weshalb der Aufruf auf http://localhost:3333/ abzielt.

Die Spec selbst sucht zunächst nach einem DOM-Element:

  • element(…) nimmt einen sogenannten Locator entgegen, welche sich über Hilfsfunktionen unter der globalen Variable by erzeugen lassen (CoffeeScript-Nutzer können By einsetzen, um Konflikte mit dem Keyword by zu vermeiden). Hier wird beispielhaft der id-Locator genutzt – andere wären bspw. cssclassNametagName oder model. Die by.css-Variante lässt sich bspw. mit Selektoren füttern, wie man sie sonst für jQuery oder in halbwegs aktuellen Browsern für document.querySelector verwenden kann. Das hier genutzte Beispiel by.id entspricht einem Aufruf von document.getElementById.
  • Das Ergebnis des element()-Aufrufes ist ein sogenannter ElementFinder, welcher sich für die weitere Suche nutzen lässt, da er sich wie ein Element selbst verhält. Initial kann man hier allerdings keine Informationen darüber beziehen, ob das selektierte Element wirklich im DOM ist oder ob es dargestellt wird – entsprechend muss man auch keine Fehler behandeln, sollte das Element noch nicht im DOM sein, wenn man davon ausgehend tiefere Selektionen durchführt. Dies erlaubt, solche Finder außerhalb des Tests vorzubereiten, um sie darin nur noch zu nutzen. Wie eine solche Vorbereitung aussehen kann, wird im nächsten Teil der Serie noch näher erläutert.
  • Für die Selektion von mehreren, auf den gegebenen Locator zutreffenden Elemente steht element.all(…) bereit. Dies liefert einen ElementArrayFinder zurück.
    • Hinweis: Wenn der gegebene Locator auf mehrere DOM-Elemente zutrifft, aber nicht mit element.all ausgeführt wurde, wird eine Warnung im Log ausgegeben, dass nur das erste gefundene Element verwendet wird.
  • Zuletzt wird getestet, ob das Element aktuell sichtbar ist, mit der Erwartung, dass dies der Fall ist. Hierzu ist zu wissen, dass isDisplayed() erst eine Abfrage an den Browser senden muss und damit asynchron läuft. Daher ist das Ergebnis ein Promise, welches nach Auflösung die eigentliche Antwort liefert. Die jasminewd2 Integration von Protractor erlaubt hier dem expect von Jasminedie gewünschte Assertion nach Abschluss des Promises zu testen, ohne das dafür spezielle Anpassungen des Testcodes notwendig wären.

Die Selektion von DOM-Elementen ist letztlich der einzige Aspekt, bei dem E2E-Tests der Simulation von Nutzerverhalten etwas voraus sein müssen: Benutzer können zur Aufspürung der notwendigen Elemente einfach hinsehen, die Automatisierung muss hierfür technischeren Weg gehen.

Eine umfassendere Dokumentation bezüglich der o.g. Locator ist für die Basis-Funktionen hier zu finden. Die erwähnten Optionen bzgl. u.a. by.model sind Teil der protractor-spezifischen Ergänzungen.

Ein Task-Runner, mehrere Browser

Vor einem komplexeren Beispiel widmen wir uns zunächst der Frage, wie man die Tests gegen unterschiedliche Browser laufen lassen kann. Dies ist zum einen für den praktischen Einsatz relevant, da sich zum einen unterschiedliche Browser gerne unterschiedlich verhalten, zum anderen, da sich diese Unterschiede auch auf die Testbarkeit und das Verhalten des Test-Tools selbst niederschlagen und man sie in der Regel gern berücksichtigt wissen möchte. So gibt es bspw. unter Firefox oft Probleme, wenn das Fenster Fokus verliert, oder unter Chrome, weil der Browser an einigen Stellen für die Testausführung zu schnell agiert und daher im Test selbst ausgebremst werden muss. U.a. aus diesen Gründen ist es oft notwendig, gegen mehrere Browser zu testen und ggf. die Tests anzupassen, auch wenn natürlich niemals alle Eigenheiten abgedeckt werden können.

Wie eingangs erwähnt ist es bei Nutzung eines Task-Runners auf einfache Art und Weise möglich, unterschiedliche Browser mit zugehörigen Konfigurationen anzusteuern. Neben dem bereits integrierten Firefox würde sich hier Chrome anbieten. Dafür wird zunächst eine weitere Konfiguration benötigt:

var defaultTimeout = 60000;
module.exports = {
  config: {
    capabilities: {
      browserName: 'chrome',
      chromeOptions: {
        args: ['--lang=en-GB'],
        prefs: {
          intl: {
            "accept_languages": "en-GB,en"
          }
        }
      }
    },
    framework: 'jasmine2',
    getPageTimeout: defaultTimeout,
    allScriptsTimeout: defaultTimeout,
    defaultTimeoutInterval: defaultTimeout,
    jasmineNodeOpts: {
      showColors: true
    },
    onPrepare: () => {
      // E.g., we might add a better structured command line reporter.
      SpecReporter = require('jasmine-spec-reporter');
      jasmine.getEnv().addReporter(new SpecReporter());
    }
  }
};

Diese Konfiguration sieht jener vom Firefox sehr ähnlich – der einzige Unterschied neben der Namensangabe liegt in den hinzugekommenen chromeOptions, welche die Sprache des Browsers sowie den Standard-Sprachheader aller Anfragen auf en-GB umsetzen bzw. damit beginnen lassen. Insbesondere bei internationalisierten Seiten ist es sinnvoll, Tests immer mit einer fest definierten Sprache zu starten und sich nicht auf die Locale des jeweiligen Betriebssystems zu verlassen. Diese Einträge sind allerdings kein Muss und dienen hier eher der Illustration, da die Beispielanwendung nicht internationalisiert ist. Der Stand bis hierhin Beispiels ist über diesen Tag verfügbar.

Im o.g. Beispiel fällt die erhebliche Code-Redundanz zur Firefox-Konfiguration auf – was natürlich die Frage aufwirft, wie man selbige vermeiden kann. Hierzu sei zunächst gesagt, dass die Abweichungen in der Konfiguration für unterschiedliche Browser in den meisten Fällen ausschließlich den Capabilities-Eintrag betreffen. Daher bietet es sich an, die übrigen, für alle Browser gemeinsam genutzten Einstellungen in einer Datei baseConfig.js zusammenzufassen, sodass für die beiden spezifischen Konfigurationen nur noch folgendes über bleibt:

var baseConfig = require('./baseConfig'),
  _ = require('lodash/object');
 
var specificConfig = {
  capabilities: {
    // The browser's name, obviously.
    browserName: 'firefox',
    // Only essentially required for Internet Explorer, ignored otherwise.
    ignoreProtectedModeSettings: true
  }
};
 
module.exports.config = _.merge(baseConfig, specificConfig);
var baseConfig = require('./baseConfig'),
  _ = require('lodash/object');
 
var specificConfig = {
  capabilities: {
    browserName: 'chrome',
    chromeOptions: {
      args: ['--lang=en-GB'],
      prefs: {
        intl: {
          "accept_languages": "en-GB,en"
        }
      }
    }
  }
};
 
module.exports.config = _.merge(baseConfig, specificConfig);

Um die beiden Konfigurationen zusammenzufügen, wurde hier die Funktion merge der lodash-Bibliothek verwendet. Für das o.g. Beispiel ist sie etwas übermächtig, kann bzw. wird aber bei der Zusammenführung komplexerer Spezialisierungen von Vorteil sein. Der Stand mit diesen Änderungen ist über diesen Tag zu finden.

Es ist im Übrigen möglich, Tests parallel laufen zu lassen:

  • Im Fall des Taskrunners könnte man einfach mehrere Tasks in mehreren Terminal-Tabs starten.
  • Protractor unterstützt von Haus die parallele Ausführung gegen mehrere Browser über die sogenannten multiCapabilities.

In beiden Fällen ist jedoch Vorsicht geboten – da die Specs i.d.R. nicht in allen Browsern identisch schnell laufen, kann es hier zu Kollisionen kommen, wenn im Rahmen des Tests Aktionen bzgl. Setup oder Teardown erfolgen, welche das gesamte zu testende System betreffen. Ein Beispiel hierfür wäre das Anlegen (im Setup) und Löschen (im Teardown) eines Nutzers in bzw. aus einer Datenbank: Wenn Browser 1 die zugehörige Suite gerade abschließt, Browser 2 aber selbige noch durchläuft, würde der erstere Daten vernichten, welche vom letzteren noch gebraucht werden. Kollisionsfreiheit zu erreichen ist im ganz allgemeinen Fall hier nicht möglich. Ob sich die parallele Ausführung lohnt bzw. technisch unproblematisch ist, muss für jeden Fall gesondert, insbesondere unter Beachtung genannten potentiellen Problemstellen, evaluiert werden.

Ein komplexeres Beispiel

Als Basis für das weitere Testvorgehen dient dieser Tag mit einer gegenüber dem bisherigen Stand deutlich umgebauten Seite. Macht euch nach der Aktualisierung der Abhängigkeiten (npm install und jspm install) vor allem mit dem Template todo.html und dem zugehörigen Controller todo.controller.js in app/components vertraut und betrachtet das Resultat auch im DOM – sowohl die Daten als auch das Layout werden für den folgenden Test benötigt.

Wir starten mit dem Abgleich des gewünschten Layouts der genannten TODO-Seite:

describe('A more complex test for the "TODO list" page', () => {
 
  beforeAll(() => {
    browser.get("/");
  });
 
  describe('General layout', () => {
 
    it('should correctly move to the "TODO list" page', () => {
      // `$` is a short-hander for `element(by.css(...))`.
      // `$$` would be the equivalent for `element.all(by.css(...))`
      let headerButton = $('a[ui-sref="todo"]'),
        todoPageRoot = element(by.id('todo-page'));
 
      headerButton.click();
      expect(todoPageRoot.isDisplayed()).toBeTruthy();
    });
 
    it('should correctly list two example tasks', () => {
      let todoEntries = element.all(by.className('list-group-item'));
 
      expect(todoEntries.count()).toEqual(2)
    });
 
    it('should correctly display entries to create a new task: A textarea, a datepicker, and a button to submit the task', () => {
      let textarea = element(by.tagName('textarea')),
        datepicker = element(by.tagName('datepicker')),
        submitButton = element(by.id('create-task-button'));
 
      expect(textarea.isDisplayed()).toBeTruthy();
      expect(datepicker.isDisplayed()).toBeTruthy();
      expect(submitButton.isDisplayed()).toBeTruthy();
    });
 
    it('should not be possible to submit a task without entering some text', () => {
      let submitButton = element(by.id('create-task-button'));
 
      expect(submitButton.getAttribute('aria-disabled')).toBeTruthy();
    });
 
    it('should highlight the textarea as invalid, since it is empty by default', () => {
      let textarea = element(by.tagName('textarea'));
 
      expect(textarea.getAttribute('aria-invalid')).toBeTruthy();
    });
 
  });
 
});

Sieht erst einmal nach viel aus, ist aber inhaltlich recht simpel:

  1. Vor allen Tests: Aufruf der Rootseite „/“.
  2. Teste, ob von dort die TODO-Seite erreichbar ist, wenn man auf den zugehörigen Link im Header klickt
    1. Erstelle einen Locator für besagten Link: Wird hier per CSS-Selektor erzeugt, der auf das State-Reference-Attribut abzielt.
    2. Erstelle einen Locator für den Root der aufzurufenden Unterseite. Zielt hier auf die ID dieses Roots.
    3. Klicke auf den Link.
    4. Erwarte, dass der Root der aufzurufenden Unterseite angezeigt wird.
  3. Tests, ob die beispielhafte TODO-Liste genau zwei Einträge enthält.
    1. Erstelle einen Locator anhand eines Selektors, der auf jedes gewünschte Ziel zutrifft. Laut Template hat jeder Listeneintrag ein div als Root, welches die Klasse list-group-item besitzt. Bietet sich also als Selector an.
    2. element.all sorgt dafür, dass alle Elemente, welche der Locator aufspüren kann, eingesammelt werden.
  4. Der Test bzgl. der Vorhandenseins der Kontrollelemente sollte entsprechend klar sein.
  5. Teste, ob der Button initial gesperrt ist – was sinnvoll ist, da noch keine Information zum Task eingegeben wurde.
    1. Den Button via ID heraussuchen.
    2. Das aria-disabled Attribut abgleichen (falls ARIA kein Begriff sein sollte – siehe diese Dokumentation). Die Integration von ARIA-Informationen auf Angular-Basis wurde hier mittels ng-aria automatisiert.
  6. Teste, ob die Textarea initial als invalide markiert ist.
    1. Die Textarea via Tagname heraussuchen.
    2. Das aria-invalid Attribut abgleichen.

Anhand der hier sichtbaren Expectations wird deutlich, dass die gewünschte Approximation des Benutzerverhaltens nur mit ein paar Abstrichen möglich ist. Oder würde ein Nutzer direkt den DOM betrachten, um das Attribut auszulesen, anstelle der Betrachtung des Elements selbst? Eher nicht. Für einen erfolgreichen Test ist hier entscheidend, dass sich die Anforderungen bzgl. der Darstellung und ihres Verhaltens auf technisch klar feststellbare Aspekte abbilden lassen. Bei der Frage, ob ein Element dargestellt wird, ist dies noch trivial – es ist sichtbar, oder eben nicht (strenggenommen muss auch hierfür erst die Anwesenheit im DOM und dann der entsprechende Stylesheet geprüft werden, aber die verfügbare API ist überschaubarer). Will man nun aber testen, ob bspw. wie oben ein Element gesperrt ist, ist der Weg über die angehängten Attribute der technisch einzig sauber umsetzbare. Natürlich kann man auch eine Interaktion mit dem Element starten, um zu testen, ob eine Reaktion erfolgt – was aber ein deutlich komplexeres Problem aufwirft: Wann geht man davon aus, dass nichts mehr passiert? Passiert wirklich nichts, oder hängt nur die Oberfläche und die Reaktion erfolgt verzögert? Für diesen sowie ähnlich gelagerte Fälle ergibt sich hier entsprechend ein Definitionsproblem, was sich nur schwer bis gar nicht lösen lässt. Daher ist die genannte Abbildbarkeit entscheidend, welche hier bspw. wie folgt aussehen könnte:

  • aria-disabled=“true“ <=> Keine Interaktion mit Element möglich
  • aria-invalid=“true“ <=> Element-Inhalt ist invalide, ggf. sind dafür definierte Styles gesetzt
  • todoPageRoot ist sichtbar <=> TODO-Page wird angezeigt

Nur wenn solche Abbildungen möglich sind, kann ein entsprechender Test auch automatisiert werden.

Ein weiterer Test zeigt beispielhaft den Einsatz von by.binding:

it('should display tasks with descriptions "First task" and "Second task"', () => {
  let taskNames = element.all(by.binding('todo.description'));
 
  expect(taskNames.first().getText()).toEqual("First task");
  expect(taskNames.last().getText()).toEqual("Second task");
});

Da element.all, wie man es auch von jQuery- oder Document-Selektoren gewöhnt ist, die Elemente in derselben Reihenfolge zurückliefert, in welcher sie auch im DOM dargestellt werden, kann selbige hier vorausgesetzt werden. by.binding zielt hier den folgenden DOM-Knoten:

<div>{{todo.description}}</div>

In diesem Fall wäre auch die Nutzung von by.exactBinding möglich gewesen. Während der an by.binding gegebene Selektor auch auf einen beliebigen Teil des Namens des möglichen Ziels zutreffen darf, fordert by.exactBinding die exakte Identität. by.binding hat vor allem Vorteile, wenn die gebundenen Werte noch durch Filter geschickt werden, aber man diese nicht alle mit den dazugehörigen Parametern auflisten will.

Im nächsten Schritt testen wir die gewünschte Zielfunktionalität – das korrekte Anlegen und Entfernen eines TODO-Tasks.

Für das Anlegen eines Tasks ist die Interaktion mit der Textarea, dem Datepicker und dem Submitbutton erforderlich.

describe('Functionality', () => {
  it("should correctly add a new task", () => {
    let inputElem = element(by.id('task-until-input')),
      todayEntry = element.all(by.className('_720kb-datepicker-active'))
        .filter((elem) => elem.getAttribute('ng-click').then((attr) => {
          return /datepickerDay/i.test(attr);
        })),
      textarea = element(by.tagName('textarea')),
      submitButton = element(by.id('create-task-button')),
      todoEntries = element.all(by.className('list-group-item')),
      mostRecentEntry = todoEntries.last().element(by.binding('todo.description'));
 
    let testTaskName = "Testing task";
 
    inputElem.click();
    todayEntry.first().click();
    textarea.sendKeys(testTaskName);
    submitButton.click();
 
    expect(todoEntries.count()).toEqual(3);
    expect(mostRecentEntry.getText()).toEqual(testTaskName);
  });
});

Das sieht auf den ersten Blick etwas wüster aus, als es letztlich ist:

  • Der Datepicker wird über ein Input-Element getriggert, folglich muss dieses herausgesucht werden, um damit interagieren zu können.
  • Der Eintrag für „heute“ im Datepicker ist etwas kniffliger: Initial ist der Eintrag für den aktuellen Tag mit der o.g. _720kb-datepicker-active Klasse versehen. Dies trifft allerdings auch auf den Jahresselektor (Dropdown) zu. Allerdings enthält nur der Eintrag für den Tag ein ng-click Attribut, dessen Wert datepickerDay beinhaltet. Daher kann auf dieser Basis gefiltert werden. Das Resultat dieses Filtervorgangs ist eine Liste (in Form eines ElementArrayFinders), die nach unserem Kenntnisstand genau ein Element enthalten sollte.
  • Für den neu zu erstellenden Eintrag wird hier zuletzt schon einmal ein Locator zwecks späterem Ansprechen angelegt (mostRecentEntry). Dafür wird auf die Suche nach einem Element unterhalb eines bereits herausgesuchten zurückgegriffen: Ein ElementFinder, welcher über einen Locator definiert wird, ermöglicht den successiven Aufruf von .element und .all für die gezielte Selektion von Elementen in einem Unterbaum.

Die Interaktion läuft dann wie folgt ab:

  • Der Datepicker wird via Klick auf das zugehörige Input-Element geöffnet.
  • Der Eintrag des aktuellen Tages wird im Datepicker angeklickt.
  • Der Textarea wird unsere Test-Beschreibung geschickt. Hier könnten auch spezielle Keys verwendet werden, soweit sie von WebDriverJs unterstützt werden. Die unterstützte Liste hängt an der globalen Variablen protractor.Key an.
  • Der Bestätigungsbutton wird geklickt.

Erwartet wird dann, dass ein neuer Task am Ende der Liste angelegt wurde (und die Liste somit drei Elemente enthält), welcher die eingegebene Beschreibung beinhaltet.

Das Entfernen eines Tasks ist dann wieder vergleichsweise trivial:

it('should correctly remove a task from the list', () => {
  let removeIcons = element.all(by.className('remove-item')),
    todoEntries = element.all(by.className('list-group-item')),
    taskDescriptions = todoEntries.all(by.binding('todo.description'));
 
  removeIcons.get(1).click();
 
  expect(todoEntries.count()).toEqual(2);
  expect(taskDescriptions.first().getText()).toEqual("First task");
  expect(taskDescriptions.last().getText()).toEqual("Testing task");
});

Das einzig neue ist der Aufruf von .all auf einem ElementArrayFinder todoEntries. Dies bewirkt die Abbildung jedes enthaltenen ElementFinders mittels des angegebenen Locators. Bedeutet hier: In jedem Eintrag der Liste wird nach dem Element mit dem angegebenen Binding gesucht, sodass der resultierende ElementArrayFinder letztlich eben diese Elemente umfasst.

Der gesamte Test mit allen Einträgen kann unter diesem Tag gefunden werden.

Da der oben gezeigte Code mittlerweile etwas wüst aussieht, wird sich der nächste Teil der Reihe vor allem mit den Strukturierungsmöglichkeiten beschäftigen, die sich in diesem Kontext anbieten.

Teilen Sie diesen Beitrag

Das könnte dich auch interessieren …

3 Antworten

  1. Jonas Kilian sagt:

    Ja, wir hatten nur eine einfache Webdriver-Anbindung in FitNesse, für Singlepage Apps sicher nicht optimal. Theoretisch müsste man in FitNesse aber auch Testbausteine mit Protractor erstellen können, die man dann den Testern zur Verfügung stellt, so dass die Nutzung von Protractor nicht den Entwicklern vorbehalten bleibt, oder?

    • Christian Linne sagt:

      Naja, Protractor nutzt letztlich auch „nur“ die JS-Bindings für die Webdriver-Ansteuerung via Selenium. Ob man FitNesse in irgendeiner Form damit zusammenbringen könnte… soweit ich gesehen habe, ging das nur mit den Java-Bindings, da die erstellen Specs auf die zugehörige Reflection setzen. Strukturell entsprechen die dort genutzten Bausteine am ehesten Page Objects und Page Components (dazu komme ich in Teil 2 noch).
      Weiterhin müsste man sich dann fragen, warum man diesen Umweg gehen wollen würde – die Jasmine-Syntax entspricht schon recht nahe sprachlichem Ausdruck und ist damit weit weg von Sonderwegen wie spezieller Markup-Syntax zur Definition von Tests. Die Lernkurve ist überschaubar, ggf. kann man auch auf Cucumber.js setzen, wenn einem das lieber sein sollte.
      Der primäre Vorteil liegt hier doch eigentlich darin, dass man weder Entwickler noch Tester beim Schreiben von Tests außen vor lässt – Tester können die Syntax in kurzer Zeit lernen (wenn sie sie nicht ohnehin schon können), Entwickler können sie in der Regel schon, weil sie bei Unit-Tests im Frontend gängig ist (neben Jasmine z.B. auch bei Mocha). Und die Semantik ist soweit gut dokumentiert, wenn sie noch nicht ohnehin schon intuitiv ist.
      Vergleichbare Umstände findet man sonst halt v.a. bei Watir (rspec-Syntax) oder Geb (Spock-Syntax), deren einziger „Haken“ im Vergleich zu Protractor die fehlende Angular-Integration in den Testrunner ist. Wäre das nicht der Fall, würde ich sogar einen der beiden empfehlen, weil die von ihnen genutzten Bindings doch etwas stabiler sind als die JS-Variante.

Schreibe einen Kommentar

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