Magnar Sveen gir en glimrende introduksjon til testdrevet JavaScript-utvikling i screencast-serien “Zombier, mafia og testdrevet utvikling”, zombietdd.com. Etter å ha fulgt med på denne i høst, ble jeg inspirert til å gjøre noe tilsvarende selv og bestemte meg for å utvikle en enkel applikasjon, der jeg benytter TDD-metodikk og tilhørende verktøy.
iTouch
App’en som skal lages er et spill, hvor poenget er å taste en gitt tekst feilfritt, raskest mulig. Spillet skal støtte flere samtidige spillere, og alle spillere skal fortløpende se hvordan konkurrenter ligger an underveis, samt status på egen tasting i form av fargekoding av tastet tekst.
Jeg ser for meg en arkitektur der vi kjører JavaScript på server, JavaScript+HTML på klienter, og sanntidsoppdatering av konkurrentstatus over Web Sockets.
Tanken er at på hvert tastetrykk, sender klienten inntastet tekst til serveren for validering. Serveren sender så tilbake et statusobjekt som sier noe om hvorvidt tastet tekst er feil, hvor mye av tekst som gjenstår osv. Det blir så opp til klienten presentere denne statusen til brukeren.
Litt om teknologi
Til Web Sockets-kommunikasjonen skal jeg bruke rammeverket NowJS, som er ganske fascinerende. Det lar deg synkronisere JavaScript-variabler og -funksjoner mellom klient og server, slik at man kan kalle funksjoner på server direkte fra klient, og faktisk også funksjoner definert på klient(er) direkte fra server. NowJS baserer seg på veletablerte SocketIO.
Serverkoden kjører på Node.js, som er blitt en defacto plattform for serverside JavaScript.
Testdrevet utvikling
I JavaScript er det p.t. en rekke testrammeverk tilgjengelig. Jeg har valgt å bruke Jasmine, mest fordi jeg liker BDD-syntaksen, og at man kan benytte rammeverket til å teste både klient- og serverkode. I dag ville jeg nok gått for Buster.JS, men mer om Buster senere.
Man kan fint kjøre Jasmine-tester standalone i en nettleser, men det er fornuftig å benytte seg av en test runner, eksempelvis JS Test Driver. Bloggpost’en til kollegaer Torstein og Børge: ”Getting started with JS and TDD”, gir en grundig innføring i hvordan Jasmine er i bruk, samt hvordan man integrerer med JS Test Driver.
Det avgjørende her er å få lastet ned riktige biblioteker, lagt disse inn i en fornuftig mappestruktur, og fått satt opp en jsTestDriver.conf-fil til å reflektere denne. I tillegg kan det være fornuftig å installere “jstdutil”. Dette verktøyet lar deg kjøre kommandoene “jstestdriver” og “jsautotest” fra kommandolinja. Sjekk siden til Christian Johansen for installasjonsbeskrivelse.
Prosjektet mitt benytter altså Jasmine som testrammeverk, og JS Test Driver for å kjøre testene. Når disse tingene er på plass, er man klar til å skrive første test!
Første test
Dette blir litt som å skrive første setningen i en bok, eller i denne bloggposten for den saks skyld. Jeg tenker det enkleste å teste i min kode måtte være mekanismen som validerer inntastet tekst. Jeg ser for meg at vi oppretter en scoringobjekt som tar inn en tekst, og returnerer et statusobjekt som sier noe om hvilke bokstaver i teksten som evt. er feil.
Vi spesifiserer forventet oppførsel i filen spec/scoring.spec.js:
it("should validate text with 2 errors", function() { var typedText = "DeTte Er en test"; expect(scoring.validate).toBeDefined(); var results = scoring.validate(originalText, typedText); expect(results.errors.length).toEqual(2); expect(results.errors).toEqual([2, 6]); });
og kjører kommandoen ”jstestdriver –tests all –reset”, eller enda bedre ”jsautotest”. Vi får beskjed om å opprette scoring-modulen i namespacet TDD, med funksjonen “validate”. Og etter litt TDD’ing ender vi opp med en mer eller mindre komplett scoring.spec.js med tilhørende implementasjon scoring.js.
Arkitektur
Før vi går videre med tester, kan vi titte litt på kodearkitektur. I tillegg til scoring-modulen, trenger vi funksjonalitet for å administrere selve spillets gang. La oss gi denne modulen navnet “game”. På klientsiden trenger vi en modul som styrer kommunikasjonen med server og oppdatering av skjermbilder; game_controller.js
Hvis vi skisserer opp systemet, så har vi et avhengighetsbilde som ligner dette:
Vi vet at game benytter NowJS-rammeverket for å formidle sanntidsinformasjon, i tillegg til scoringmodulen for å kalkulere status. Samtidig skal game_controller.js kommunisere med game.js vha. NowJS.
Når man nå skal sette sammen disse bitene, er det viktig å tenke at de skal være løst koblet. Dette kan man få til ved å spesifisere eksterne avhengigheter ved opprettelse av modulene. F.eks., får game.js sprøytet inn en referanse til scoring og NowJS når den blir opprettet, og kode for å opprette et game-objekt blir:
var game = TDD.game.create({ originalText: "En tekst", scoring: TDD.scoring.create(), everyone: this.everyone });
Dette gjør det lett å enhetsteste game.js, da man enkelt kan stubbe/mocke disse avhengighetene.
Mer testing
Nå har det liten hensikt å gå i gjennom all kildekode og tester i teksten her, men vi skal se litt mer på hvordan utvalgt funksjonalitet er implementert og testet.
Når det gjelder game-modulen, skal denne bla. ha funksjonalitet for å
- registrere deltakere
- sette i gang en ny omgang
- distribuere forløpende status til alle deltakere
Hvis vi ser nærmere på det siste punktet, så skal status sendes fortløpende til alle deltakerne i sanntid over Web Sockets vha. NowJS. Web Sockets-kommunikasjonen er fullstendig abstrahert bort av rammeverket, og alt man trenger å gjøre er å legge til funksjoner i det spesielle ”now”-objektet som blir synkronisert mellom server og klient. Rammeverket fungerer slik at man kan adressere server via now-objektet på klienten, samt adressere alle klienter via everyone.now-objektet fra serveren.
Når man vet hvordan dette rammeverket fungerer, er man i stand til å spesifisere testen:
it("should validate text and distribute scores upon validation request", function() { var typedText = "Dette er en"; var status = { "errors": [1], "percentage": 98 }; spyOn(this.game.scoring, "validate").andReturn(status); spyOn(this.everyone.now, 'receiveScores'); this.game.validate(this.everyone.now, typedText); expect(this.everyone.now.receiveScores).toHaveBeenCalledWith({ clientId: "123", name: undefined }, status); });
Her bruker vi spy-egenskapene til Jasmine, til å legge til en forventning om at når man kaller ”validate”-funksjonen på serveren, så skal ”receiveScores”-funksjonen kalles på alle klienter.
Selve this.everyone.now er en stub vi selv spesifiserer:
this.everyone = { now: { validate: function() {}, receiveScores: function(clientId, score) {}, startGame: function() {}, displayTextToBeTyped: function() {}, hideStartButton: function() {}, showStartButton: function() {}, clearText: function() {}, gameOver: function() {}, user: { clientId: "123" } } };
Igjen, er det bare å implementere spesifikasjonen og kjøre testene:
Firefox 10.0 Mac OS [PASSED] Game.should massage original text Firefox 10.0 Mac OS [PASSED] Game.should validate text and distribute scores upon validation request Firefox 10.0 Mac OS [PASSED] Game.should NOT end game when text is errornous Firefox 10.0 Mac OS [PASSED] Game.should end game when text is complete with no errors Firefox 10.0 Mac OS [PASSED] Game.should start game on request Firefox 10.0 Mac OS [PASSED] Validation of text and scoring.should validate typed text Firefox 10.0 Mac OS [PASSED] Validation of text and scoring.should validate text with 2 errors Firefox 10.0 Mac OS [PASSED] Validation of text and scoring.should calculate score when typed text is 100% complete Firefox 10.0 Mac OS [PASSED] Validation of text and scoring.should calculate score when typed text is 0% complete Firefox 10.0 Mac OS [PASSED] Validation of text and scoring.should calculate score when typed text is incomplete Total 10 tests (Passed: 10; Fails: 0; Errors: 0) (8.00 ms) Firefox 10.0 Mac OS: Run 10 tests (Passed: 10; Fails: 0; Errors 0) (8.00 ms)
Hva med klienten?
Det er like enkelt å teste klientkode som serverkode, selv om man må hanskes med en DOM. Det avgjørende her er å gjøre klientkoden ”unobtrusive” og ikke mikse JavaScript og HTML-markup mer enn nødvendig.
Vi kan begynne med å tenke litt på hvordan markup’en på klienten skal se ut. Da spillet går ut på taste en gitt tekst, trenger vi i hvert fall et editerbart felt, og et element til å presentere tekst som skal tastes. I tillegg kan vi legge til en knapp for å starte en omgang, samt et element for å vise progress bars. Grensesnittet kan f.eks. se slik ut:
<!DOCTYPE>
<html>
<body>
<div id="wrapper">
<h1>iTouch</h1>
<input id="name" type="text" placeholder="Skriv inn navnet ditt her og trykk ENTER"/>
<button id="startButton">Start ny omgang (vent til alle er klare da)</button>
<div id="text-to-be-typed"></div>
<div id="typed-text" contenteditable="true"></div>
</div>
</body>
</html>
Med disse antakelsene, kan vi utvikle klientlogikken – test først.
Eksempelvis kan vi spesifisere at spillet skal startes når noen klikker på ”Start ny omgang”-knappen på følgende vis:
it("should do start game when start button is clicked", function() { spyOn(this.game, "startGame"); this.startButton.trigger("click"); expect(this.game.startGame).toHaveBeenCalled(); expect($(this.startButton).is(':hidden')).toBeTruthy(); });
Denne testen spesifiserer at startGame-funksjonen skal kalles, og at knappen skal skjules.
$(this.startButton) er et jQuery-wrappet element som er definert slik:
this.startButton = $("<button></button>", { id: "startButton", }).appendTo(document);
og sprøytet inn i game_controller-modulens create-funksjon.
Slik kan vi fortsette; ta f.eks. koden som tester at tekst fargelegges korrekt. Ganske avansert GUI-messig, og ganske enkelt å teste da man kan simulere nettleser-eventer, kjøre css-spørringer osv.:
it("should color text upon text validation and update progress bar", function() { var self = this; var originalText = "Dette er en test"; var typedText = "Dette er En"; var errorIndex = 9; var clientId = "123"; spyOn(this.now, "validate").andCallFake(function() { self.now.receiveScores({ clientId: clientId, name: "Snorre" }, { "errors": [errorIndex], "percentage": 95 }) }); this.game.now.displayTextToBeTyped(originalText); this.typedTextElement.text(typedText); this.typedTextElement.trigger("keyup"); expect(this.now.validate).toHaveBeenCalled(); expect($(this.textToBeTypedElement).find(":nth-child(1)").css("color")).toEqual("rgb(0, 128, 0)"); expect($(this.textToBeTypedElement).find(":nth-child(" + (errorIndex + 1) + ")").css("color")).toEqual("rgb(255, 0, 0)"); expect($(this.textToBeTypedElement).find(":nth-child(" + (typedText.length + 1) + ")").css("color")).toEqual("rgb(0, 0, 0)"); expect(this.wrapperElement.find("#" + clientId).attr("value")).toEqual(95); });
Når man lager JavaScript-koden “unobtrusive” (i mangel av et bedre norsk ord), blir man kvitt all spagettikode man ofte ser i $(document).ready(), og sitter igjen med en ryddig index.html.
Hmmm, hvorfor kjører vi alle testene i Firefox?
Inntil nå, har vi kjørt klientestene og servertesten i kontekst av en nettleser via JS Test Driver. Da vi at vet at serverkoden skal kjøre på Node, gir det ikke mening å la ymse nettleser-JavaScript-motorer - og ikke Nodes Google V8-baserte kjøretidsmiljø, tolke koden vår. Vi kan bote på dette ved å kjøre server testene med jasmine-node. Dette er en Node.js-modul, som installeres med npm (som nå bundles med Node-installasjonen). Da kan vi kjøre servertestene våre med:
~/dev/faggruppe/iTouch(git::master):➔ jasmine-node spec/server --verbose Started .......... Spec Game it should massage original text it should validate text and distribute scores upon validation request it should NOT end game when text is errornous it should end game when text is complete with no errors it should start game on request Spec Validation of text and scoring it should validate typed text it should validate text with 2 errors it should calculate score when typed text is 100% complete it should calculate score when typed text is 0% complete it should calculate score when typed text is incomplete Finished in 0.005 seconds 2 tests, 24 assertions, 0 failures
Dette fremprovoserer også det faktum at vi er nødt til å gjøre om scoring.js-modulen til å være Node/CommonJS-kompatibel, da Node krever dette. Dette gjøres ved å legge til disse linjene i scoring:
if (typeof module === 'object') { module.exports = TDD.scoring; }
Man kan da “hente” ut en referanse til scoring-modulen som en vanlig Node-modul slik:
TDD.scoring = require("./src/server/scoring");
Buster
Her kommer vi tilbake til Buster.js. I tillegg til å være et flott testrammeverk på andre måter, gir Buster deg mulighet til å kjøre klient- og servertester med samme test runner. Buster.js er snart ute av beta, blir spennende å sjekke det ut nærmere.
Skru alt sammen
Når all JavaScript-logikken er ferdig implementert, gjenstår konfigurasjon av Node, slik at vi kan begynne å teste spillet. All denne konfigurasjonen gjøres i filen app.js. Her ser vi hvordan vi oppretter et game-objekt, med alle dets avhengigheter.
Her er et artig lite avbrekk i en ellers hektisk prosjekthverdag:
Oppsummering
Å komme fra en typisk hverdag der jeg skriver “mvn clean install” altfor mange ganger i løpet av en dag, er det utrolig frigjørende å se hvor raskt disse JavaScript-testene kjører. Når man først kommer inn i det, er JavaScript en fornøyelse å jobbe med, spesielt med tanke på egenskapene ved språket. TDD i JavaScript er definitivt ikke vanskelig, og det er like greit å lære seg teknikkene først som sist.
On a side note:
Magnar Sveen snakker om zombietdd-prosjektet sitt i morgen på Framsia at CiA 2012 – Responsive, realtime and Zombies! Meetup’en omhandler forøvrig Web Sockets og Node.js, så det er ikke helt urelatert til det som blir tatt opp her!


