Fronteers — vakvereniging voor front-end developers

Geharnast JavaScript

Het belang van JavaScript op het web is de laatste jaren enorm toegenomen. Ten eerste heeft JavaScript deels de animatierol van Flash overgenomen, ten tweede is het web applicatiever geworden, waardoor JavaScript (bijvoorbeeld in Ajax-communicatie) een grote vlucht genomen heeft. De rol JavaScript wordt groter en tegelijkertijd neemt de professionalisering toe. Het is opvallend te zien dat veel best practices uit de back-end-wereld gemeengoed aan het worden zijn bij JavaScript-development. Testen is zo'n belangrijk onderdeel.

Testen

Het testen van JavaScript kan op meerdere niveaus gedaan worden:

  1. Lint testen. Het testen op correcte syntax door tools als JsHint, JsLint of Closure Linter. Dit kan deels door je editor gedaan worden.
  2. Functioneel testen. Het testen van bijvoorbeeld klikscenario's in je website of app met een tool als Selenium.
  3. Unittesten. Daar gaan we in dit artikel dieper op in.
  4. Last but not least: handmatig testen. Idealiter stel je hiervoor schriftelijke testscripts op, zodat je gestructureerd dezelfde scenario's kunt testen bij opeenvolgende software releases.

De eerste drie soorten testtools kunnen geautomatiseerd worden, zodat je continu op de hoogte gebracht kunt worden van de staat van je applicatie.

Unittesten

Wikipedia zegt over unittesten: "Unittesten is een methode om softwaremodulen of stukjes broncode (units) afzonderlijk te testen. Bij unittesten zal voor iedere unit een of meerdere tests ontwikkeld worden. Hierbij worden dan verschillende testcases doorlopen. In het ideale geval zijn alle testcases onafhankelijk van andere tests." Verschillende units samen worden getest in een integratietest. Voor JavaScript zijn er diverse unittest frameworks beschikbaar. Enkele bekende zijn:

In dit artikel kijken we verder naar unittesten met Jasmine. Jasmine kan onafhankelijk van een JavaScript-library gebruikt worden en heeft ook geen DOM nodig om zijn testen uit te voeren. Verder is Jasmine goed te automatiseren. In syntax en mogelijkheden verschilt Jasmine niet veel van andere unittesting tools.

TDD - Assume your code will fail

Eén stap verder nog dan systematisch testen is het testen als uitgangspunt te nemen in je software-ontwikkelproces: "Test-Driven Development" (TDD). Test-Driven Development is een ontwikkelmethode voor software waarbij eerst tests worden geschreven en daarna pas de code. De testcases worden beschreven vanuit het oogpunt van de gebruiker. Hoewel TDD (een methodiek) en Jasmine (een tool) niet per definitie een combinatie vormen, werpen we hier een korte, inleidende blik op TDD met Jasmine.

Jasmine installeren

  1. De voorbeeldcode bij dit artikel is te downloaden vanaf github.
  2. Op het hoogste niveau zie je de directory's "lib", "spec" en "src". "lib" bevat de core-bestanden van Jasmine, in "src" komen de JavaScriptbestanden van je project en in "spec" zitten de bestanden die de sourcecode gaan testen.
  3. Open SpecRunner.html in je favoriete browser. Er worden nog geen testen uitgevoerd: "0 specs, 0 failures ...".

Een eerste Jasmine unit test maken

Jasmine is opgebouwd uit suites, specs en expectations. Eén JavaScript-project bestaat normaal gesproken uit meerdere Jasmine testsuites. Eén testsuite, die vaak een component of een class omvat, kan op zijn beurt een geneste suite of meerdere specs bevatten. Een spec test gerelateerde functionaliteit. In een spec kunnen één of meerdere test cases (expectations) gedefinieerd zijn. Met de standaard matchers van Jasmine (functies zoals toEqual(), toBe(), toMatch(), toBeUndefined() etc.) kun je verschillende scenario's testen.

Hoe schrijf je een spec? Belangrijke leidraden voor TDD zijn:

  1. Schrijf eerst je test (dus niet eerst je functionele code)
  2. Zie de test falen
  3. Schrijf nu de code om de test te laten slagen, op de snelst mogelijke manier, dus niet rekening houdend met eventuele aanpassingen in de code
  4. Refactor (verbeter de code zonder de functionaliteit te wijzigen)
  5. Herhaal deze stappen

Voorbeeld

Stel dat je de Fronteers webshop beheert. Een product kan een variabele prijs hebben: De gewone bezoeker betaalt de volle mik, vaste klanten krijgen 20% korting, en Fronteersleden toucheren maar liefst 50%. Als je hier een functie voor wilt schrijven zou je dat volgens TDD met Jasmine als volgt kunnen aanpakken:

Maak een suite aan (/spec/FronteersShopSpec.js) die het component FronteersShop en de nog te schrijven functie calcDiscount() gaat testen:

describe("FronteersShop", function() {

var fs;
var CLIENTTYPE_MEMBER = 'member',
CLIENTTYPE_FRONTEERS = 'fronteers',
CLIENTTYPE_NONMEMBER = 'other';

// Is executed before each spec:
beforeEach(function() {
fs = new FronteersShop();
});

});

De bijbehorende JavaScriptcode (/src/FronteersShop.js) ziet er dan nog als volgt uit:

function FronteersShop() {
// a lot TODO
}

Unittesten worden doorgaans opgeschreven in begrijpelijke taal, zie achtereenvolgens de beschrijving van een suite, een spec en een expectation:

  • describe "when the discount price is calculated"
  • it "should correctly validate function input"
  • expect(fs.calcDiscount(null)).toBeUndefined();

Dit maakt enerzijds de Jasmine-code self-documenting en geeft anderszijds duidelijk aan waar in de testen fouten optreden.

De functie calcDiscount() willen we twee input-parameters geven:

  • price {Number} - de prijs van het product
  • customerType {String} - het soort klant: 'member', 'fronteers' of 'other'.

In de eerste stap van onze functie calcDiscount() kijken we (in een geneste suite) of de twee input-parameters van het verwachte datatype zijn:

describe("FronteersShop", function() {

var fs;

var CLIENTTYPE_MEMBER = 'member',
CLIENTTYPE_FRONTEERS = 'fronteers',
CLIENTTYPE_NONMEMBER = 'other';

beforeEach(function() {
fs = new FronteersShop();
});

describe("when the discount price is calculated", function() {

it("should correctly validate function input", function() {
// Wrong number of expected arguments
expect(fs.calcDiscount(1)).toBeUndefined();
// First argument of wrong data type
expect(fs.calcDiscount(null, CLIENTTYPE_FRONTEERS)).toBeUndefined();
// Second argument of wrong data type
expect(fs.calcDiscount(100, null)).toBeUndefined();
// Input should be accepted
// expect(fs.calcDiscount(100, CLIENTTYPE_FRONTEERS)).toBeDefined();
});

});

});

De testen zullen falen, want de bijbehorende code ontbreekt. Hier volgt een eerste implementatie van calcDiscount():

FronteersShop.prototype.calcDiscount = function(price, customerType) {

// Check if parameter "price" is correct, must be floating point number
if (isNaN(parseFloat(price)) || (!isFinite(price)) ) {
return;
}
// Check if parameter "customerType" is a String
if (typeof customerType !== 'string') {
return;
}

}

(Normaal gesproken wil je waarschijnlijk geen kale return doen, maar handel je de fout af. Dat valt buiten de scope van dit artikel.) Met deze code zal de eerste spec slagen en groen worden. Vervolgens moet calcDiscount() doen waarvoor het in het leven geroepen is, de juiste prijs teruggeven, al dan niet met korting. Eerst schrijven we de spec:

describe("FronteersShop", function() {

// ...

describe("when the discount price is calculated", function() {

// ....

it("should correctly calculate discount", function() {
// Expect 50% of 100 => 50
expect(fs.calcDiscount(100, CLIENTTYPE_FRONTEERS)).toEqual(50);
// Expect 80% of 100 => 80
expect(fs.calcDiscount(100, CLIENTTYPE_MEMBER)).toEqual(80);
// Expect 100% of 100 => 100
expect(fs.calcDiscount(100, CLIENTTYPE_NONMEMBER)).toEqual(100);
// Check decimals, expect 50% of 17.1 => 8.55
expect(fs.calcDiscount(17.1, CLIENTTYPE_FRONTEERS)).toEqual(8.55);
});

});

});

De testen zullen weer falen. Met vallen en opstaan kunnen we dan de bijbehorende JavaScript afleveren:

FronteersShop.prototype.calcDiscount = function(price, customerType) {

// ...

if (customerType === 'member') {
return price * 0.8;
} else if (customerType === 'fronteers') {
return price * 0.5;
} else {
return price;
}
};

Hoera, de testpagina kleurt weer groen!

Dit is natuurlijk een simpel voorbeeld. Hoe complexer je code, hoe meer de waarde van unittesten toeneemt.

Wat verder?

Om verder up-speed te komen met Jasmine kan het inleidende artikel op Nettuts erg nuttig zijn. De Jasmine-wiki biedt alle noodzakelijke informatie. Er zijn veel plugins voor Jasmine beschikbaar, bijvoorbeeld om Jasmine in je IDE te integreren (JsTestDriver) of om met zogeheten fixtures de interactie met de DOM te testen (zie de Jasmine jQuery-plugin). Het is ook mogelijk om Jasminetesten automatisch uit te voeren in Maven en hen zo een onderdeel te maken van de Continuous Integration. Mocht je offline zijn dan heb je wellicht iets aan het verhelderende boek van Christian Johansen Test-Driven JavaScript Development.

Tot slot

Wanneer moet je nu alles uit de kast halen met unittesten? Als je een JavaScript-library, plugins of herbruikbare componenten schrijft, zijn unittesten een must. Maar ook in langlopende projecten met een snelle release cycle zijn unittesten onmisbaar om mogelijke regressiebugs af te vangen. Zelfs als je korte toevoeging schrijft voor een kleine website kunnen unittesten erg nuttig blijken. Ze laten je anders tegen je code aankijken, waardoor je code robuuster, leesbaarder en makkelijker overdraagbaar wordt. Ook voor front-enders belangrijke waarden.

Reacties

1 Lars Kappert op 18-12-2011 om 11:35 uur:
Bedankt Tom, goed artikel. Ik vind het wel belangrijk dat dit meestal ondergewaardeerde onderwerp ook aan bod komt.

Ik heb wel een opmerking: in je voorbeelden geef je één test met meerdere assertions ("expects"). In het algemeen wil je zo weinig mogelijk assertions in één test, vnl. zodat het eventuele probleem makkelijker te traceren is. Het voorbeeld werkt misschien in de hand dat mensen nu een hele rits assertions in een enkele test gaan zetten.
2 Mathias op 18-12-2011 om 11:41 uur:
Vergeet je performance testing niet? ;)
3 Tom op 18-12-2011 om 12:10 uur:
@Lars: Terechte opmerking!
@Mathias: Helemaal mee eens. Performance is een topic op zich dat meer dan alleen testen behelst.
4 Pascal Strijbos op 18-12-2011 om 18:43 uur:
Zijn onafhankelijkheid van libraries, geen DOM en automatisering jouw redenen om voor Jasmine te kiezen en niet voor een ander test framework?

Wat ik erg lastig vind om met TTD te beginnen in JavaScript is inschatten wat het me uiteindelijk gaat opleveren en of de problemen die ik tegenkom in het proces afgevangen kunnen worden door het test driven aan te pakken.
Wat zijn jouw ervaringen in de praktijk? Pak jij elk JavaScript project aan met een TTD methode?
5 Tom op 18-12-2011 om 22:42 uur:
Bij Info.nl hebben we ondermeer voor Jasmine gekozen vanwege:
- actieve community, goede documentatie
- beschikbare plugins (o.a. voor jquery)
- integratie met Maven

Ik begin zeker niet elk project met TDD. Wij gebruiken Jasmine momenteel alleen bij complexe, applicatieve sites. JavaScript kan uitwaaieren en zeer complex worden als je het in het begin van het project niet goed opzet. Juist hier kan TDD een hulpmiddel zijn om structuur af te dwingen.

ALs je project alleen een JavaScript driven carrousel bevat, schiet TDD zijn doel misschien voorbij. Aan de andere kant is klein beginnen wel een goede manier om ervaring op te doen met TDD. Alleen met ervaring kan TDD zijn nut bewijzen. 100% test coverage is nooit mogelijk ("een bug is een test die je vergeten bent te schrijven"), en je zult toch uit moeten vinden welke code je in de praktijk wel en niet automatisch test en wat hier de waarde van is.
6 Pascal Strijbos op 19-12-2011 om 09:54 uur:
Misschien moet je het ook gewoon een keer gaan toepassen in een nieuw project en inderdaad klein beginnen.
Uiteindelijk zul je ook collega's moeten gaan overtuigen om het toe te passen, want als iemand je project overneemt of wat onderhoud doet is het belangrijk dat dit ook test driven gebeurd.
Volgens mij gaat de kwaliteit van je code ook wel omhoog, omdat je minder snel geneigd bent om "quick fixes" te doen.

De drempel blijft best wel hoog om dit in de praktijk te gaan doen, maar het heeft zeker toegevoegde waarde.
Nu nog PM's gaan overtuigen ;)
7 Tom op 19-12-2011 om 12:42 uur:
Mee eens dat het gedragen moet worden door zowel het management als de ontwikkelaars. Zie ook de Benefits and shortcomings op de wikipediapagina over TDD.
8 Roy Tomeij op 22-12-2011 om 08:37 uur:
Laten we "testen" en "TDD" niet door elkaar halen in de discussie :-) Bij Test Driven Development begint alles bij de test; die stuurt de ontwikkeling aan. Je schrijft dus eerst een falende test en daarna de code die het fixt. Als je tests schrijft, maar niet volgens dit principe, dan ben je niet bezig met TDD. Dat is niet erg: wat mij betreft is het überhaupt hebben van testdekking op je JS iets waarmee je boven 99% van de developers uit steekt. Resumé: laten we begrippenvervuiling voorkomen :-)
9 Roy Tomeij op 22-12-2011 om 08:39 uur:
Potdorie, heb ik gewoon verkeerd gelezen op m'n kleine iPhoneschermpje; wat ik zeg is exact wat hier staat. Mea culpa!
Plaats een reactie