Kokemuksia yksikkötesteistä ja mockeista Railsissä
November 29th, 2007
(Pidin lyhyen esityksen aiheesta Tampereen Ruby-käyttäjien kokouksessa 29.11.07, ja siksi materiaali on suomeksi)
Miksi?
- TDD suosittua Rails-projekteissa, yksikkötestit taas keskeisiä TDD:ssä
- Hyvien testien kirjoittaminen vaikeaa
Yksikkötestit
Millaisia ovat hyvät yksikkötestit? (as if I knew…)
No ainakin ne ovat
- nopeita suorittaa
- riippumattomia muista (eristettyjä, ortogonaalisia)
- eivät käytä levyä tai tietokantaa (...ainakaan kirjoita ) => voidaan suorittaa rinnakkain! (Deep Test)
- kuitenkin helppolukuisia(!)
Mock-oliot
Kahdenlaisia “jäljitelmä”olioita, ks. Martin Fowlerin Mocks Aren’t Stubs
Erityisesti hyödyllisiä, kun
- todellinen olio on hyvin hidas. Esimerkiksi rakentajaolio, joka työstää joukosta tiedostoja jonkin toisen joukon tiedostoja
- todellinen olio käyttäytyy epädeterministisesti, esimerkkeinä vaikkapa satunnaislukugeneraattori, säätilan seurantajärjestelmän osa, verkkoliitäntä…
- todellisen olion instantiointi on vaikeaa, esimerkiksi rakentaja vaatii paljon parametreja tai parametriolioita, joiden instantiointi on vastaavasti vaikeaa
- olion käyttäytyminen tilanteessa, jonka laukaiseminen tai luominen on hankalaa, esimerkiksi levytilan täyttyminen.
- todellista oliota ei ole vielä olemassa
- rajapinnan löytäminen (interface discovery)!
(Suurimmasta osasta krediitit Tim McKinnonille)
Esimerkki
Kehnompi versio
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 |
require File.dirname(__FILE__) + '/../spec_helper' describe TicketsController do before(:each) do @project = Project.create!(:owner => 'DSII', :name => 'moff.jerjerrod@empire.mil') @ticket = Ticket.create!(:summary => 'one proton torpedo could wreak havoc...', :classification => 'destructive', :submitter => 'Piet') end it "should render index template containing created tickets" do get :index, :project_id => @project.id response.should be_success response.should render_template('index') response.should have_text("<td>#{@ticket.summary}</td>") response.should have_text("<td>#{@ticket.classification}</td>") end it "should list user's recent tickets" do ticket_count = 20 user_id = 42 flexmock(Ticket).should_receive(:find). with(:all, :conditions => ["project_id = ? AND user_id = ?", @project.id, user_id], :limit => ticket_count). once. and_return([@ticket]) get :recent_tickets, { :project_id => @project.id, :count => ticket_count, :user_id => user_id } end ... def post_ticket_data { :summary => 'trooper armor protection is insignificant', :classification => 'severe', :submitter => 'Veers'} end it "should create a new ticket" do old_count = Ticket.count post :create, :ticket => post_ticket_data Ticket.count.should == old_count+1 end it "should redirect to the new ticket on successful save" do post :create, :ticket => post_ticket_data response.should redirect_to(project_ticket_path(@project.id, @ticket.id)) end it "should re-render 'new' on failed save" do post :create, :ticket => { :invalid_field => '' } flash[:errors].should_not be_nil response.should render_template('new') end end |
Ongelmia?
- toistoa koodissa, testattava asia ei tule selkäesti esille
recent_ticketssisältää toimintalogiikkaa, joka sopii paremmin mallille- käyttää tietokantaa (
ActiveRecord::Base#create) - testaa epäsuorasti mallia (mitä jos testin malli ei ole enää validi?)
- näkymätesti on vahvasti kytketty layouttiin
- ...
Parempi(?) versio
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 |
require File.dirname(__FILE__) + '/../spec_helper' module PTContext def setup_project_tickets @project = flexmock(:model, Project, :id => 1, :owner => 'DSII', :name => 'moff.jerjerrod@empire.mil') @ticket = flexmock(:model, Ticket, :id => 1, :summary => 'one proton torpedo could wreak havoc...', :classification => 'destructive', :submitter => 'Piet') flexmock(Project).should_receive(:find). with("#@project.id"). and_return([@ticket]) end end describe TicketsController, "listing tickets" do include PTContext before(:each) do setup_project_tickets request_index end it "should be successful" do response.should be_success end it "should render index template" do response.should render_template('index') end # oikeasti tämä kuuluu ihan toiseen paikkaan (@spec/views/tickets/index_rhtml_spec.rb@) it "should contain ticket data inside proper elements" do response.should have_tag("#ticket_#{@ticket.id}") do with_tag(".summary", @ticket.summary) with_tag(".classification", @ticket.classification) end end end describe TicketsController, "listing recent tickets" do include PTContext before(:each) do setup_project_tickets end it "should list user's recent tickets" do ticket_count = 20 user_id = 42 flexmock(Ticket). should_receive(:find_recent_tickets_by_user). with(user_id, ticket_count). once get :recent_tickets, { :project_id => @project.id, :count => ticket_count, :user_id => user_id } end end describe TicketsController, "posting new ticket" do before(:each) do @project = flexmock(:model, Project, :id => 1) @ticket = flexmock(:model, Ticket, :id => 1) @ticket_class = flexmock(Ticket) end it "should create new ticket when it has valid data" do @ticket.should_receive(:save).once post_request_valid_returning(true) end it "should redirect to the new ticket on successful save" do post_request_valid_returning(true) response.should redirect_to(project_ticket_path(@project.id, @ticket.id)) end it "should not save invalid ticket" do @ticket.should_receive(:save).never post_request_valid_returning(false) end it "should re-render 'new' on invalid ticket post with errors on flash" do post_request_valid_returning(false) flash[:errors].should_not be_nil response.should render_template('new') end def post_request_valid_returning(bool) hsh = { :baz => 'bif' } @ticket_class.should_receive(:new).with(hsh).and_return(@ticket) @ticket.should_receive(:valid?).and_return(bool) post :create, :ticket => hsh end end |
Lopuksi
Mock-olioiden avulla yksikkötesteistä voi helpommin kirjoittaa eristettyjä, jolloin refaktorointi helpottuu ja yksittäinen virhe löytyy nopeammin. Lisäksi levyoperaatioiden välttäminen mock-olioilla tekee testeistä nopeampia sekä mahdollistaa rinnakkaisen suorittamisen.
TDD:stä vielä sen verran, että siellä testit ovat oikeasti spesifikaatioita, mutta ajatukseen tottuminen on
kognitiivisesti haasteellista, koska työkalujen terminologia liittyy aina
testeihin. Jos TDD BDD kiinnostaa, kokeile joko
RSpeciä (creme de la creme for BDD’in) tai Test/Spec:iä
(käyttää Test::Unitia, auttaa siirtymään vähitellen
RSpeciin)
Päivitys 30.11.07: kuulin juuri, että RSpec on nykyään täysin yhteensopiva Test::Unitin kanssa tehden test/specin sikäli tarpeettomaksi.

December 3rd, 2007 at 11:39
RSpecin trunk on siis Test::Unitin kanssa yhteensopiva, nykyinen versio (1.0.8) ei vielä ole. 1.1.0 julkaistaneen lähiaikoina. Tavoite oli, että se olisi marraskuussa valmis, vaan eipä ollut vielä.