Test Driven Development met Ruby on Rails
Al een paar jaar wordt Test Driven Development (TDD) gepredikt in de software industrie, maar helaas ben ik het nog nooit in het wild tegen gekomen. Totdat ik in aanraking kwam met Ruby on Rails.
TDD komt uit het Extreme Programming (XP) kamp en hoewel XP een belangrijke inspiratiebron is voor nieuwe methodologie hypes, zoals Agile Development, zijn er maar weinig plekken waar deze aanpak wordt toegepast. XP is tÈ extreem voor mensen die opgegroeid zijn aan de waterval. Er zou te veel tijd gespendeerd worden aan het schrijven van tests en pair programming kost alleen maar geld (ìmoet ik dan steeds twee blikken programmeurs optrekken?î).
De meest directe impact van XP op programmeurs is TDD. Sindsdien heeft elk programmeerplatform een test framework (in veel gevallen zelfs een ruime keuze aan frameworks) en is Continuous Integration (CI), geautomatiseerd bouwen en testen van broncode, bijna niet meer weg te denken in een software ontwikkelstraat. Maar wat houdt TDD precies in?
TDD is een manier van werken. Een programmeur die TDD hanteert schrijft eerst tests en daarna pas de eigenlijke code. Het proces ziet er, versimpeld, als volgt uit:
- schrijf een test
- zie de test falen, de daadwerkelijke code is immers nog niet geschreven
- schrijf de code
- zie de test slagen
Deze aanpak heeft een aantal voordelen. De meest voor de hand liggende is dat de correctheid van de code gecontroleerd wordt, maar daar heb je dit proces niet voor nodig. Juist de volgorde van handelingen levert bij TDD de meerwaarde. De programmeur wordt tijdens het maken van de initiÎle test nogmaals gedwongen na te denken over wat hij gaat maken, maar dan vanaf de andere kant. In plaatst van ìhoe ga ik dit probleem oplossenî, moet hij zich afvragen aan welke voorwaarden het resultaat moet voldoen. Daarnaast wordt hij gedwongen zín werk in stukjes op te delen en krijgt hij de mogelijkheid de nog niet geschreven code te ìvoelenî.
Maar als er zoveel frameworks en tools zijn om te TDD-en, waarom kom je het dan nog steeds zelden tegen in de praktijk? Simpel: gemak. Hoe prachtig de frameworks ook zijn en hoeveel features een CI systeem ook heeft, zolang het schrijven van tests niet gemakkelijker is dan het schrijven van de code die getest wordt, gaan ontwikkelaars niet aan de TDD. Misschien moet het schrijven van tests zelfs leuker zijn dan het schrijven van code.
Ruby on Rails
Ruby on Rails, de web framework hype van 2006, is een op het Model View Controller (MVC) patroon gebaseerd full-stack web framework. Het heeft alles in zich dat nodig is om database-driven webapplicaties te schrijven. Ruby on Rails is geschreven in en voor Ruby, een meer dan 10 jaar oude dynamische object-georiÎnteerde programmeertaal uit Japan.
Ruby on Rails heeft een enorme impact gehad op hoe vandaag de dag webapplicaties gebouwd worden. Daarnaast heeft Ruby on Rails de programmeertaal Ruby op de kaart gezet (sinds januari 2007 op de 10de plaats van de TIOBE Programming Community Index). Na de introductie, medio 2004, zijn er veel nieuwe web frameworks, in andere talen dan Ruby, gebouwd die in variÎrende mate de onderscheidende factoren van Ruby on Rails proberen te implementeren.
Onder deze onderscheidende factoren vallen, ìconvention over configurationî waarbij configuratie van de applicatie pas aangepast hoeft te worden als er van de ìstandaardî aanpak afgeweken wordt. ìReloadableî, aanpassingen in de code zijn meteen in de webbrowser terug te zien. Een console omgeving waarin code in de context van de applicatie ìliveî uitgeprobeerd kan worden. En, zeker niet als laatste maar relevant voor dit artikel, standaard drie executieomgevingen: ìdevelopmentî voor de omgeving waarin de ontwikkelaar werkt, ìproductionî waarin de applicatie gedeployed wordt en ìtestî voor de testcode. De omgevingen hebben alle drie een eigen database en zijn standaard anders geconfigureerd als het gaat om bijvoorbeeld caching en logging.
Testing On Rails
Ruby on Rails heeft zeer uitgebreide testfaciliteiten en slaagt er, naar mijn mening, in om het schrijven van testcode gemakkelijk te maken. Zo gemakkelijk dat TDD vanzelfsprekend wordt. Om het schrijven van tests te stimuleren worden er bij het aanmaken van modelobjecten en controllers zelfs automatisch lege testclasses aangemaakt. Er wordt alles aan gedaan argumenten van de programmeur om geen tests te schrijven, weg te nemen.
Rails onderscheidt drie soorten tests: unit, functional en integration. Rails gebruikt deze termen anders dan in de industrie gangbaar is. De eerste variant is een klassieke test waarbij de conventie geldt dat er een enkele class wordt getest op API-niveau. De andere twee soorten concentreren zich op controllerlogica; in functional tests worden individuele controllers onderhanden genomen en in integration tests kan de interactie tussen controllers getest worden.
Een kort voorbeeld van een unittest:
class AuthorTest < Test::Unit::TestCase def test_type assert_kind_of Author, Author.find(:first) end def test_lastname_required author = Author.new(:firstname => 'Remco') assert !author.valid?, 'author should not be valid' assert author.errors.on(:lastname), 'lastname field should contain an error' end end
De bovenstaande unittestcode voor een Author bevat een testcase met daarin twee tests. Methodes waarvan de naam begint met test_ zijn individuele tests. In elke test wordt een enkel aspect of een enkele methode uit de te testen class gevalideerd. Er zijn nog enkele speciale methodes. De belangrijkste daarvan is: setup. setup wordt aangeroepen vÛÛr elke test, hiermee wordt testdata klaargezet.
Fixtures
Het klaar zetten en opruimen van testdata is iets dat voor zoveel tests geldt dat het Rails framework hier standaard functionaliteit voor heeft genaamd ìfixturesî. Fixtures worden voor het laden van elke test automatisch in de testdatabase geladen.
Deze fixtures worden in de vorm van YAML bestanden gedefinieerd. YAML is net als XML een bestandsformaat om gegevens in op te staan. Hier volgt een voorbeeld van testdata voor mín AuthorTest, authors.yml:
remco: id: 1 firstname: Remco lastname: van 't Veer david: id: 2 firstname: David lastname: Heinemeier Hansson
De voorgaande fixtures verzorgen twee Author records in de testdatabase. In tests kunnen deze aangepast en weggegooid worden. Omdat al deze mutaties na het uitvoeren van een test meteen ongedaan worden gemaakt, wordt een consistente testset gewaarborgd.
Alle fixtures hebben een label, in het voorbeeld zijn dat remco en david, die in de tests gebruikt kunnen worden om dat specifieke record op te halen. Fixtures in actie:
class AuthorTest < Test::Unit::TestCase fixtures :authors def test_delete assert_equal 2, Author.count authors(:remco).destroy assert_equal 1, Author.count end end
Buiten Ruby On Rails zijn mijn ervaringen met het automatisch laden testdata schaars of pijnlijk. Het is vaak wel mogelijk maar niet zonder eerst allerlei verschillende software, die allemaal een stukje van het probleem oplossen, aan elkaar te knopen en ter plekke afspraken te maken over waar de data binnen het project ondergebracht gaat worden.
Een Rails applicatie heeft fixtures zodat dat de programmeur niet over het laden van testdata hoeft na te denken. Testdata is zoín algemeen onderdeel van tests dat dit een significante stap is in het ondersteunen van TDD ontwikkeling. Het is zo voor de hand liggend zelfs dat je je af kan vragen waarom een populair Java framework zoals Spring hier geen direct antwoord op heeft.
Functional tests
Een Rails applicatie in de ìdevelopmentî omgeving is ìreloadableî. Dat betekent dat de server zelden herstart hoeft te worden. In de webbrowser hoeft alleen maar een reload gedaan te worden om aanpassingen in je code uit te proberen. Dat is mooi maar niet echt iets nieuws, als je met PHP werkt kan je ook gewoon op reload rossen en in een goed ingerichte Java ontwikkelomgeving ook.
Deze reloadable manier van werken is niet TDD. Helaas is het in de meeste ontwikkelomgevingen toch dÈ manier om je applicaties te ontwikkelen omdat het vaak de enige manier is om je controllercode in actie te zien. In Rails functionele tests kan je de browser tot op een zeker niveau simuleren en asserts doen op het antwoord van je controller. Een voorbeeld:
class ArticleControllerTest < Test::Unit::TestCase fixtures :articles, :authors def setup @controller = ArticleController.new @request = ActionController::TestRequest.new @response = ActionController::TestResponse.new end def test_index get 'index' assert_response :success end end
In dit voorbeeld zie je het gebruik van de setup methode. Deze methode is al voor je gemaakt door Rails en hoeft maar zelden aangepast te worden.
Interessant is de test_index methode. Deze methode roept de index actie op de ArticleController aan met de get methode. Deze get simuleert een HTTP GET request. Naast GET kunnen er ook POST, HEAD en zelfs de minder bekende PUT en DELETE HTTP requests gedaan worden. Na de get wordt er getest, met assert_response of er een ìsuccessî status is gegeven uit de HTTP 200 range. Er kan ook getest worden op een ìredirectî of ìerrorî status.
Dat is allemaal vrij basic, laten we het iets ingewikkelder maken. In de volgende test bekijken we een artikel met de show actie en testen we of de titel van het artikel op de resultaatpagina getoond wordt:
def test_show get 'show', :id => articles(:tdd_rails).id assert_select 'h1', :text => articles(:tdd_rails).title end
De get methode roept nu de show actie aan met een request parameter genaamd ìidî. De assert_select methode wordt een CSS selector gegeven om een element uit de resultaatpagina te selecteren. Met de :text optie wordt de verwachte inhoud van het geselecteerde element gegeven. Als deze ìselectieî niet gedaan kan worden faalt de assert.
Vrij krachtig allemaal maar het kan nog uitgebreider:
def test_destroy_without_and_with_login post 'destroy', :id => articles(:tdd_java).id assert_redirected_to :action => 'login' post 'login', :author => { :username => 'remco', :password => 'ocmer' } assert_equal session[:login], Author.find_by_username('remco').id post 'destroy', :id => articles(:tdd_java).id assert_raise ActiveRecord::RecordNotFound do Article.find(articles(:tdd_java).id) end end
Dit voorbeeld probeert een artikel te verwijderen en stelt vast dat er dan naar de loginpagina gesprongen wordt. Vervolgens wordt er ingelogd en getest of dit gelukt is door te controleren of het ìidî van de author op sessie staat. Tot slot wordt nogmaals geprobeerd het artikel te verwijderen en getest of het ook echt verdwenen is.
Met deze functional tests hebben we onze applicatie kunnen testen zonder een browser te open. Sterker nog, zonder onze applicatie te bouwen. Uiteraard falen onze tests zonder de index, show, destroy en login acties te implementeren maar dat is juist wat we wilden bereiken. De implementatie is bijna een saai klusje geworden dat ook nog gedaan moet worden. Gelukkig valt deze implementatie buiten de scope van dit artikel!
Integration tests
In functional tests kunnen de acties in maar ÈÈn controller getest worden. Het doel van een controller is het bundelen van acties die met elkaar te maken hebben. In het voorbeeld van de ArticleController zou de login actie door die zelfde controller geÔmplementeerd moeten worden. Eigenlijk zou die operatie ondergebracht moeten worden in een andere controller, LoginController ofzo. Dan kunnen andere gegroepeerde operaties, een AttachmentController bijvoorbeeld, ook gebruik maken van deze loginfunctie.
Om deze ìcross controllerî operaties ook te kunnen testen zijn op een gegeven moment integration tests in Rails (sinds versie 1.1) geÔntroduceerd. In dit soort tests wordt het hele routing proces doorlopen. Routing is de vertaling van een binnenkomend URL naar de juiste controller en actie. De get en post (etc.) methodes wordt in deze tests een URL gegeven en geen actienaam.
De functional tests voor het verwijderen van een artikel, kan er na het onderbrengen van het inloggen in een eigen controller, als integration test als volgt uitzien:
class ArticleDestructionTest < ActionController::IntegrationTest fixtures :articles, :authors def test_destroy_without_login post 'article/destroy', :id => articles(:tdd_java).id assert_redirected_to 'login' end def test_destroy_with_login post 'login', :author => { :username => 'remco', :password => 'ocmer' } assert_equal session[:login], Author.find_by_username('remco').id post 'article/destroy', :id => articles(:tdd_java).id assert_raise ActiveRecord::RecordNotFound do Article.find(articles(:tdd_java).id) end end end
Naast het testen van routing en daarmee het kunnen aanspreken van meerdere controllers geven integration tests ook nog de mogelijkheid om meerdere sessies te openen. Zo kan er naast de interactie tussen controllers ook de interactie tussen gebruikers getest worden.
Een voorbeeld voor een veiling site:
class BiddingTest < ActionController::IntegrationTest fixtures :users, :items def test_david_winning_a_speedboat_bid remco = open_session remco.post 'login', :user => {:name => 'remco', :password => 'ocmer'} remco.assert_response :success david = open_session david.post 'login', :user => {:name => 'david', :password => 'divad'} david.assert_response :success remco.post_via_redirect 'bid', :id => items(:speedboat).id, :amount => 10 remco.assert_select "div.highest span.owner", :text => 'remco' david.get 'show', :id => items(:speedboat).id david.assert_select "div.highest span.owner", :text => 'remco' david.post_via_redirect 'bid', :id => items(:speedboat).id, :amount => 11 david.assert_select "div.highest span.owner", :text => 'david' end end
Dat is mooi! Maar het kan nog mooier. We kunnen de sessie verrijken met operaties waarmee we een DSL kunnen vormen voor onze bidding test:
class BiddingTest < ActionController::IntegrationTest fixtures :users, :items def test_remco_winning_a_speedboat remco = login('remco', 'ocmer') david = login('david', 'divad') remco.assert_item_is_not_mine(items(:speedboat)) remco.bid(items(:speedboat), 10) remco.assert_item_is_mine(items(:speedboat)) david.assert_item_is_not_mine(items(:speedboat)) david.bid(items(:speedboat), 11) david.assert_item_is_mine(items(:speedboat)) remco.assert_item_is_not_mine(items(:speedboat)) remco.bid(items(:speedboat), 12) remco.assert_item_is_mine(items(:speedboat)) end def login(name, password) user = open_session do |user| def user.login(name, password) @name = name post 'login', :user => {:name => name, :password => password} assert_response :success end def user.show(item) get 'show', :id => item.id assert_response :success end def user.assert_item_is_mine(item) show(item) assert_select "div.highest span.owner", :text => @name end def user.assert_item_is_not_mine(item) show(item) assert_select "div.highest span.owner", :text => @name, :count => 0 end def user.bid(item, amount) post_via_redirect 'bid', :id => item.id, :amount => amount assert_response :success end end user.login(name, password) user end end
De bovenstaande code zal ik niet statement voor statement door nemen. Het is een illustratie van de mogelijkheden die het Ruby on Rails framework aan programmeurs biedt om helemaal los te gaan. Vaak moet ik mezelf in houden om niet zoveel testcode te schrijven dat die code ook weer tests nodig heeft..
Tests testen?
Veel Continuous Integration omgevingen verzorgen ook informatie over hoeveel code er aangeraakt wordt door tests, ook wel test-coverage genoemd. In deze analyses is tot op broncodeniveau zichtbaar welke code er precies wel en niet uitgevoerd wordt tijdens het draaien van tests. De uitdrukking ì100% test-coverageî betekent dat alle code aangeraakt wordt tijdens het draaien van de testcases. Dit is echter erg misleidend, 100% is gemakkelijk te halen door simpelweg alle code aan te roepen. Maar zonder te controleren, met asserts, of de code zich correct gedraagt zijn je tests vrijwel waardeloos.
Coverage tools moet je dus met een korreltje zout nemen. Het is wel aardig om af en toe naar deze rapporten te kijken of je niet per ongeluk in je pre-TDD gedrag teruggevallen bent. Voor Ruby projecten zoals een Rails applicatie kan je deze analyses uitvoeren met rcov, een coverage tool voor Ruby code.
Toch zijn er ook manieren om Ècht de dekking van je tests te analyseren, Mutation Testing. Mutation Testing een methode waarbij tests gevalideerd worden door de code die getest wordt te muteren. Als, na het muteren van code, geen tests falen zijn de tests niet compleet. Een voorbeeld van een mutatie kan bijvoorbeeld inhouden dat de conditie in een if-constructie altijd waar wordt gemaakt. Als de tests voor deze code hier niets van merken, zijn deze dus niet compleet.
Op het Ruby platform kan je je tests testen met Heckle. Deze tool muteert je code, draait dan je tests en toont de gemuteerde code als je tests niet falen. Heckle genereert niet zulke mooie rapporten als rcov en deze analyses zijn veel intensiever dan coverage tests. Toch kan het een belangrijke bijdrage leveren in een TDD proces.
Continuous Integration
Ruby on Rails bevat geen Continuous Integration oplossing. Onder CI wordt het automatisch herbouwen en testen van de applicatie verstaan. Omdat Ruby een geÔnterpreteerde taal is, is herbouwen niet relevant. Voor het automatisch testen van code is er een Ruby tool beschikbaar die dit heel gemakkelijk maakt; autotest, een utility uit de ZenTest library.
De autotest utility start een proces dat alle bronbestanden in een applicatie in de gaten houdt. Dit proces draait op de ontwikkelomgeving en geeft de programmeur meteen na opslaan feedback door alle relevante tests te draaien. Zo ziet hij meteen of de veranderingen die zojuist opgeslagen zijn tests breken of niet.
En verder?
Ruby on Rails is een prachtig systeem en geeft je een complete omgeving om de Test Driven Development methode toe te passen. Ikzelf ben bijna obsessief als het gaat om schrijven van tests. In de projecten waar ik Rails gebruik heb ik over het algemeen meer testcode dan applicatiecode. Er worden dan ook zelden nog basale bugs gevonden door testers en daardoor is er meer tijd over voor de gevallen waar ik simpelweg nog niet goed begrepen heb, wat er precies gebouwd moest worden.
De volgende stap is Behaviour Driven Development (BDD), waar je de software specificaties schrijft in de vorm van tests. In de Ruby gemeenschap is RSpec een populaire library om dit soort declaratieve tests te schrijven. Er is zelfs een Rails plugin beschikbaar zodat je ìspecsî kan schrijven voor je webapplicatie.
Ik denk dat BDD de toekomst is en dat specificaties, eindelijk via tests, aan code gekoppeld kunnen worden. Mín favoriete quote:
Programs should be written for people to read, and only incidentally for machines to execute.
zou ik graag aanvullen met:
Programmers should only write code to satisfy tests.


