Das JavaScript-Ökosystem gilt als ein bewegtes Umfeld. Testwerkzeuge sind hier keine Ausnahme. Grund genug also, einen Blick auf die aktuell verfügbaren Werkzeuge und ihren Einsatzzweck zu werfen. Der vorliegende Artikel gibt einen Überblick über eine Reihe von Werkzeugen für das JavaScript Testing, wie sie aktuell in Projekten zum Einsatz kommen. Damit lässt sich die eine oder andere Testlücke schließen und eine solide Teststrategie für das eigene Projekt entwerfen.

Die Testpyramide

Als Orientierung kann die klassische Testpyramide dienen. Die ursprüngliche Idee war es, verschiedene Arten von Tests nach Häufigkeit zu klassifizieren. So ist es normalerweise eine gute Idee, viele Unit-Tests und nur wenige End-to-End-Tests zu verwenden. Eine schnelle Internetsuche fördert jedoch unzählige Varianten der Testpyramide zu Tage.

Abbildung JavaScript Testing - Varianten von Testpyramiden

Varianten von Testpyramiden – Inspiriert von David Völkel https://www.slideshare.net/davidvoelkel/unit-vs-integration-tests

Tatsächlich lässt es sich kaum noch verallgemeinern, welche Tests in welcher Menge sinnvoll sind.

In der Praxis spielen viele Faktoren eine Rolle: z.B. die Größe und Zusammensetzung des Teams oder die Architektur der Software. Klassische Roca-Anwendungen lassen sich beispielsweise ganz anders testen als eine React-Single Page App, der Jamstack oder auf Node.js basierende Microservices.

Dennoch gilt es nach wie vor, Laufzeit und Wartungskosten der Tests zu berücksichtigen. Muss das Team zu lange auf Testergebnisse warten, oder zieht die Implementierung eines neuen Features tagelanges Reparieren von Testfällen nach sich, geht der angestrebte Nutzwert eines Testwerkzeuges schnell verloren. Einige wartungsintensive End-to-End-Tests, die die Anwendung wie ein Benutzer “von außen” bedienen, sind verschmerzbar, aber es sollten nicht zu viele werden. Hier kommt das Tooling ins Spiel – es gilt, das richtige Werkzeug auf der richtigen Ebene der Testpyramide einzusetzen.

Unit-Tests

Wir starten am unteren Ende der Testpyramide, bei den Unit-Tests. Die beiden Platzhirsche sind hier:

Es gibt auch noch einige weitere, die in der Praxis aber kaum (mehr) Verwendung finden. Einen Blick auf die bekannte JavaScript-Studie State-of-JS 2019 zeigt, dass 61 Prozent der Befragten Jest benutzt haben und das auch gerne wieder tun. Mocha folgt knapp dahinter mit 42 Prozent. Die Historie zeigt, dass Jest sich in wenigen Jahren vom Newcomer zum beliebtesten Testwerkzeug hin entwickelt hat.

Jahr der Studie “State of JS” Jasmine Mocha Jest
2016 41% 50% 6%
2017 41% 49% 25%
2018 29% 40% 40%
2019 28,6% 42% 61%

 

Abbildung JavaScript Testing - Beliebte Testframeworks

Facebooks Jest ist ursprünglich als Fork aus Pivotals Jasmine hervorgegangen und hatte anfangs mit einigen Problemen zu kämpfen, die mittlerweile aber behoben sind, wie z.B. fragwürdigen Standardeinstellungen.

Jest und Mocha sind BDD-Frameworks

Jest und Mocha haben viele Gemeinsamkeiten und einige Unterschiede. Beide verwirklichen den ursprünglichen Behaviour-Driven Development-Ansatz von Dan North. Es geht dabei darum, dass Tests im Grunde ausführbare Spezifikationen sind. Insbesondere ergibt es Sinn, Spezifikationen vor der Implementierung zu schreiben und nicht wie klassische Tests erst danach. Um diesen Ansatz zu ermöglichen, bieten die beiden Werkzeuge eine Domain Specific Language (DSL) an, die die Begriffe describe und it benutzt, um gut lesbare Testnamen bzw. Spezifikationen zu schreiben. Die Idee einer sogenannten “internal DSL” ist es, mit Sprachmitteln die Sprache selbst zu erweitern, um damit domänenspezifische Konzepte abzubilden. Die Teststruktur, die describe und it bietet, sieht auf den ersten Blick wie ein Sprachkonstrukt aus. Tatsächlich handelt es sich dabei aber einfach um Funktionen, die wiederum anonyme Funktionen als Argumente entgegennehmen.

Gleichzeitig ist es wichtig, dass die eigentlichen Testnamen freie Satzformulierungen mit Leerzeichen ermöglichen und nicht etwa wie bei klassischen Testframeworks Methoden sind, die mit “test” beginnen. Es folgt ein Beispiel inspiriert von einem Vortrag von Kevlin Henney.

Die Aufgabe besteht darin, eine Funktion isLeapYear zu schreiben, die prüft, ob es sich bei einer Jahreszahl um ein Schaltjahr handelt. Ein klassischer, unbedarfter Ansatz hätte vielleicht verschiedene Varianten in einem einzigen Test namens

testIsLeapYear() 

abgehandelt. Besser sind allerdings vier verschiedene Tests (oder besser Specs), die die einzelnen Aspekte der Schaltjahr-Prüfung beschreiben:

  • A year is a leap year if it is divisible by 4 but not by 100
  • A year is a leap year if it is divisible by 400
  • A year is NOT a leap year if it is not divisible by 4
  • A year is NOT a leap year if it is divisible by 100 but not by 400

In Jest lässt sich das dann so ausdrücken:

Das Starten von Jest mittels npx jest –verbose erzeugt folgende Ausgabe:

Abbildung JavaScript Testing - Das Starten von Jest mittels npx jest

Interessant dabei ist die Möglichkeit, describe-Blöcke zu verschachteln. Gibt es kein sinnvolles describe, darf es entfallen. Falls sich übrigens mit it keine sinnvollen Sätze bilden lassen, ist es auch möglich, auf ein klassisches test zurückzugreifen. Die beiden Funktionen test und it sind dabei nur Synonyme.

Mocha vs Jest

Wie unterscheiden sich nun Mocha und Jest? Beiden Testframeworks liegen unterschiedliche Philosophien zugrunde. Mocha verfolgt den leichtgewichtigen Ansatz, möglichst wenig Erweiterungen mitzubringen (Mocking, Assertion-Frameworks, etc.), während Jest die “Batteries-included”-Strategie realisiert und für praktisch alle Aspekte des Testens bereits entsprechende Umsetzungen mitbringt. Beide Ansätze haben ihre spezifischen Vorteile. In vielen Projekten ist Jest die bevorzugte Wahl. Es ermöglicht einen schnellen Start und vermeidet Diskussion im Team – beispielsweise welches Mocking-Framework am besten geeignet ist.

Parameterized Testing

Jest bringt außerdem viele nützliche Features mit, wie beispielsweise

  • Parameterized Testing
  • Hervorragendes Error-Reporting
  • Einen Runner, der Tests filtern kann
  • Gut gewählte Assertions
  • Spys

Parameterized Testing hilft, duplizierten Testcode zu vermeiden, falls der gleiche Test mit unterschiedlichen Werten ausgeführt werden soll. Im Schaltjahr-Beispiel kann es hilfreich sein, mehrere Jahreszahlen für die gleiche Regel zu prüfen – das ist möglich dank it.each.

each durchläuft alle Werte im Array und injiziert sie als Parameter in den Test. Mithilfe von Platzhaltern wie %i (für Integer) oder %s (für Strings) lassen sich die Beispielwerte auch in der Ausgabe des Testnamens verwenden:

Abbildung04 Assertion Frameworks

Assertion Frameworks und Matcher

Wird Jest verwendet, so stellt sich selten die Frage nach einem spezifischen Assertion-Framework – die mitgelieferten Assertions (oder besser Expectations, nach dem BDD-Wording oder auch schlicht Matcher) reichen in vielen Fällen aus. So bringt Jest neben dem universellen toEqual auch Matcher für viele Standardfälle mit, z.B.

  • toBeTruthy – matched neben true auch andere Werte, die JS als true interpretiert
  • toBeFalse – das Gegenstück
  • toBeCloseTo – für die Toleranzprüfung von Fließkomma-Zahlen
  • toBeGreaterThan, toBeGreaterThanOrEqual, toBeLessThan, toBeLessThanOrEqual – selbsterklärend
  • toMatch – für reguläre Ausdrücke
  • toMatchObject – für Objekt-Tiefenvergleich
  • toContain – ist ein Element in einem Array enthalten
  • toThrow – für Exceptions

… und noch einige mehr. Die richtige Assertion einzusetzen, statt immer nur toEqual und toBeTruthy, hilft dabei, bessere und aussagekräftige Meldungen im Fehlerfall zu erzeugen.

Wer noch einen Schritt weiter gehen möchte, kann einen Blick auf unexpected oder auf Power Assert werfen. Hier ein paar Beispiele für fehlgeschlagene Tests und ihre Fehlermeldungen:

Für Spezialfälle stehen dann oft noch spezifischere Assertion/Expectation-Libraries zur Verfügung. So lässt sich beispielsweise das State-Management von Redux-Anwendungen durch den Einsatz von expect-redux deutlich einfacher testen, als mit Standard-Matchern.

Spione und Stuntman

Das Thema Mocking und Stubbing ist leider zu umfangreich, um es im vorliegenden Artikel ausführlich zu behandeln. Grundsätzlich lässt sich aber feststellen, dass Jest bereits mit einer umfangreichen Integration glänzen kann.

Konzeptionell geht es dabei um sogenannte Test Doubles. Ein Test Double ist ein Ersatz für eine echte Implementierung, die in einem Test gerade nicht mitgetestet werden soll – wie ein Stuntman, der einen Schauspieler doubelt. Test Doubles gliedern sich in Mocks, Stubs, Spys, Fakes und Dummies – vereinfacht ist oft immer nur von Mocks die Rede. Mehr zu den Hintergründen lässt sich in Martin Fowlers Mocks Aren’t Stubs oder bei den xUnit-Patterns von Gerard Meszaros nachlesen. Jede Art von Test Double lässt sich mit Hilfe von Jest-Spies abbilden. Dadurch ist eine Isolierung des zu testenden Codes gegenüber seinen Abhängigkeiten möglich, und der Unit-Test wird nicht versehentlich zum Integration-Test. Reichen die mitgelieferten Möglichkeiten von Jest nicht aus, oder wird eine Lösung für Mocha benötigt, hilft das etablierte Sinon.JS weiter. Auf das Mocking des Network-Layers hat sich dagegen Mock Service Worker spezialisiert.

Units in der Oberfläche

Ein wichtiger Bestandteil einer modernen Teststrategie für Webanwendungen sind Tests für Oberflächenbestandteile: UI-Unit-Tests. In der Testpyramide standen UI-Tests ursprünglich an der Spitze. Gemeint waren dort aber eher End-to-End-Tests, die den kompletten Stack einer Anwendung bis hin zur Datenbank aus Sicht des Anwenders durch die UI hindurch betrachteten. In gut-designten Web-Architekturen ist es aber auch möglich, einzelne Teile der UI losgelöst von der Domänen-Logik zu testen.

Eine Unit der UI ist im Gegensatz zu einer Unit der Applikationslogik keine Funktion oder Klasse. Was das genau bedeutet, hängt wiederum vom eingesetzten UI-Framework ab. Zu jQuery-Zeiten waren das beispielsweise isolierte Widgets, wie ein selbstentwickelter Date-Picker. Bei modernen React-Anwendungen ist die Unit eine React-Komponente.

Als Browserunterschiede noch eine große Rolle spielten, war Karma für UI-Tests weit verbreitet. Karma macht es möglich, die gleichen Tests in verschiedenen realen Browsern  auszuführen. Abgesehen von Spezialfällen hat sich in diesem Bereich aber mittlerweile  jsdom etabliert. jsdom simuliert das DOM und andere Teile der Browser-API in Node.js. Damit lassen sich UI-Tests auch ganz ohne Browser durchführen. Der Vorteil ist ein massiver Geschwindigkeitsgewinn bei der Testausführung. Gerade für testgetriebene Entwicklung (TDD) ist ein schneller Feedback-Zyklus unerlässlich.

Glücklicherweise ist es nicht notwendig, jsdom selbst zu installieren und zu konfigurieren. Jest bringt es bereits mit. Auf reiner Jest/jsdom-Basis wäre dennoch einiges an manuellem Aufwand zu investieren, um gut lesbare UI-Test zu schreiben. Abhilfe schaffen hier die beiden Testwerkzeuge

Wie schon erwähnt, gibt es eine Abhängigkeit zum eingesetzten UI-Framework. Enzyme unterstützt React, Preact und Inferno, Testing Library neben React und Preact auch ReasonReact, Vue, Svelte, React-Native und einige andere.

Um UI-Tests sinnvoll zu betrachten, benötigen wir eine passende Anwendung. Es folgt eine vereinfachte (und unvollständige) Tic-Tac-Toe-Implementierung.

Abbildung JavaScript Testing - Tic Tac Toe

Der Domain Code hat keinerlei Abhängigkeiten nach außen, d.h. hier zum UI-Code. Das ist eine Anforderung, die z.B. eine Hexagonale oder eine Onion-Architektur formuliert, beides Konzepte, die Domain Driven Design-Projekte (DDD) gerne verwenden. Die UI setzt auf dem Domain Code auf und besteht aus den Komponenten Board und Cell. Das Board besteht dann je nach Feldgröße des Spiels aus mehreren Komponenten Cell.

Der Code soll dabei übrigens nur zur Illustration der Test-Beispiele dienen und nicht etwa als vorbildliche Implementierung von Tic Tac Toe.

Shallow Rendering mit Enzyme

Was Enzyme nun gerade für UI-Unit-Tests interessant macht, ist das sogenannte Shallow Rendering. Damit lassen sich React-Komponenten (oder Preact, oder Inferno) im DOM rendern, ohne alle Komponenten bis auf ihre HTML-Bestandteile aufzulösen. Damit lässt sich hier z.B. das Board auf Basis der Cells testen statt den darunter liegenden Buttons:

Der Test prüft, dass ein Board der Größe 4×4 tatsächlich aus 16 Cells besteht. Damit darf sich die Implementierung der Cell-Komponente jederzeit ändern, ohne dass die Board-Tests fehlschlagen. Benutzt die Cell-Komponente z.B. später ein <div>-Element statt des <button>-Elements, sind keine Anpassungen an Board-Tests nötig. Das ist eine wesentliche Eigenschaft, um die leichte Wartbarkeit von Unit-Tests sicherzustellen. Eine weitere Verbesserung wäre, die Abhängigkeit der Board-Komponente zum Domain-Code (hier: der Klasse TicTacToe) aufzutrennen, z.B. mit Hilfe eine Stubs/Jest-Spys.

Um den Unterschied zwischen Shallow und Deep Rendering weiter zu verdeutlichen, lässt sich der Test (nach Zeile 3) um eine Debug-Anweisung erweitern:

console.log(wrapper.debug());

Hier die Ausgabe mit Shallow Rendering:

Abbildung JavaScript Testing - Ausgabe mit Shallow Rendering

Ein Austausch von shallow gegen mount sorgt für ein Deep Rendering der Board-Komponente:

Abbildung JavaScript Testing - Deep Rendering der Board-Komponente

Das Deep Rendering zeigt die tatsächlichen DOM-Elemente, hier: button-Tags. Die Cell-Komponenten, die die Board-Komponente enthält, bestehen wiederum aus den HTML-Button-Elementen.

Testing Library

Die Testing Library von Kent C. Dodds ist leichtgewichtiger als Enzyme. Sie bietet bewusst keine Funktionalität zur Prüfung von State oder Props von React-Komponenten – auch keine Möglichkeit zum Shallow Rendering. Stattdessen ist die Philosophie dieser Bibliothek, eine Komponente immer im Ganzen zu betrachten und dabei nur das resultierende DOM zu prüfen. Hilfsfunktionen wie getByText oder getByAllTestId bieten willkommene Abkürzungen zur manuellen Navigation im DOM.

Um beispielsweise den obigen Test von Enzyme in Test Library zu übertragen, lässt sich der Button aus der Cell-Komponente um data-testid=”cell” erweitern. Nun ist der Zugriff über die testid möglich:

In diesem kleinen Beispiel ist der Vorteil kaum sichtbar. In einer komplexen Situation aber erspart das vorherige Markieren per testid eine aufwendige Navigation durch das DOM und macht die Tests robuster. “Robuster” bedeutet, dass Änderungen in der DOM-Struktur kein Nachpflegen der Tests erzwingen – vorausgesetzt die Markierung mit testid bleibt erhalten.

Die Qual der Wahl

Welche der beiden Libraries nun besser für UI-Unit-Tests geeignet ist, ist nicht einfach zu beantworten. Das leichtgewichtige und einfache Konzept der Testing Library hilft sicherlich, allzu komplexe Tests zu vermeiden. Andererseits ist Shallow Rendering hervorragend für das isolierte Testen einzelner Komponenten geeignet, die sich in einer komplexen Hierarchie befinden. Die Entscheidung ist, wie so oft, kontext-abhängig. Selbst beide Varianten in der gleichen Test-Suite zu verwenden, ist kein völlig abwegiger Gedanke.

Service-/Komponententests

Auf der mittleren Ebene der klassischen Testpyramide finden sich die Servicetests. Abhängig von Architektur kann es sich dabei um interne Pakete/Module oder Services im Netzwerk handeln. Beides lässt sich technisch mit Bordmitteln eines Unit-Test-Frameworks wie Jest oder Mocha abbilden. Im Netzwerkfall ist zusätzlich noch die entsprechende Bibliothek erforderlich, die das Projekt auch im Implementierungscode einsetzt, z.B. Axios für Rest oder Apollo bei GraphQL. Es gibt aber auch spezialisierte Frameworks, die sich in Jest/Mocha einklinken lassen. Hier ein Beispiel für den Einsatz von Supertest zum Testen einer HTTP-Schnittstelle:

End-to-End-Tests

Am oberen Ende der Testpyramide befinden sich die sogenannten End-to-End-Tests. Wie schon erwähnt, ist das Ziel eines solchen Tests, den kompletten Stack einer Anwendung, von der UI bis hin zur Datenbank (oder sogar externen Diensten), zu betrachteten – von einem Ende zum anderen Ende. Dazu schaut sich das Test-Tool die Anwendung aus Sicht eines Anwenders an, klickt auf Links, betätigt Buttons und füllt Eingabefelder aus.

Moderne Werkzeuge für End-to-End-Testing im JavaScript-Umfeld sind z.B.

Diese Tools lösen ältere auf Selenium basierende Testwerkzeuge ab. Im Moment etabliert sich Cypress als Standardlösung. Cypress unterscheidet sich massiv von Selenium durch seine Architektur. So hatten frühere Lösungen oft Performance-Probleme. Selenium testet den Browser “von außen”, während Cypress in der gleichen Umgebung wie die Anwendung läuft. Damit kann Cypress beispielsweise das Auftauchen neuer Elemente oder den Wechsel zu einer anderen Route in einer Single Page App automatisch erkennen und darauf warten. Ständiges Pollen und waitFor-Funktionen (aka async hell) sind nicht mehr nötig.

Weitere Vorteile sind:

  • Einfache Installation/Einrichtung.
  • Einfache Verwendung als CLI-Tool im Continuous Integration Server mittels Headless-Electron.
  • Einfache Verwendung während der Entwicklung mit einem sehr guter Testrunner, der außerdem Time-Travel Debugging unterstützt.
  • Für End-to-End-Tests ungewohnt schnelle Testausführung.
  • Hoher Abstraktionslevel (DSL) zum Formulieren des Testcodes, der linear wirkt, den Cypress aber in Wirklichkeit asynchron ausführt.

Der hohe Abstraktionslevel und Erweiterungen wie Custom Commands führen dazu, dass Cypress-Specs für End-to-End-Tests verhältnismäßig wartungsfreundlich ausfallen. Allerdings hat der hohe Abstraktionsgrad auch seinen Preis und kann in speziellen Fällen das Testen erschweren.

Abbildung JavaScript Testing - Cypress beim Ausführen der Beispieltests

Um die Wartungsfreundlichkeit weiter zu erhöhen, bietet es sich an, das End-to-End-Konzept etwas aufzuweichen. So lässt sich das Setup eines Testszenarios statt über die GUI auch direkt über den Applikations-Code als App-Actions durchführen. Statt dass das Testframework zunächst mühsam wie ein Anwender Formulare ausfüllt, um einen bestimmten Zustand als Testvoraussetzung zu erzeugen, stellt der Setup-Code den Zustand über die interne API direkt her. Das eigentliche Testen erfolgt dann immer noch über die GUI. Die Wahrscheinlichkeit, dass der Test bricht, weil z.B. ein Designer eine Eingabemöglichkeit ändert, ist aber drastisch reduziert. Als kleiner Nebeneffekt ist der Test zudem noch deutlich schneller.

Eine weitere Möglichkeit besteht darin, den Netzwerkverkehr zu einem Backend durch Stubs zu ersetzen. Damit reduziert sich der End-To-End-Test zu einem leichter wartbaren UI-Integration-Test. Ein beliebtes Werkzeug für das Simulieren des Backends ist beispielsweise Stubby4Node.

Eine guter Einführung zum Thema Cypress findet sich in diesem Artikel.

JavaScript Testing – Fazit und Ausblick

Wie immer in der Softwareentwicklung gilt: Es gibt es keine Silberkugel, keine “beste” Zusammenstellung von Werkzeugen, die für jedes Projekt gleichermaßen gut funktionieren. Es gilt, die Testwerkzeuge und Teststrategie auf das eigene Projekt abzustimmen. Dabei hilft es, eine eigene Testpyramide zu konstruieren und sich Gedanken über das richtige Verhältnis der jeweiligen Testtypen zu machen.

Wer nun noch mehr tun möchte, als “nur” klassisch automatisiert zu testen, kann den Schritt Richtung BDD gehen und statt Tests tatsächlich ein Spezifikationskonzept in sein agiles Vorgehensmodell integrieren (siehe Specification by Example). Das Werkzeug, das dabei hilft, ist Cucumber.js, das sich auch hervorragend in Cypress integrieren lässt.

Eine andere Möglichkeit, eine Schippe drauf zu legen, ist, die Tests selbst zu testen. Hört sich verrückt an? Das Mutation Based Testing-Tool Stryker schickt eine Horde von Mutanten los, um den eigenen Code zu sabotieren (ein Minus wird gegen ein Plus ersetzt, ein if-else tauscht seine Zweige, eine Funktion verschwindet…). Bemerkt die eigene Testsuite diese Manipulation nicht, besteht Nachbesserungsbedarf!

Ich hoffe, dieser Artikel konnte ein paar Anregungen geben und zeigen, wie viele Möglichkeiten das JavaScript-Universum bietet, Qualität und Vorgehen zu verbessern, und wünsche viel Spaß beim Experimentieren und Konzipieren Ihrer eigenen, angepassten soliden Teststrategie.

 

 

Marco Emrich
Letzte Artikel von Marco Emrich (Alle anzeigen)

Schreibe einen Kommentar

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

Die von Ihnen hier erhobenen Daten werden von der Host Europe GmbH zur Veröffentlichung Ihres Beitrags in diesem Blog verarbeitet. Weitere Informationen entnehmen Sie bitte folgendem Link: www.hosteurope.de/AGB/Datenschutzerklaerung/