Grünes Licht für korrekten Code: Testgetriebene Entwicklung mit JavaScript
Ihren Anfang nahm die testgetriebene Entwicklung in den 1960er Jahren im Mercury-Programm der NASA. Seitdem wurde diese Art der Software-Entwicklung in den verschiedensten Programmiersprachen adaptiert, bis sie schließlich auch in der Webentwicklung Einzug hielt. Nachdem die Client-Geräte stetig an Rechenleistung gewannen, verlagerte sich auch der Anteil der Applikationslogik in Richtung Client. Das hatte zur Folge, dass JavaScript als clientseitige Programmiersprache nicht nur für simple Dinge wie Formularvalidierung oder das Ein- und Ausblenden von HTML-Elementen verwendet wurde, sondern auch für vollwertige Applikationen, die ein höheres Maß an Qualität verlangen. Ein möglicher Ansatz dafür ist die testgetriebene Entwicklung.
Die Qualität steigern
Denn wenn JavaScript-Applikationen zunehmend businesskritisch werden, also auch in Bereichen zum Einsatz kommen, in denen ein finanzielles Risiko für ein Unternehmen besteht, kann die testgetriebene JavaScript-Entwicklung als eine besondere Form der Qualitätssicherung zum Einsatz kommen. Dadurch steigt der Anspruch an Qualität und die Methoden der Entwicklung und die eingesetzten Werkzeuge müssen entsprechend angepasst werden. Die testgetriebene Entwicklung kann also ein wichtiger Baustein sein, um diesen erhöhten Qualitätsanspruch an die Software zu erfüllen.
Ein weiterer, nicht sofort ersichtlicher Grund für testgetriebene Entwicklung ist, dass man sich dadurch mehr mit dem zu lösenden Problem auseinandersetzt: Die Anforderungen werden zunächst in einer Reihe von Tests beschrieben und dann erst durch die eigentliche Implementierung gelöst.
Von Rot nach Grün
„Test first“ – mit diesen zwei Worten lässt sich die testgetriebene Entwicklung ganz einfach beschreiben. Wird strikt testgetrieben entwickelt, gibt es keine Zeile Quellcode, für die nicht zuvor ein Test geschrieben wurde. Ganz so konsequent lässt sich diese Art der Entwicklung in der Realität aber meist nicht umsetzen, da pro Funktion nicht nur ein Test anfällt, sondern mindestens zwei: ein Positiv-Test und einer für den Fehlerfall. Bei der testgetriebenen Entwicklung bewegt man sich in einem Muster, das Red-Green-Refactor genannt wird.
- Red: Zunächst formuliert man einen Test. Zu diesem Zeitpunkt gibt es noch keine Implementierung. Bekannt ist lediglich die Anforderung an die zu entwickelnde Funktion. Der Test schlägt initial fehl.
- Green: Der nächste Schritt hat das Ziel, den Test erfolgreich ablaufen zu lassen. Idealerweise implementiert man nur das, was unbedingt für den Erfolg des Tests erforderlich ist.
- Refactor: Dieser Schritt dient zum Aufräumen. Duplikate im Code werden entfernt und nicht optimal implementierte Stellen mit laufenden Tests verbessert.
Dieser Zyklus wird quasi als Endlosschleife während der Entwicklung wiederholt.
Erste Schritte
Bevor die testgetriebene Entwicklung in JavaScript beginnen kann, muss zunächst die Umgebung dafür vorbereitet werden. Im Lauf der Zeit sind für JavaScript zahlreiche Testframeworks entstanden, die zum Einsatz kommen können, um testgetrieben Applikationen zu entwickeln. Weit verbreitet sind beispielsweise Jasmine oder QUnit. Eingebunden in eine HTML-Seite stellen sie Funktionen zur Verfügung, mit denen sich Tests formulieren lassen. Der Nachteil hierbei ist, dass die Tests direkt im Browser laufen müssen, was den Wechsel von der Entwicklungsumgebung in den Browser und ein Neuladen der Seite erforderlich macht. Zur Lösung dieses Problems bietet sich die Verwendung von zusätzlichen Infrastruktur-Frameworks wie beispielsweise Karma an. Die von ihnen bereitgestellte Serverkomponente erlaubt die Registrierung von verschiedenen Browsern für das anschließende Testen.
Zwischen dem Setup und dem ersten wirklichen Test liegt allerdings noch einiges an Arbeit. Zunächst muss das bereits erwähnte Problemverständnis geschaffen werden. Dafür ist es meist erforderlich, dass die Entwickler mit den Auftraggebern sprechen und eine Beschreibung des zu implementierenden Sachverhalts erhalten. Ist das Problem verstanden und existiert ein grobes Konzept zum Vorgehen, kann mit dem Einstieg in den Red-Green-Refactor-Zyklus begonnen und der erste Test erstellt werden.
Der folgende Abschnitt erläutert die testgetriebene Entwicklung anhand eines sehr einfachen Beispiels. Hierfür soll eine Funktion entstehen, die zwei Werte addiert und das Resultat zurückgibt.
Red
Der erste Schritt zu einem guten Test besteht darin, ein Problem zu formulieren, das dem Entwickler mehr Informationen über das zu testende System liefert. Im Falle des Beispiels ist dies der Aufruf der Funktion mit zwei Werten und die Prüfung auf einen bestimmten Rückgabewert. Dieser Test stellt sicher, dass die Funktion existiert und dass durch den Aufruf eine bestimmte Ausgabe erzeugt wird.
Je nach verwendetem Framework kann auf verschiedene Formen der Organisation von Tests zurückgegriffen werden. Für dieses Beispiel kommt Jasmine als Testframework zum Einsatz. Durch den Aufruf der Methode describe wird eine Gruppe von Tests erstellt, die nach Möglichkeit verschiedene Aspekte des gleichen Sachverhalts überprüfen. describe-Aufrufe lassen sich auch ineinander verschachteln, um komplexere Strukturen aufzubauen. Die eigentlichen Tests werden durch Aufrufe der it-Funktion erstellt. Wichtig bei der Formulierung von Tests ist immer, diese aussagekräftig zu benennen.
Red – ein Test mit Jasmine
describe('Calculator', function() { it('should add 1 and 1 and return 2', function() { var calculator = new Calculator(); var result = calculator.add(1, 1); expect(result).toBe(2); }); });
Listing 1
Ein Test sollte möglichst nach dem Triple-A-Muster aufgebaut sein. Dies gewährleistet, dass sämtliche Tests eine ähnliche Struktur aufweisen, was für eine bessere Lesbarkeit sorgt. Das erste A steht für Arrange. Im Beispiel wird an dieser Stelle die Umgebung vorbereitet und eine Instanz erzeugt. Das zweite A bedeutet Act, also die Ausführung des zu testenden Sachverhalts. Im dritten A, dem Assert, wird schließlich überprüft, ob das Ergebnis des zweiten Schritts mit der Erwartung übereinstimmt. Nachdem hier testgetrieben vorgegangen wird, schlägt der Test erwartungsgemäß fehl.
Green
Das Ziel im nächsten Schritt ist es, einen erfolgreichen Testlauf zu erreichen. Der Fokus liegt dabei nicht auf einer perfekten Implementierung, sondern auf einer möglichst einfachen. Dieses Vorgehen soll sicherstellen, dass sich der Entwickler auf das eigentliche Problem konzentriert. Ein einfaches Hilfsmittel ist es, sich an den Fehlermeldungen des Testlaufs abzuarbeiten. Im Falle des Beispiels bedeutet das, dass im ersten Schritt der Konstruktor Calculator erstellt werden muss, dann dem Prototyp die Funktion add hinzugefügt wird und diese schließlich den Wert 2 zurückgibt. Am Ende dieser drei Schritte steht der erfolgreiche Testlauf.
Green – Implementierung der zu testenden Funktion
function Calculator() {} Calculator.prototype.add = function() { return 2; };
Listing 2
Arbeitet man testgetrieben, werden immer wieder Muster angewandt, die so genannten Test-Patterns. Das sind Vorgehensweisen, die sich in der Vergangenheit bei verschiedenen Problemstellungen als erfolgreich herausgestellt haben. Eines dieser Muster trägt den Namen Baby Steps und bezeichnet möglichst kleine Schritte bei der Entwicklung. Sie stellen sicher, dass zwischen zwei Tests keine unkalkulierbaren Risiken liegen, die eine umfangreiche Fehlersuche erforderlich machen oder Raum für unentdeckte Fehler öffnen.
Im Gegensatz dazu steht das Muster der Obvious Implementation. Dieses besagt, dass für offensichtliche Implementierungen, wie es beispielsweise die Addition zweier Werte ist, kein Test erforderlich ist. Wann dieses Muster greift, muss der Entwickler individuell oder zusammen mit seinem Team entscheiden.
Refactor
Laufen alle Tests erfolgreich ab, ist es an der Zeit, den Quellcode zu verbessern. Einerseits durch die Reduktion von Duplikaten im Code, aber auch durch Einführen dynamischer Komponenten. Wichtig dabei ist zum einen, dass durch das Refactoring keine Tests fehlschlagen dürfen, und zum anderen, dass die Änderungen durch die bestehenden Tests abgedeckt sind und keine neue Funktionalität eingeführt wird. Für das Beispiel könnte dies bedeuten, einen berechneten Wert zurückzugeben oder die Generierung der Instanz im Test in eine beforeEach-Funktion auszulagern. So eine Funktion wird vor jedem Test der Gruppe ausgeführt:
Refactor – Umbau des Tests und der Implementierung
describe('Calculator', function() { var calculator; beforeEach(function() { calculator = new Calculator(); }); it('should add 1 and 1 and return 2', function() { var result = calculator.add(1, 1); expect(result).toBe(2); }); }); function Calculator() {} Calculator.prototype.add = function(a, b) { return a + b; };
Listing 3
TDD jenseits von Red-Green-Refactor
Wer schon einmal mit JavaScript entwickelt hat, weiß, dass eine Applikation leider nicht nur aus Funktionen besteht, die eine definierte Ein- und Ausgabe haben. Stattdessen muss man mit Callback-Funktionen und Asynchronität umgehen, Exceptions behandeln und auf den Ablauf einer Zeitspanne warten. All diese Fälle machen das Schreiben von Tests schwierig und damit eine testgetriebene Entwicklung zumindest umständlicher. Glücklicherweise gibt es für diese und zahlreiche weitere Problemstellungen bereits etablierte Lösungen, auf die man zurückgreifen kann.
Bei Callback-Funktionen – das sind Funktionsobjekte, die zu einem späteren Zeitpunkt, meist nach Beendigung einer bestimmten Aktion, aufgerufen werden – können so genannte Spy-Objekte die Verwendung aufzeichnen. Ein Spy ist nur einer von mehreren Test Doubles. Daneben gibt es noch Stubs, die wie reguläre Funktionen arbeiten, deren Verhalten sich allerdings programmieren lässt und die so eine stabile Umgebung für Tests schaffen. Mocks schließlich erlauben, eine bestimmte Verwendung zu prüfen.
Zum Umgang mit Asynchronität, also der zeitlich versetzten Ausführung von Funktionen, liefern die meisten Frameworks bereits Behandlungsroutinen mit. Ähnliches gilt für den Umgang mit Exceptions, die somit nicht zum Abbruch des Tests führen.
Ein ganz wichtiger Bereich beim Testen sind Abhängigkeiten. Dies gilt sowohl für zeitliche Abhängigkeit beim Ablauf von Timeouts und Intervallen als auch für die Kommunikation mit einem Server oder für die Abhängigkeit von einer HTML-Struktur. Frameworks wie Sinon.js bieten die Möglichkeit, die Browserzeit anzuhalten und vollständig zu kontrollieren. Auch Antworten eines Servers lassen sich damit ohne die erforderliche Serverinfrastruktur testen, indem die Antwort vorprogrammiert wird und die Anfrage niemals den Browser in Richtung Server verlässt. Für die HTML-Abhängigkeiten gibt es die so genannten Fixtures – das sind vorbereitete HTML-Strukturen, die für den Test geladen werden und eine definierte Umgebung darstellen.
Umgang mit Abhängigkeiten zum Server
it('should fetch the data and build the list with 5 items', function() { var server = sinon.fakeServer.create(); server.respondWith("GET", "/url/on/server", [200, { "Content-Type": "application/json" }, '{"data":["a","b","c","d","e"]}']); $('body').append('<ul id="itemList"></ul>'); fetchAndInsert(); server.respond(); expect($('#itemList li').length).toEqual(5); });
Listing 4
Mit diesen Hilfsmitteln lassen sich die verschiedensten Facetten einer JavaScript-Applikation testgetrieben entwickeln und sie tragen so zu einer stabileren Software bei.
Ausblick
Auch die Entwickler verschiedener Frameworks haben die Vorteile von Tests erkannt und achten bei der Konzeption und Programmierung der Frameworks darauf, dass die Software Tests ermöglicht und damit eine testgetriebene Entwicklung von Applikationen unterstützt. Ein populäres Beispiel hierfür ist AngularJS. Dieses Framework wurde mit dem Ziel entwickelt, eine gute Testbarkeit für alle Komponenten zu ermöglichen.
Sind die Tests dann erst einmal geschrieben, lassen sie sich auch problemlos in einer Continuous-Integration-Umgebung einsetzen, wo sie regelmäßig und automatisiert ausgeführt werden, was weiter zu einer stabilen und qualitativ hochwertigen Software beiträgt.