
Test-Driven-Design (TDD) ist ein heißes Thema und kaum ein Entwickler kommt mehr daran vorbei. War dieses Feld zunächst nur von Bedeutung in Projekten, die hoch-kritische Prozesse zum Gegenstand hatten, wie sie für Banken, Kernkraftwerke oder gar den Start einer Ariane-Rakete typisch sind, führte die Integration von TDD-Tools in populäre Entwicklungssysteme bald zu einer teilweise auch modischen Verbreitung.
TDD-Tools müssen nicht wirklich in jedem Projekt eingesetzt werden. Vor allem die reine Lehre führt schnell zu einem zusätzlichen Aufwand für das Schreiben von Tests, der nicht immer wirtschaftlich ist.
Die Theorie verlangt, dass vor jeder Funktion, die geschrieben werden soll, erst die zugehörigen Tests entwickelt werden. Dies erfordert viel Disziplin vom Entwickler, die Kenntnis hochentwickelter Testverfahren (Mocking) und das Vorhandensein von sehr viel zusätzlicher Entwicklerzeit. Der Ansatz verspricht, dass dieses Mehr an Entwickleraufwand später wieder hereingeholt wird durch sehr viel weniger Aufwand beim Debuggen.
In der Praxis wird heute aber auch ein weniger fundamentalistischer Ansatz akzeptiert, natürlich nur dann, wenn es nicht um hochkritische Projekte, wie einen Raketenstart, geht. Viele Entwickler haben sich zudem Gedanken gemacht, wie man entsprechende Tools komfortabel in den eigenen Workflow integrieren kann.
Nach einigem Probieren habe ich für mich für die Frontend-Entwicklung das Jasmine-Framework (Behavior driven Javascript) als Unit-Testing-Tool entdeckt, das per Grunt sehr einfach in den eigenen Workflow integrierbar ist.
Ich möchte im Folgenden den Einsatz ganz praktisch am Beispiel von zwei hier im Blog häufiger verwendeter Units (z.B. in unserem Three-Purpose-Grid-Projekt) vorstellen.
Installation und Parametrisierung
Wir installieren uns das Grunt-Modul grunt-contrib-jasmine mit dem npm-Installer in unserem Projektordner:
npm install grunt-contrib-jasmine --save-dev
Unser Gruntfile.js kann dann zum Beispiel so aussehen:
module.exports = function(grunt){ grunt.initConfig({ pkg: grunt.file.readJSON('package.json'), jasmine: { pivotal: { src: ['assets/js/mod/lib-util-whr-nrw-var-1-vers-0.5.0.js', 'assets/js/mod/lib-env-whr-nrw-var-1-vers-0.5.0.js' ], options: { specs: 'specs/*Spec.js', helpers: 'specs/helpers/*Helpers.js' } } }, }); grunt.loadNpmTasks('grunt-contrib-jasmine'); grunt.registerTask('utest', ['jasmine']); };
Zu testende Anwendungsmodule
Wir erkennen anhand der Parametrisierung, dass wir zwei Javascript-Sourcen evaluieren wollen, z.B. das Modul lib-util-whr-nrw-var-1-vers-0.5.0.js, das wir schon aus mehreren Artikeln kennen und das sich permanent weiterentwickelt.
var util = (function () { // define(function() { // needed for '_replaceAll' function escapeRegExp(string) { return string.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, "\\$1"); } function _replaceAll(string, find, replace) { return string.replace(new RegExp(escapeRegExp(find), 'g'), replace); } // e.g.: var S = new Object(); // S.val='Berlin-Wedding'; // var P=util.shiftWord('-', S); // => P='Berlin', S.val='Wedding' function _shiftWord(Separator, S){ var Q = ''; var P = S.val.indexOf(Separator, S.val); var L = Separator.length; if (P==-1) { Q = S.val; S.val = ''; return Q; } if (P==0) { S.val = S.val.substr(L); return ''; } Q = S.val.substr(0,P); S.val = S.val.substr(P+L); return Q; } function _timestamp() { var now = new Date(); var date = [ now.getFullYear(), now.getMonth() + 1, now.getDate()]; var time = [ now.getHours(), now.getMinutes(), now.getSeconds() ]; for ( var i = 0; i < 3; i++ ) { if ( time[i] < 10 ) { time[i] = "0" + time[i]; } } for ( var i = 0; i < 3; i++ ) { if ( date[i] < 10 ) { date[i] = "0" + date[i]; } } return date.join("") + "-" + time.join(""); } function _appendTrailingSlash(s) { var lastChar = s.substr(-1); if (lastChar !== '/') { s = s + '/'; } return s; } function _functionExists(name) { return typeof window[String(name)] === "function"; } return { replaceAll: function(string, find, replace) { return _replaceAll(string, find, replace); }, shiftWord: function(Separator, S) { return _shiftWord(Separator, S); }, timestamp: function(){ return _timestamp(); }, appendTrailingSlash: function(s) { return _appendTrailingSlash(s); }, functionExists: function(name) { return _functionExists(name); } }; // }); }()); console.log('lib.util..var-1...js');
Zu testendes Modul lib.util...var-1...js
Unter anderem haben wir es hier aufgesplittet. Die Funktionen, die sich auf die Anwendungsumgebung beziehen, z.B. ob eine Anwendung im Ripple-Emulator läuft, haben wir in ein neues Modul lib-env-whr-nrw-var-1-vers-0.5.0.js ausgelagert.
var env = (function () { // define(function() { function urlExists(url) { var http = new XMLHttpRequest(); http.open('HEAD', url, false); http.send(); // return http.status!=404; return http.status===200; } // Are we running in native app or in a browser? function _runsInNativeApp() { var res = false; if (document.URL.indexOf("http://") === -1 && document.URL.indexOf("https://") === -1) { res = true; } return res; } // Are we running in Ripple-emulator? function _runsInRippleEmu() { if (_runsInNativeApp()) { return false; } return urlExists('http://localhost:4400/ripple/assets/ripple.css')===true; } function _getQueryParameterByName(name, loc) { name = name.replace(/[\[]/, "\\[").replace(/[\]]/, "\\]"); var regex = new RegExp("[\\?&]" + name + "=([^&#]*)"); if (typeof loc === 'undefined') { results = regex.exec(location.search); } else { results = regex.exec(loc); } return results === null ? "" : decodeURIComponent(results[1].replace(/\+/g, " ")); } return { urlExists: function(url){ return _urlExists(url); }, runsInNativeApp: function() { return _runsInNativeApp(); }, runsInRippleEmu: function() { return _runsInRippleEmu(); }, getQueryParameterByName: function(name, loc) {return _getQueryParameterByName(name, loc);} }; // }); }()); console.log('lib.env..var-1...js');
Zu testendes Modul lib.env...var-1...js
Unit-Tests
Zunächst benötigen wir noch eine kleine Testfunktion helloWorld(), die wir in LibUtilHelpers.js im Ordner specs/helpers untergebracht haben. Diese kleine Helper-Funktion benötigen wir für einen Test unserer Utility-Funktion functionExists(...) (s. weiter unten).
function helloWorld() { return "Hello world!"; }
Helper-Funktion in der Datei LibUtilHelpers.js für die Testumgebung
Die Helper-Funktion ist nicht Teil der Anwendung, sondern der Testumgebung.
Wir erstellen zwei Test-Skripte, jeweils eins für jedes Modul:
describe("Hello world", function() { it("says hello", function() { expect(helloWorld()).toEqual("Hello world!"); }); }); describe("lib-util", function () { it("shift word 1", function() { var S = new Object(); S.val='Berlin-Wedding'; var P=util.shiftWord('-', S); expect(P).toEqual("Berlin"); expect(S.val).toEqual("Wedding"); }); it("shift word 2", function() { var S = new Object(); S.val='Berlin'; var P=util.shiftWord('-', S); expect(P).toEqual("Berlin"); expect(S.val).toEqual(""); }); it("shift word 3", function() { var S = new Object(); S.val='Berlin-Wedding-Mitte'; var P=util.shiftWord('-', S); expect(P).toEqual("Berlin"); expect(S.val).toEqual("Wedding-Mitte"); }); it("shift word 4", function() { var S = new Object(); S.val='-Wedding'; var P=util.shiftWord('-', S); expect(P).toEqual(""); expect(S.val).toEqual("Wedding"); }); it("shift word 5", function() { var S = new Object(); S.val='-'; var P=util.shiftWord('-', S); expect(P).toEqual(""); expect(S.val).toEqual(""); }); it("shift word 6", function() { var S = new Object(); S.val=''; var P=util.shiftWord('-', S); expect(P).toEqual(""); expect(S.val).toEqual(""); }); it("replace all", function() { var S = '//eins//zwei/////'; var P=util.replaceAll(S, '//', '-'); expect(P).toEqual("-eins-zwei--/"); }); it("timestamp", function() { var P=util.timestamp(); expect(P).toMatch(/\d{8}-\d{6}/); }); it("appendTrailingSlash 1", function() { var P=util.appendTrailingSlash('hallo'); expect(P).toEqual("hallo/"); }); it("appendTrailingSlash 2", function() { var P=util.appendTrailingSlash('hallo/'); expect(P).toEqual("hallo/"); }); it("functionExists", function() { var P=util.functionExists('helloWorld'); expect(P).toEqual(true); }); });
Jasmine-Test-Skript LibUtilSpec.js für das Modul lib.util...var-1...js
describe("lib-env", function () { it("getQueryParameterByName", function() { var loc = '?name=otto&plz=1234'; var P=env.getQueryParameterByName('name', loc); expect(P).toEqual('otto'); var P=env.getQueryParameterByName('plz', loc); expect(P).toEqual('1234'); }); });
Jasmine-Test-Skript LibEnvSpec.js für das Modul lib.env...var-1...js
Wir sehen im zweiten Test-Skript, dass wir nur für eine Funktion des Moduls lib.env...var-1..js einen Test geschrieben haben. Dies hat damit zu tun, dass in dieser einfachen Form des Unit-Testings keine Test von Umgebungsbedingungen möglich sind, z.B. mit welchen Query-Parametern aktuell eine Browser-Seite aufgerufen wurde oder ob eine Anwendung im Ripple-Emulator oder in einer hybriden App läuft.
Für den ersten Fall habe ich etwas getrixt, indem ich einen Query-String als Parameter der zu testenden Funktion übergebe, die ich dahingehend modifiziert haben. Deshalb kann ich für diese Funktion einen Test schreiben.
Für die beiden anderen Fälle (z.B. Anwendung läuft im Ripple-Emulator) müssten diese Umgebungen ja zur Verfügung stehen, was in dieser Test-Umgebung nicht der Fall ist. Dafür wären, wenn man denn unbedingt dafür Tests benötigt, aufwändigere Test-Designs erforderlich.
Ich habe diese Fälle deshalb hier aufgenommen, damit man die Grenzen des einfachen Unit-Testings erkennt und sieht, in welcher Richtung es schnell sehr aufwändig wird, wenn man wirklich alles testen möchte.
Ziel ist das "Green Bar Feeling"
Startet man nun das Grunt-Skript mit grunt utest (s. Gruntscript.js), bekommt man folgende Ausgabe, wenn alle Tests erfolgreich durchlaufen wurden:
Abb. 1: Ausgabe, wenn alle Tests erfolgreich sind
Mit Grunt ist es dann sehr einfach, einen solchen Unit-Test in den kompletten Workflow zu integrieren.
Fazit und Ausblick
Tatsächlich habe ich mit diesen Jasmine-Tests für die beiden einfachen Module auch einige Fehler gefunden. Es lohnt sich also, Unit-Tests einzusetzen, auch wenn man die Tests nicht vorher schreibt, sondern erst dann, wenn z.B. ein Modul einen gewissen Reifegrad erreicht hat.
In weiteren Artikeln beschäftigen wir uns mit funktionalem Testen.