(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_tickets sisä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.

1 Response to “Kokemuksia yksikkötesteistä ja mockeista Railsissä”

  1. Antti Tarvainen Says:

    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ä.

Leave a Reply