Im ersten Teil ging es vor allem um ein grundsätzliches Setup für eine benutzbare Testumgebung und die wichtigsten Grundlagen zur Erstellung von E2E-Tests mit Protractor. Schon am Ende des Einstiegs dürfte aufgefallen sein, dass etliche Code-Anteile redundant und die Tests an sich schon schwieriger hinsichtlich ihrer Intention zu verstehen sind. Daher soll es in diesem Teil vor allem darum gehen, welche Möglichkeiten es zur Strukturierung der Tests sowie der zugehörigen (Hilfs-)Mechanismen gibt und wie sie praktisch genutzt werden können. Außerdem werden einige erweiterte Mechanismen behandelt, die sowohl die Testbarkeit als auch den Kontrollfluss betreffen.
Allgemeine Teststruktur
Die Beispieltests haben ein Problem offenbart – die verwendeten ElementFinder werden zum einen mehrfach angelegt, zum anderen blähen sie den Test-Code auf und machen ihn damit unübersichtlicher. Weiterhin wird in den Tests selbst sehr viel Detailwissen über die in der Anwendung genutzte DOM-Struktur verlangt und hinterlegt, ohne dabei unmittelbar strukturellen und kontextuellen Bezug zur getesteten Seite herzustellen. Hieraus resultiert eine konzeptionelle Schwäche für die Nachvollziehbarkeit und damit die Erstellung bzw. Pflege von Tests. Die folgenden Abschnitte werden zeigen, wie man diesen Effekt abmildern oder vollständig negieren und ganz nebenbei auch einen höheren Grad an Wiederverwertbarkeit erreichen kann.
Zur Vorbereitung müssen einige Änderungen am Code vorgenommen werden.
- Da die noch kommenden Änderungen unter anderem ES6-Klassen beinhalten, muss die Konfiguration zur zwangsweisen Nutzung von Babel angepasst werden. Hierzu lediglich Zeile
require('babel-core/register');
in der angegebenen Datei am Ende der onPrepare Funktion hinzufügen.
- In den Root-Level describe-Aufrufen muss anstelle der Arrow-Notation die function-Notation verwendet werden, d.h.
() =>
wird zu
function ()
Hintergrund: Die Arrow-Notation bindet this aus dem umgebenden Context. Da dieser hier nicht existiert bzw. null ist, muss auf die function()-Notation zurückgegriffen werden.
Der Suite-Context
Seit Jasmine 2 werden Suites und und die darin befindlichen Specs mit einem speziellen Context-Object aufgerufen, welches über die this-Referenz verfügbar ist. Während dessen initialer Inhalt in den meisten Fällen eher uninteressant (weil meist leer) ist, bietet es die Möglichkeit, Parameter daran anzuhängen, welche dann in der jeweiligen Suite verfügbar sind. Dabei ist das Nesting zu beachten – die angehängten Parameter sind nur innerhalb einer Suite verfügbar. Wenn mehrere Suites zu einem Flow zusammengefasst werden und Parameter für den gesamten Flow verfügbar sein sollen, müssen sie entsprechend dort angehängt werden.
Zum Setup bieten sich die before*-Funktionen an, hier exemplarisch mit beforeAll repräsentiert:
beforeAll(() => { //... this.todoPage = new TodoPage(); });
Was der hier gezeigte Parameter TodoPage ist, wird der folgende Abschnitt zeigen.
Ziel der Nutzung dieses Contexts ist zum einen eine bessere Struktur der Test-Cases durch übersichtlichere bzw. sprechende Definitionen, zum anderen aber auch die Wiederverwertbarkeit solcher Initialisierungsschritte über mehrere Szenarien (Stichwort Function.call).
Page Objects
Page Objects beschreibt ein Pattern, bei dem die zu einer bestimmten Seite gehörenden ElementFinder unter Verwendung nachvollziehbarer Benennung und Hierarchiebildung in einer Klasse zusammengefasst und zur Verwendung im Test instanziiert werden. Im Rahmen des komplexeren Beispiels würde sich bspw. anbieten, ein solches Objekt für die Todo-Seite anzulegen.
export class TodoPage { constructor() { this.root = element(by.id('todo-page')); this.todoEntries = this.root.all(by.className('list-group-item')); this.todoEntries.descriptions = this.todoEntries.all(by.binding('todo.description')); this.todoEntries.mostRecentEntry = this.todoEntries.last().element(by.binding('todo.description')); this.todoEntries.removeIcons = this.todoEntries.all(by.className('remove-item')); this.newTodo = { textarea: this.root.element(by.tagName('textarea')), datepicker: this.root.element(by.tagName('datepicker')), submitButton: this.root.element(by.id('create-task-button')) }; this.newTodo.datepicker.inputElem = this.newTodo.datepicker.element(by.id('task-until-input')); this.newTodo.datepicker.todayEntry = this .newTodo .datepicker .all(by.className('_720kb-datepicker-active')) .filter((elem) => elem.getAttribute('ng-click').then((attr) => { return /datepickerDay/i.test(attr); })); } }
Die hier aufgelisteten ElementFinder entsprechen denen in der bisherigen todo.spec.js. Zusammengefasst wurden die Elemente für das Erstellen eines neuen Todos (this.newTodo) sowie für die Inspektion von bestehenden Tasks (taskNames, taskDescriptions und mostRecentEntry, removeIcons).
Page Objects können auch um navigierende Funktionalität erweitert werden. Dies bietet sich an, wenn ein oder mehrere Elemente einer Seite zu einer anderen Seite führen und für diese wiederum ein Page Object erzeugt werden müsste. So könnte bspw. der Klick auf ein Element im Header direkt ein Page Object für die Zielseite (z.B. die TodoPage) zurückliefern. Insbesondere bei den synchronen Bindings zur Ansteuerung, wie sie bspw. in den Java- oder Ruby-Bindings verwendet werden, können sich hier strukturelle Vorteile ergeben (Beispiel Java):
public class LoginPage { private final WebDriver driver; // Some initialization stuff ... public HomePage loginAs(String username, String password) { driver.findElement(usernameLocator).sendKeys(username); driver.findElement(passwordLocator).sendKeys(password); driver.findElement(loginButtonLocator).submit(); return new HomePage(driver); } }
Die Interaktions-Aufrufe sind im Java-Binding synchron, d.h. die Zeile return new HomePage(…) wird erst ausgeführt, nachdem die beiden sendKeys-Aufrufe sowie das submit abgeschlossen sind. Die von Protractor genutzten Bindings auf WebDriverJs-Basis verwenden einen asynchronen Ansatz, bei welchem mehr Vorsicht geboten ist, der aber auch Vorteile bzgl. Performance mitbringen kann. Sowohl die Expectations als auch die Specs können mit Promises umgehen, müssen dafür aber Zugriff auf selbige haben. Wird hier nicht die Abfolge beachtet, kann es zu Problemen in der Ausführung kommen, u.a. zu False Positives in den Expectations. Das o.g. Java-Beispiel müsste auch im Protractor-Zusammenhang ein neues Page Object liefern, ohne dabei auf noch offene Promises achten zu dürfen. Wenn nun der Test eine Expectation auf ein Element des zurückgelieferten Page Objects setzt, bevor die alte Seite verlassen wurde, fehlt das Element ggf. – womit der Test fehlschlagen würde. Hier müsste, falls das o.g. Muster angewendet werden soll, evtl. manuell gewartet werden. In unseren Testläufen sind solche Timing-Probleme vor allem unter Google Chrome und Internet Explorer aufgetreten, seltener unter Firefox. Daher haben wir oft darauf verzichtet, beim getesteten Seitenwechsel unmittelbar das neue Page Object zurückzugeben. Allerdings müssen Probleme dieser Art nicht zwangsläufig auftreten, sodass eine Evaluierung solcher Navigations-Strategien in jedem Projekt zu unterschiedlichen Ergebnissen führen könnte – daher der Rat, sie im Zweifelsfall selbst am jeweils konkreten Beispiel auszuprobieren. Der Vorteil dieser Asynchronität liegt vor allem in der resultierenden Last auf dem Test-Runner sowie erhöhter Robustheit gegenüber fehlschlagenden Aufrufen.
Page Objects werden idealerweise von den für die jeweiligen Subseite verantwortlichen Entwicklern bereitgestellt. Die darauf aufbauenden Tests sollten natürlich – ebenfalls idealerweise – durch andere Entwickler erstellt werden.
Welche Auswirkung die Einführung von Page Objects auf die Tests haben, wird der letzte Abschnitt dieses Kapitels noch zeigen.
Page Components
Page Components beschreibt ein vergleichbares Pattern, welches sich aber auf Komponenten bezieht und nicht auf ganze Seiten. Zu Komponenten zählen bspw. Header und Footer – welche selbst bei einer SPA nur selten ausgetauscht werden – oder Widgets. In unserem Fall würde es sich für die Todo-Spec anbieten, den Header als Komponente verfügbar zu haben.
export class HeaderComponent { constructor() { // `$` is a short-hander for `element(by.css(...))`. // `$$` would be the equivalent for `element.all(by.css(...))` this.todoPageRef = $('a[ui-sref="todo"]'); } }
Wie zu sehen ist, folgen Komponenten-Definitionen derselben Struktur wie Seiten-Definitionen, lediglich der Name lässt einen Rückschluss auf den eigentlichen Zweck zu. Page Components können entweder direkt in Tests verwendet werden oder als Teil eines Page Objects dienen, sofern selbiges sinnvoll in Komponenten aufgeteilt werden kann. Den größten Vorteil bieten Page Components, wenn die von ihnen referenzierten Komponenten auf mehreren zu testenden Unterseiten auftauchen.
Binding it all together …
Die Anwendung der o.g. Pattern und Zugriffsmuster wirkt sich dann wie folgt auf den Code des Tests aus (Auszug):
// ... beforeAll(() => { browser.get("/"); this.header = new HeaderComponent(); this.todoPage = new TodoPage(); }); // ... describe('Functionality', () => { beforeAll(() => { this.header.todoPageRef.click(); }); it("should correctly add a new task", () => { let testTaskName = "Testing task", newTodo = this.todoPage.newTodo; newTodo.datepicker.inputElem.click(); newTodo.datepicker.todayEntry.first().click(); newTodo.textarea.sendKeys(testTaskName); newTodo.submitButton.click(); expect(this.todoPage.todoEntries.count()).toEqual(3); expect(this.todoPage.todoEntries.mostRecentEntry.getText()).toEqual(testTaskName); }); it('should correctly remove a task from the list', () => { this.todoPage.todoEntries.removeIcons.get(1).click(); expect(this.todoPage.todoEntries.count()).toEqual(2); expect(this.todoPage.todoEntries.descriptions.first().getText()).toEqual("First task"); expect(this.todoPage.todoEntries.descriptions.last().getText()).toEqual("Testing task"); }); }); // ...
Die primären Auswirkungen sind hier die Verkürzung des Tests sowie die detailliertere Übersicht, mit welchen Elementen innerhalb der logischen Struktur jeweils konkret interagiert wird.
Im o.g. Beispiel wäre es zusätzlich möglich, die Schritte zur Erstellung eines neuen TODOs als Funktionalität dem Page Object hinzuzufügen – allerdings müsste man dafür einen Weg finden, die Auswahl des korrekten Elements im Datepicker zu generalisieren, was hinsichtlich dessen Komplexität keine triviale Aufgabe ist. Für solche und ähnliche Fälle muss man sich entsprechend von Fall zu Fall die Frage stellen, ob der nötige Aufwand in sinnvoller Relation zur Erleichterung im Test-Case steht.
Weitere Strukturmechanismen
Der Stand des Beispiels am Ende dieses Abschnitts ist unter diesem Tag zu finden.
Asynchrone Specs / Suites und die Verkettung von Aufrufen
Seit Jasmine 2 ist es möglich, bei einer Suite, Spec oder einer der zugehörigen Setup- bzw. Teardown-Funktionen (before/after*) ein Done-Callback entgegen zu nehmen. In diesem Fall würde die weitere Ausführung durch das Test-Framework auf dessen Aufruf warten. Im Rahmen von Protractor kann man dieses Feature nutzen, wenn man innerhalb des Tests mit verschachtelten oder verketteten Promises arbeitet. Ein Beispiel hierfür sind Specs, bei denen der Zustand der Seite vor und nach einigen Änderungen abgeglichen werden soll.
it('should increase the amount of visible tasks by one if a new task was added', (done) => { this.todoPage.todoEntries.count().then((oldCount) => { this.todoPage.createTodoForToday('Another test task'); this.todoPage.todoEntries.count().then((newCount) => { // Expectation expect(newCount - oldCount).toEqual(1); // Cleanup this.todoPage.todoEntries.removeIcons.last().click().then(done); }); }); });
Dieser Test gleich die Anzahl der auf der Seite dargestellten TODOs vor und nach dem Hinzufügen eines Neuen ab und erwartet, dass die Differenz genau 1 entspricht. Da sämtliche Aufrufe in Richtung Webdriver, also auch die verwendeten count und click, Promises zurückgeben, können diese direkt via then verkettet werden. Die Spec wird erst als erledigt abgehakt, wenn der Aufruf an click auf dem letzten Eintrag von removeIcons durchgeführt und bestätigt wurde. Sollte dies nicht möglich sein oder der Aufruf fehlschlagen, verhindert das eingestellte Timeout (siehe Konfiguration) ihr unendliches Weiterlaufen. Eine Alternative wäre hier die Nutzung von thenCatch auf dem Promise, welches in anderen Implementierungen i.d.R. mit catch benannt ist, um den Fehler einzufangen. Hier müsste dann der Test manuell via Aufruf von done.fail(…) zum Fehlschlag gebracht werden (ist u.a. hier dokumentiert).
Expected Conditions
ExpectedConditions ist eine Hilfsbibliothek, welche insbesondere bei der (Vor-)Formulierung komplexerer Expectations hilfreich sein kann. Weiterhin hilft dies ein wenig bei der Kombination von Expectations via and bzw. or, welche in Jasmine sonst nur über eigene Matcher möglich wären. Die Funktionen sind prinzipiell sehr übersichtlich und gut dokumentiert, daher hier nur ein kleines Beispiel:
let button1 = $('#xyz'), button2 = $('#xya'), EC = protractor.ExpectedConditions; // without EC - two separate expectation, if you don't want to write your own matcher. expect(button1.isDisplayed()).toBeTruthy(); expect(button2.isDisplayed()).toBeTruthy(); // with EC - just a single one let bothVisible = EC.and(EC.visibilityOf(button1), EC.visibilityOf(button2)); expect(bothVisible()).toBeTruthy();
Hier ist die Kombination der Anforderungen bzgl. der Sichtbarkeit zweier Buttons zu sehen. Ohne EC (oder einen eigenen Matcher) müsste dieser Test aufgeteilt werden.
Das o.g. Muster, sich eine Variable EC anzulegen, welche protractor.ExpectedConditions als Wert hat, ist als Kurzschreibweise verbreitet und wird auch in der Dokumentation oft verwendet. Wichtig ist, dass niemals eine der Funktionen darin direkt durch eine Variable referenziert wird: In unseren Testläufen führte dies dazu, dass die referenzierte Funktion plötzlich undefined war. Warum genau ist auch bei genauerer Betrachtung nicht wirklich klar geworden – die Funktionen scheinen erst zur Laufzeit bereit zu stehen.
Auf etwas warten
Hin und wieder ist es notwendig, den Control-Flow des Test-Runners auszubremsen. Dies kann der Fall sein, wenn man bspw. asynchron Daten nachlädt, auf deren Basis neue Elemente in den DOM kommen oder bestehende geändert werden, oder wenn das Rendern einer Komponente bekanntermaßen etwas mehr Zeit in Anspruch nimmt. Dies gilt vor allem, wenn die Aktion nicht oder nur in Teilen von der Synchronisierung Angular <=> Protractor überwacht werden kann. Bspw. kann es hilfreich sein, auf die An- oder Abwesenheit eines Elements zu warten, oder auf dessen Sicht- bzw. Unsichtbarkeit.
Auf Basis des o.g. EC lässt sich hierfür sehr leicht ein nützlicher Helper implementieren:
let EC = protractor.ExpectedConditions, defaultTimeout = 20000; export default { presence: function (elem, timeout = defaultTimeout) { return browser.wait(EC.presenceOf(elem), timeout); }, absence: function (elem, timeout = defaultTimeout) { return browser.wait(EC.stalenessOf(elem), timeout); }, visible: function (elem, timeout = defaultTimeout) { return browser.wait(EC.visibilityOf(elem), timeout); }, invisible: function (elem, timeout = defaultTimeout) { return browser.wait(EC.invisibilityOf(elem), timeout); } }
Diesen Funktionen würde jeweils ein Element übergeben, auf welches gewartet werden soll.
Zum Verständnis: browser.wait erwartet entweder eine ohne Parameter aufrufbare Funktion oder ein Promise als Bedingung, sowie ein optionales Timeout für den Wartevorgang. Sowohl die Funktion als auch das Promise müssen in einem Wert resultieren, dessen boolsche Repräsentation zum Ausdruck bringt, ob weiterhin gewartet werden soll (false) oder die Bedingung erfüllt wurde und damit der Wartevorgang beendet werden kann (true). Für bedingungsloses Warten bietet sich browser.sleep an. Dessen Verwendung sollte allerdings die absolute Ausnahme bleiben, da die anzugebende Zeitmenge je nach Browser und Umgebung stark variieren kann und dementsprechend mehr oder weniger geraten wäre.
Was kann man jetzt mit dem o.g. Helper tun? Im Beispiel-Test-Case bietet sich an, nach dem Klick auf den Link zur TODO-Seite darauf zu warten, dass deren Page-Root angezeigt wird, d.h.:
import wait from '../helper/wait'; // ... it('should correctly move to the "TODO list" page', () => { this.header.todoPageRef.click(); wait.presence(this.todoPage.root); // <-- expect(this.todoPage.root.isDisplayed()).toBeTruthy(); });
Technisch steht dies hier hier für ein Warten auf den Abschluss des Seitenwechsels bis zur Integration des Roots der Zielseite in den DOM bis hin zu dessen Darstellung.
Der gezeigte Eingriff sieht an dieser Stelle wahrscheinlich erst einmal nur bedingt sinnvoll aus – schließlich funktionierte der Test auch ohne. Im konkreten Fall trifft dies auch zu, denn hier kommen keine komplexeren Wechselwirkungen mit Abrufen von Daten von einem Server oder die Erzeugung zusätzlicher (ggf. zahlreicher) Elemente via Direktiven zustande. Bei einer Anwendung realer Größe ist dies wiederum ein häufiger auftretendes Szenario. Ein Wartevorgang kann dabei sinnvoll sein, um zu verhindern, dass der Test-Runner versucht, eine Expectation auf ein Seitenelement zu setzen, welches noch gar nicht vorhanden oder unvollständig dargestellt ist. Wenn die Seite einen Ladespinner verwendet, kann es bspw. schon genügen, auf dessen Abwesenheit zu warten. Oder, allgemeiner ausgedrückt – bedingte Wartevorgänge sind prinzipiell überall sinnvoll…
- … wo die bereits angesprochenen Concurrency-Issues durch die Asynchronität des Control-Flows auftreten können.
- … wo Verzögerungen durch Interaktionen mit externen Schnittstellen erwartet werden können.
- … wo ggf. größere Element-Mengen (bspw. Direktiven in einem ng-repeat) neu gerendert und integriert werden müssen.
So lassen sich logische Synchronisierungspunkte als Ergänzung zu den – von Protractor gehandhabten – technischen setzen.
Mit den nun gelernten Techniken zur Strukturierung und Kontrolle von Tests können wir uns demnächst den Themen Headless- und Remote-Testing widmen.