Sonntag, 6. März 2011

Automatische Tests für JavaScript-Code

Vor kurzem habe ich auf diesem Blog meine minimalistische JavaScript-Bibliothek beschrieben. Der Post wäre nicht ganz vollständig, wenn ich nicht darauf hinweisen würde, dass diese Bibliothek namens minlib.js unter der milden BSD-Lizenz für alle Interessierten verfügbar ist.

Wie immer, erschliesst sich die Verwendung einer Bibliothek nicht nur durch die Dokumentation, sondern auch durch die Unit-Tests: Unit Tests zeigen, wie die Funktionen konkret aufgerufen werden und welche erwarteten Ergebnisse die Funktionsaufrufe produzieren. Auf der Unit-Testseite können diese Aufrufe darüberhinaus in action beobachtet werden. Man kann sie bei Interesse debuggen und sich Schritt für Schritt ansehen, was die Funktionen tun.

Vor mehreren Jahren hatte ich mich zum Testen von JavaScript-Code für das schmale Unittest-Framework ECMA Unit aus dem Kupu-Projekt entschieden. Auch in diesem Blog schrieb ich darüber bereits am 1.6.2007. Bislang hatte ich keinen Anlass, diese Entscheidung zu bereuen. Ein Framework für Unit Tests muss eben nicht üppig sein. Man muss nur in der Lage sein, ohne viel redundanten Code sofort die geplanten Selbsttestfunktionen hinschreiben und ausführen zu können.

"Testfälle" sind in ECMA Unit Objekte zur Gruppierung von "Tests". Sie sind von einem zentralen Objekt TestCase abgeleitet, das auch die verschiedenen Zusicherungen wie assert, assertEquals, assertFalse usw. beinhaltet. Die einzelnen Tests sind diejenigen Methoden des Testobjekts, deren Name mit dem Präfix test beginnt. Vor Ausführung eines Testfalls wird eine setUp()-Methode aufgerufen, falls eine solche im Testobjekt hinterlegt ist; ebenso wird nach Ausführung eine tearDown()-Methode aufgerufen. Eine Reihe von Testfällen kann zur Ausführung in einer Suite registriert werden, die schliesslich mit runSuite() aufgerufen wird. Für die Protokollierung gibt es ein Objekt HTMLTestReporter, oder, für die Ausgabe auf der Konsole, einen StdoutReporter. Das ist alles. Die (spartanische) Ausgabe des HTML-Reporters sieht folgendermassen aus:



Im Fehlerfall erhält man auch den Hinweis darauf, welcher Test in welchem Testfall welche Assertion verletzte:



Beim Entwickeln von JavaScript-Code kann man also ein Browserfenster mit der einmal eingerichteten ECMA-Unit-Testseite geöffnet lassen und nach jeder Änderung mittels Auffrischen sehen, ob die bisherigen Tests noch alle erfolgreich durchlaufen werden.

Beim Schreiben von Testcode kommt es darauf an, den Test so kurz und lesbar wie möglich zu formulieren. Ein Kommentar ist meistens besser als Meldungsstring für eine Assertion plaziert. Das erhöht die Chance, dass der Text im Fehlerfall von einem Entwickler wirklich gelesen wird.

Hier ein einfaches Beispiel, das die zu testende Funktion byId() an einem Element der Testseite ausprobiert:
this.testById = function(){
var element = byId( "form2" );
this.assert( element,
"Element der ID 'form2' muss gefunden werden");
this.assertEquals( element.id, "form2",
"Element der ID 'form2' muss korrekt identifiziert werden");
};

Die erste Zusicherung mit assert prüft, ob die Methode byId() überhaupt ein Objekt zurückgeliefert hat. Mit assertEquals folgt dann die spezifischere Zusicherung, dass es auch das richtige Element ist, das gefunden wurde. Die Aufteilung auf zwei Zusicherungen hat den Vorteil einer besseren Lesbarkeit im Fehlerfall. Würde byId() nämlich null oder undefined zurückgeben, so bekäme man ohne den vorgängigen assert beim Zugriff auf element.id eine Ausnahme, etwa "element.id is null or not an object". In der obigen Form aber würde der Test die Meldung "Element der ID 'form2' muss gefunden werden" bringen, was einen spezifischeren Hinweis auf die Fehlerursache enthält.

Für den Test von Callbackfunktionen können wir uns Closures zunutze machen. Die folgende Methode prüft, ob eine each()-Iteration mit der speziellen Ausnahme $break wirklich verlassen wird:
// ---
this.testLeaveEachWithBreak = function() {
var lastItem;
[1,2,3,4,5,6,7].each( function() {
lastItem = this;
if (this==5) { throw $break; }
});
this.assertEquals( lastItem, 5,
"each() muss mit $break abgebrochen werden können"
);
};

Die anonyme Iteratorfunktion dieses Tests kann auf die lokale Variable lastItem zugreifen, obwohl diese nicht in ihrem eigenen Geltungsbereich, sondern in dem der Testmethode deklariert wurde. Somit können wir nach Ausführung der Schleife bequem prüfen, ob lastItem wirklich den erwarteten Wert hat.

Ich hatte angekündigt, die Datei minlib.js nicht mehr wesentlich erweitern zu wollen. Die Funktion extend(), die ich noch hinzugefügt habe, ist von ihrer Grösse her sicher unwesentlich, nicht aber von ihrer Bedeutung. Die Funktion ist der von der Mozilla Developer Community empfohlene Vererbungsmechanismus. Unter Verwendung von extend() kann man Vererbung so implementieren, wie es der folgende testRedefineFunction vorführt:
this.testRedefineFunction = function() {

var A = function(x,y) {
this.x = x;
this.y = y;
};

A.prototype = {
t:1,
x:null,
y:null,
f:function() {
return "t:"+this.t+",x:"+ this.x + ",y:" + this.y ;
}
};

var B = function(x,y,z) {
A.call(this,x,y);
this.z = z;
};

B.prototype = { z:null,
f:function() {
return A.prototype.f.call(this) + ",z:" + this.z;
}
};

extend( B, A);

var b = new B(2,3,4);

this.assertEquals( b.f(), "t:1,x:2,y:3,z:4");

};

Der Test zeigt mehreres auf einmal:

  • Methoden eines Objekts definiert man nicht im Konstruktor, sondern im Prototyp. Damit wird die Deklaration nur einmal durchlaufen (und nicht bei jeder Instanzbildung).
  • Alle Komponenten des Objekts sollten im Prototyp aufgezählt sein.
  • Es müssen aber nicht alle Komponenten bereits im Konstruktor auftreten. Hier kommt z.B. das Attribut t nur über den Prototyp ins Objekt.
  • Im Konstruktor von B sieht man einen Aufruf des Superkonstruktors mittels A.call(this,x,y) (und nicht etwa einfach nur A(x,y)). Nur so arbeitet man auf der gewünschten Instanz, nämlich der gerade zu bildenden Instanz von B.
  • Die extend()-Funktion übernimmt den Prototyp von A in den Prototyp von B. Wenn möglich, durch blosse Zuweisung der Referenz des Prototyps (der ja wie alles in JavaScript nur ein Hash ist) an das dafür vorgesehene Pseudo-Attribut __proto__. Falls dieses von der JavaScript-Implementierung nicht offengelegt ist, werden wenigstens alle Prototypkomponenten von A in B übernommen, die in B nicht definiert wurden.
  • Der Zugriff auf x und y würde auch ohne extend(B,A) im Subtyp B funktionieren, da diese Komponenten auch im Konstruktor von A definiert werden. Der Zugriff auf t wäre aber ohne extend(B,A) erfolglos, da t im Prototyp, aber nicht im Konstruktor verwendet wird.
  • Man redefiniert Funktionen, indem man sie mit gleichem Namen im Prototyp des Subtyps definiert. Wie im Konstruktor den Superkonstruktor, kann man in der redefinierten Methode mittels A.prototype.f.call(this,...) die Supermethode aufrufen.


Wie testet man eine Funktion, die HTTP-Requests durchführt – z.B. die Funktion doRequest von minlib.js? Der Unit Test muss nur den Code der Funktion doRequest selbst überprüfen, nicht aber den auszuführenden HTTP-Request. Der Unit Test prüft also, ob das XMLHttpRequest-Objekt anhand der Aufrufparameter von doRequest richtig instrumentiert wird. Hierzu kann die Funktion getRequestor, die je nach Browser die richtige Version von XMLHttpRequest beschafft, durch eine passende Mock-Funktion überschrieben werden.

Im Testfall für HTTP definieren wir also mit folgendem Code ein billiges Ersatzobjekt für XMLHttpRequest – ein Objekt, das so tut, als beherrschte es alle Operationen eines veritablen XMLHttpRequests:
function RequestorMock() {};
RequestorMock.prototype = {
headerFields:{},
readyState:0,
onreadystatechange:null,
body:null,
open:function(action,url,sync) {
this.url = url;
this.action = action;
this.sync = sync;
},
send:function(data) {
this.body = data;
this.readyState = 4;
if (this.onreadystatechange) {
this.onreadystatechange.call(this);
}
},
setRequestHeader:function(name,value) {
this.headerFields[name] = value;
}
};

In der send()-Methode des Mocks wird sofort der readyState=4 hergestellt und die Callbackfunktion aufgerufen. Das reicht, um die richtige Verwendung des XMLHttpRequest-Objekts in doRequest zu prüfen.

function() {
this.name = "HttpRequest";
var getRequestorSave = null;
var requestorMock = null;
this.setUp = function() {
getRequestorSave = getRequestor;
getRequestor = function() {
return requestorMock = new RequestorMock();
};
};
this.tearDown = function() {
getRequestor = getRequestorSave;
};

this.testDoRequest = function() {

var data = 'var i=0;',
url = 'http://ruediger-plantiko.net',
callbackCalled = false;

// doRequest(url,callback,data,action,headerFields)
doRequest( url,
function() { callbackCalled = true; },
data,
"POST",
{ "Content-Type":"text/javascript" } );

this.assertEquals( requestorMock.url, url );
this.assertEquals( requestorMock.body, data );
this.assertEquals( requestorMock.headerFields["Content-Type"],
"text/javascript" );
this.assertEquals( requestorMock.action, "POST" );
this.assert( callbackCalled,
"Die Callbackfunktion muss aufgerufen werden" );
};

}


Im Setup also wird die (globale) getRequest-Funktion durch den Zeiger auf eine anonyme Funktion überschrieben, die eine Mock-Instanz zurückliefert. Wir merken uns die Instanz noch in einer lokalen privaten Variablen requestorMock, um den Zustand des Objekts im Zusicherungsteil des Tests verwenden zu können. Im Teardown wird die ursprüngliche Funktion wiederhergestellt.

In der Testmethode selbst wird nur noch die doRequest()-Methode mit einigen Dummydaten aufgerufen. Der HTTP-Request selbst wird dann zwar niemals ausgeführt. Wohl aber wird geprüft, dass der Aufruf korrekt ist. Und das ist genau die Aufgabe der Funktion doRequest(): Den HTTP-Request entsprechend den beim Aufruf übergebenen Parametern korrekt auszuführen.

Der Versand von Formularen lässt sich auf noch einfachere Weise überprüfen, indem man das Formularattribut action, das normalerweise die Ziel-URL enthält, durch eine Dummy-URL ersetzt, zum Beispiel action="javascript:void(-1);". Dann kann nach dem submit() inspiziert werden, ob das Formular wie gewünscht gefüllt wurde. So funktioniert der Test der gotoURL()-Funktion:
this.testGotoURL = function() {

var elem, parent;
var action = "javascript:void(-1);";

gotoURL( action, { testField:"testValue" } );

elem = firstByName("testField");
this.assert( elem, "gotoURL() muss Formularfeld erzeugen" );
this.assertEquals( elem.nodeName, "INPUT" );
this.assertEquals( elem.type, "hidden" );
this.assertEquals( elem.value, "testValue" );

parent = elem.parentNode;
this.assertEquals( parent.nodeName, "FORM" );
this.assertEquals( parent.action, action );
this.assertEquals( parent.method, "post" );

};


Auch die korrekte Registrierung und Deregistrierung von Ereignisbehandlern durch die Funktionen registerFor() und unregister() lässt sich automatisch testen, sobald man weiss, wie man simulativ ein Ereignis produzieren kann, z.B. ein Click-Event. In diesem Fall punktet einmal der Internet Explorer: um einen Mausclick auf das Element e auszulösen, programmiert man hier e.click() – einfacher geht es nicht!
fireClick = navigator.userAgent.match(/MSIE/) ?
function( element ) {
// IE = wunderbar einfach
element.click();
} :
function( element ) {
// Alle anderen: Dasselbe total umständlich
var evObj = document.createEvent('MouseEvents');
evObj.initEvent( 'click', true, true );
element.dispatchEvent(evObj);
};


Der Geradeausfall zum Testen, ob ein registrierter Click-Behandler auch aufgerufen wird, lässt sich dann folgendermassen schreiben:
function() {
var f = null;
var idCopiedByHandler = "";
this.setUp = function() {
idCopiedByHandler = "";
f = registerFor( byId("btnTest"), "click", function() {
idCopiedByHandler = this.id;
});
};

this.tearDown = function() {
unregister( byId("btnTest"), "click", f );
};

// ---
this.testHandlerCalledOnClick = function() {

var btnTest = byId("btnTest");

fireClick( btnTest );
this.assertEquals( idCopiedByHandler, "btnTest",
"Registrierter Clickbehandler muss bei Click aufgerufen werden" );

};
...
}

Ob der Callback aufgerufen wurde, erkennt man daran, dass die ID des Elements (auf das im Behandler mit der Pseudovariable this zugegriffen werden kann) in die private Variable idCopiedByHandler kopiert wurde.

Um mehrere Tests mit derselben Registrierungsfunktion ausführen zu können, gefiel es mir, die Registrierung in den setUp zu setzen und im tearDown wieder zu entfernen. So reduziert sich der Code der Testmethode, wie man sieht, drastisch auf das absolut Wesentliche: Das Auslösen des Clicks und die Prüfung, dass der Clickbehandler aufgerufen wurde.

Für JavaScript-Bibliotheken sind Unit Tests dieser Art bereits ausreichend. Für Webanwendungen, die intensiv mit JavaScript arbeiten, sind natürlich weitergehende Anwendungstests angebracht, zum Beispiel mit QTP oder Selenium. Das wäre Thema eines separaten Blogs.

Keine Kommentare :