jQuery-Plugin-Entwicklung mit Yeoman und Unit-Tests

Test Driven Development mittels der Yeoman-Templates und QUnit oder Mocha

Obwohl Yeoman erst vor ca. einem Monat von Google veröffentlicht wurde, zieht das Tool schon seit Monaten das Interesse auf sich. Basierend auf Grunt ermöglicht Yeoman eine effiziente Entwicklung von Web-Applikationen dank Templates, Live-Reloads im Browser, Integration von Kommandozeilen basierten Unit-Tests via PhantomJS und weiteren Features. Näheres kann man einem YouTube-Video entnehmen. Da alle Möglichkeiten diesen Artikel hier sprengen würden, beschränke ich mich auf die Entwicklung und das Testen von jQuery-Plugins.

Installation

Um loslegen zu können, braucht man zunächst eine funktionierende Installation von Node.js – ich erspare es mir, das weiter zu erläutern. ^^

Ansonsten kann man einen Großteil von Yeoman mittels npm install yeoman installieren. Um zu prüfen, ob alle Abhängigkeiten erfüllt sind, hilft folgendes Kommando:

{syntaxhighlighter brush:bash}
$ curl -L get.yeoman.io | bash
{/syntaxhighlighter}

Bei mir fehlte etwa noch PhantomJS, das man dann aber auch schnell mit npm installiert bekommt. Ferner sollte per npm install grunt@0.4.0a Grunt in exakt dieser Version installiert werden, da Yeoman diese intern verwendet.

Anlegen eines Projekts für ein jQuery-Plugin

Nun können wir unser Projekt anlegen und einrichten:

{syntaxhighlighter brush:bash}
$ mkdir mein-plugin
$ cd mein-plugin
$ grunt init jquery:plugin
{/syntaxhighlighter}

Bitte beachtet, dass wir hier direkt Grunt aufrufen, da Yeoman (noch) keinen eigenen Generator für jQuery-Plugins mitbringt. Grunt fragt nun ein paar Informationen über das Plugin ab und legt danach ein Projekt mit der nötigen Grundstruktur an:

Datei Bedeutung
Gruntfile.js Dies entspricht einem Makefile bei anderen Build-Systemen. Verschiedene Task helfen uns bei der Entwicklung, dem Testen und der Distribution unseres Projekts.
package.json Metadaten über das Modul, wie von npm benötigt.
LICENCE-* Die verwendete Lizenz. Hängt davon ab, welche man im Generator gewählt hat.
README.md Beschreibung des Projekts im von GitHub verwendeten Format.
src/*.js Der eigentliche JavaScript-Code, hier derjenige des jQuery-Plugins.
test/* Unit-Test(s), vom Generator angelegt als js-Datei für QUnit und einem zugehörigem HTML-Dokument.
libs/** Diverse Bibliotheken, die das Projekt benötigt. Momentan sollten hier lediglich jQuery und QUnit drin sein.

Dieses Grundgerüst ist bereits funktional, wie man z.B. per Aufruf der HTML-Datei im test-Verzeichnis feststellen kann.

Plugin-Entwicklung

Als Beispiel verwende ich hier ein einfaches Plugin, dass ein Click-Event an ein Element, z.B. einen Schalter, bindet und daraufhin eine bestimmte Klasse anzeigt. Natürlich sollte das Ganze etwas konfigurierbar sein:

{syntaxhighlighter brush:js}
(function($) {

// Event an die übergebenen Elemente binden
$.fn.testplugin = function(options) {

// Standardoptionen festlegen und ggf. überschreiben
options = $.extend({event: 'click', classname: 'testplugin'}, options);

// alle übergebenen Elemente durchgehen
return this.each(function() {
$(this).on(options.event, function() {
$('.' + options.classname).toggle();
});
});
};

// Statische Methode, bindet Events an vorgegebene Elemente
$.testplugin = function(options) {

// Standardoptionen festlegen und ggf. überschreiben
options = $.extend({
event: 'click',
classname: 'testplugin',
selector: '.testplugin-toggle'
}, options);

// Plugin-Funktion für alle selektierten Elemente aufrufen
return $(options.selector).testplugin(options).trigger(options.event);
};

})(jQuery);
{/syntaxhighlighter}

Man beachte, dass getreu KISS auf Dinge wie RequireJS, gespeicherte Standardoptionen und ähnliches verzichte, um die Dinge hier übersichtlich zu halten.

Im Grunde besteht das Plugin aus zwei Funktionen $.testplugin() und $(…).testplugin(), von denen die erste die zweite über einen vorgegebenen selector aufruft und diese einen einfachen Handler für event definiert, der nach diesem Ereignis Elemente einer Klasse classname ein- oder ausblendet. Und exakt diese Parameter können auch übergeben werden. Kein sonderlich sinnvolles Plugin, aber zur Demonstration hier ausreichend.

Nun schauen wir mal, ob die Syntax so in Ordnung und der Code sauber ist:

{syntaxhighlighter brush:bash}
$ grunt lint
Running "lint:files" (lint) task
>> 3 files lint free.

Done, without errors.
{/syntaxhighlighter}

Das klingt doch gut. Die Grunt-Task lint ruft intern JSHint auf, um die Qualität zu überprüfen. Die Einstellungen werden dabei aus src/.jshint bzw. test/.jshint gelesen, was für die meisten Fälle ausreichend sollte. Nun zum …

Unit-Test mit QUnit

QUnit, das speziell für jQuery entwickelt wurde. Für andere Projekte oder komplexere Tests ist es eher nicht geeignet, weshalb man dort eher etwas wie Mocha verwenden möchte, doch dazu später mehr.

Grundsätzlich besteht ein sinnvoller Test aus den eigentlichen Tests und einem zugehörigen HTML-Dokument, und genau dieses hat der Generator uns bereits angelegt. Zunächst ergänze ich das HTML-Dokument um passende Testdaten:

{syntaxhighlighter brush:html highlight: [14, 15, 24, 25, 26]}




jQuery Plugin Test Suite


jQuery Plugin Test Suite

    Soll angezeigt werden!

    Sollte immer sichtbar sein!



    {/syntaxhighlighter}

    Der eigentlich neue Code steht in den Zeilen 24 bis 26 und bedarf keiner großen Erklärungen. Bitte beachtet, dass die Pfadangaben in den Zeilen 14 und 15 eventuell je nach den Angaben im Generator angepasst werden müssen.

    Nun zum eigentlichen Test. Der Einfachheit halber verzichte ich auf Tests mit anderen Optionen und verwende einfach die Standards:

    {syntaxhighlighter brush:js highlight: [12]}
    (function($) {

    module('testplugin');

    test('initialization', function() {
    $.testplugin();
    ok($('#toggle').is(':hidden'), '#toggle sollte unsichtbar sein');
    ok($('#comparision').is(':visible'), '#comparision sollte sichtbar sein');
    });

    test('toggle', function() {
    $.testplugin();

    // event ausführen
    $('#button').click();

    // check
    ok($('#toggle').is(':visible'), '#toggle sollte sichtbar sein');

    // event ausführen
    $('#button').click();

    // check
    ok($('#toggle').is(':hidden'), '#toggle sollte unsichtbar sein');
    });

    }(jQuery));
    {/syntaxhighlighter}

    Das sollte größtenteils selbsterklärend sein. Speziell die Zeile 12 ist jedoch wichtig, da QUnit das Element #qunit-fixture nach jedem Test zurücksetzt und z.B. auch die im 1. Test gebundenen Events entfernt.

    Ruft man nun die HTML-Datei im Browser auf, erfährt man das Testergebnis, das hoffentlich fehlerfrei sein sollte. Interessanter ist jedoch ein Test auf der Kommandozeile:

    {syntaxhighlighter brush:bash}
    $ grunt qunit
    Running "qunit:files" (qunit) task
    Testing jquery.plugin.html..OK
    >> 4 assertions passed (31ms)
    {/syntaxhighlighter}

    yeay \o/

    Nun können wir etwa die build-Task aufrufen, die alles testet, dann ggf. alle JavaScript-Dateien zusammenfast und minifiziert:

    {syntaxhighlighter brush:bash}
    $ grunt default
    Running "lint:files" (lint) task
    >> 3 files lint free.

    Running "qunit:files" (qunit) task
    Testing jquery.plugin.html..OK
    >> 4 assertions passed (31ms)

    Running "concat:dist" (concat) task
    File "dist/jquery.plugin.js" created.

    Running "min:dist" (min) task
    File "dist/jquery.plugin.min.js" created.
    Uncompressed size: 1021 bytes.
    Compressed size: 299 bytes gzipped (499 bytes minified).

    Done, without errors.
    {/syntaxhighlighter}

    Die unter dist abgelegten Dateien kann man nun ins eigentliche Projekt übernehmen, wobei für das Produktivsystem vor allem die .min.js interessant ist.

    Beobachten von Änderungen

    Sehr interessant ist übrigens die Task grunt watch. Hier läuft Grunt nicht durch, sondern bleibt aktiv und wartet auf Änderungen. Sobald eine Datei neu gespeichert wird, werden automatisch die Standard-Tasks wie oben bei build durchgeführt, sodass man das Konsolenfenster immer im Hintergrund halten kann und direkt auf (mögliche) Fehler aufmerksam gemacht wird.

    So viel zu Grunt und QUnit. Wer sich für Yeoman und Mocha nicht interessiert, kann hier mit der Lektüre aufhören. ^^

    Anpassen an Yeoman

    Wie man direkt z.B. mit yeoman default ausprobieren kann, funktioniert die existierende Gruntfile.js bereits mit Yeoman, da im Grunde nur Grunt aufgerufen wird:

    {syntaxhighlighter brush:bash}
    $ yeoman default
    Running "lint:files" (lint) task
    >> 3 files lint free.

    Running "qunit:files" (qunit) task
    Testing jquery.plugin.html..OK
    >> 4 assertions passed (29ms)

    Running "concat:dist" (concat) task
    File "dist/jquery.plugin.js" created.

    Running "min:dist" (min) task
    File "dist/jquery.plugin.min.js" created.
    Uncompressed size: 1021 bytes.
    Compressed size: 299 bytes gzipped (499 bytes minified).
    {/syntaxhighlighter}

    Wo liegen nun die Vorteile? Im Grunde hauptsächlich in der Unterstützung von CoffeeScript, SASS, selbst neuladende Web-Applikationen – und Mocha. Um den Rahmen hier nicht zu sprengen, gehe ich nur auf letzteres ein.

    Zunächst einmal benötigen wir Mocha, das sich mittels Yeoman installieren lässt:

    {syntaxhighlighter brush:bash}
    $ yeoman install mocha chai
    Running "bower:install:mocha:chai" (bower) task
    GET https://bower.herokuapp.com/packages/mocha
    GET https://bower.herokuapp.com/packages/chai
    bower cloning git://github.com/chaijs/chai.git
    bower cached git://github.com/chaijs/chai.git
    bower fetching chai
    bower cloning git://github.com/visionmedia/mocha.git
    bower cached git://github.com/visionmedia/mocha.git
    bower fetching mocha
    bower checking out mocha#1.6.0
    bower copying /home/aki/.bower/mocha
    bower checking out chai#1.3.0
    bower copying /home/aki/.bower/chai
    bower:sync helper requires a directory path.
    {/syntaxhighlighter}

    Man beachte, dass das hier bei mir mit einer Fehlermeldung abbricht. Ich vermute mal, dass es daran liegt, dass das Projekt nicht mit den Yeoman-Generator angelegt wurde, aber lasse mich gern auch eines Besseren belehren. Fakt ist jedoch, dass Yeoman trotz der Fehlermeldung Mocha unter components/mocha installiert hat. Das ebenfalls installierte Chai stellt ein Assertion-Framework dar, da Mocha von Haus aus keine Tests wie equal oder ok mitbringt.

    Um nun Yeoman zu sagen, dass wir lieber Mocha als QUnit verwenden wollten, müssen wir die Gruntfile.js anpassen. Hier genügt es, einfach den Namen der Task von qunit nach mocha zu ändern (Zeilen 24 und 56).

    Anschließend muss der Test selbst angepasst werden. Zum Glück besitzt Mocha ein QUnit-Interface, sodass einfach nur die umklammernde Funktion entfernt und ein paar Funktionsnamen definiert werden müssen, damit wir den Test unverändert übernehmen können:

    {syntaxhighlighter brush:js highlight: [14]}
    //--fällt weg-- (function($) {
    var module = suite,
    ok = assert.ok;

    module('testplugin');

    test('initialization', function() {
    $.testplugin();
    ok($('#toggle').is(':hidden'), '#toggle sollte unsichtbar sein');
    ok($('#comparision').is(':visible'), '#comparision sollte sichtbar sein');
    });

    test('toggle', function() {
    $.fx.off = true;

    // event ausführen
    $('#button').click();

    // check
    ok($('#toggle').is(':visible'), '#toggle sollte sichtbar sein');

    // event ausführen
    $('#button').click();

    // check
    ok($('#toggle').is(':hidden'), '#toggle sollte unsichtbar sein');
    });

    //--fällt weg-- }(jQuery));
    {/syntaxhighlighter}

    Wichtig ist hier vor allem die Zeile 14. Hier stand in der QUnit-Version ein erneuter Aufruf von $.testplugin();. Das ist bei Mocha nicht nötig, da Mocha das Fixture nicht nach jedem Test zurücksetzt. Stattdessen ist es hier dringend geboten, alle Animationen abzuschalten ($.fx.off = true), da sonst die Checks zu früh durchgeführt werden.

    Die zugehörige HTML-Datei ändert sich etwas deutlicher:

    {syntaxhighlighter brush:html}




    jQuery Plugin Test Suite


    Soll angezeigt werden!

    Sollte immer sichtbar sein!



    {/syntaxhighlighter}

    Nun kann man das Dokument im Browser aufrufen und den Test laufen lassen. Selbst bei diesem kurzen Beispiel sollte auffalllen, dass Mocha um einiges schneller arbeitet.

    Probieren wir es nun in der Kommandozeile:

    {syntaxhighlighter brush:bash}
    $ yeoman mocha
    Running "mocha:files" (mocha) task
    Testing jquery.plugin.html
    PhantomJS timed out, possibly due to a missing Mocha run() call. Use --force to continue.

    Aborted due to warnings.
    {/syntaxhighlighter}

    Nanu? Ich brauchte eine ganze Weile, bis ich dahinter kam, dass PhantomJS bzw. Yeoman auf bestimmte Meldungen warten, die in der Standardkonfiguration nicht gesendet werden. Hier hilft eine test/mocha-runner.js:

    {syntaxhighlighter brush:js}
    (function() {
    var runner = mocha.run();

    if(!window.PHANTOMJS) return;

    runner.on('test', function(test) {
    sendMessage('testStart', test.title);
    });

    runner.on('test end', function(test) {
    sendMessage('testDone', test.title, test.state);
    });

    runner.on('suite', function(suite) {
    sendMessage('suiteStart', suite.title);
    });

    runner.on('suite end', function(suite) {
    if (suite.root) return;
    sendMessage('suiteDone', suite.title);
    });

    runner.on('fail', function(test, err) {
    sendMessage('testFail', test.title, err);
    });

    runner.on('end', function() {
    var output = {
    failed : this.failures,
    passed : this.total - this.failures,
    total : this.total
    };

    sendMessage('done', output.failed,output.passed, output.total);
    });

    function sendMessage() {
    var args = [].slice.call(arguments);
    alert(JSON.stringify(args));
    }
    })();
    {/syntaxhighlighter}

    Natürlich muss dann auch die Zeile 36 in der HTML-Datei geändert werden:

    {syntaxhighlighter brush:html first-line:36}

    {/syntaxhighlighter}

    Nun können wir alles per Yeoman laufen lassen:

    {syntaxhighlighter brush:bash}
    % yeoman default
    Running "lint:files" (lint) task
    >> 3 files lint free.

    Running "mocha:files" (mocha) task
    Testing jquery.plugin.html..OK
    >> 2 assertions passed (0s)

    Running "concat:dist" (concat) task
    File "dist/jquery.plugin.js" created.

    Running "min:dist" (min) task
    File "dist/jquery.plugin.min.js" created.
    Uncompressed size: 1021 bytes.
    Compressed size: 299 bytes gzipped (499 bytes minified).

    Done, without errors.
    {/syntaxhighlighter}

    Fazit

    Ich hoffe, meine Abhandlung hier gibt genug Anregungen zu eigenen Recherchen und den Sinn von Paketverwaltung und Unit-Tests auch bei JavaScript-Projekten.

    Flattr