Ejecutando Pruebas de Aceptación con Story Runner de RSpec

He estado utilizando rspec ya desde hace tiempo (gracias a chrisstrum) y me mola mazo :) Te sientes muy cómodo y es muy intuitivo y incluso estoy aprendiendo como y cuando utilizar mocks/stubs (aunque todavía tengo algunos fixtures por ahí) Tenia ganas de aprender sobre el nuevo story runner y mientras estaba googleando me encontré con un post de Kerry Buckley en el cual explica un poco como montar story runner para que funcione y de paso describe como se lo monto para que story runner ejecutara las pruebas de aceptación de Selenium.

Ahora os explico un poco lo que Story Runner es…

Básicamente te permite escribir especificaciones en un fichero de texto normal y corriente y ademas en lenguaje natural (Es mas natural si los haces en ingles :). Básicamente escribes una historia (parafraseando a Dan North “una descripción de un requerimiento y su beneficio, y un conjunto de criterios con los cuales todos estamos de acuerdo que está hecho”.)

Para cada historia puedes escribir varios diferentes escenarios (imagine el requerimiento en diferentes situaciones) y por cada escenario escribes un conjunto de criterios que determinan como el escenario puede satisfacerse con éxito.

e.g.
1
2
3
4
5
6
7
8
9
10
11
12
13

 Story: UI
  As a developer                                                  #
  I want to go to the uimockups page                              # Description of intent
  So that I can implement the mockup                              #

   Scenario: Going to the /uimockups page when not logged in      <= Scenario Description
     Given an anonymous user                                      #
     When the user goes to /uimockups                             #
     Then the document title should be 'personal'                 # criteria, actions & expectations
     And the page should contain the text 'done by Webtypes'      #  
     And the page should have a field named 'strip-search-input'  #
     And the page should have a form named 'strip-search'         #

Dado un fichero de texto como este, luego escribes un pequeño script de ruby (ver /stories/stories/project.rb mas abajo) que coge el texto, lo parsea buscando palabras clave. Cada Given, When and Then es un Step (peldaño). Los Ands son el mismo tipo de ‘step’ como el anterior step.

Ejecutado tal cual, te devolverá la misma historia pero con la diferencia que cada uno de las lineas debajo de Scenario estarán marcados con “pending” que básicamente significa que la historia todavía no ha sido implementado. e.g.

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

saimon@artemis~/dev/projects/myrailsapp$ ruby stories/stories/project.rb
(in /Users/saimon/dev/projects/myrailsapp)
Running 1 scenarios

Story: UI

  As a developer
  I want to go to the ui page
  So that I can see the mockup

  Scenario: Going to the /ui page when not logged in

    Given an anonymous user (PENDING)

    When the user goes to /ui (PENDING)

    Then the document title should be 'personal' (PENDING)
    And the page should contain the text 'done by Webtypes' (PENDING)
    And the page should have a field named 'strip-search-input' (PENDING)
    And the page should have a form named 'strip-search' (PENDING)

1 scenarios: 0 succeeded, 0 failed, 1 pending

Pending Steps:
1) UI (Going to the /ui page when not logged in): Unimplemented step: an anonymous user

Para que la historia aprueba, necesitas implementar cada uno de los Steps en ruby. i.e. Aquí hay une ejemplo de la implementación del 2º step:

  • “When the user goes to /ui (PENDING)”
1
2
3
4
5
6

steps_for(:project) do
  When "the user goes to $path" do |path|
    get path
  end
end

Como puedes ver, está básicamente parseando la linea para una palabra clave que indica un step (en este caso ‘When’), y entonces coge el resto de la linea y intenta emparejar con cualquier de los When steps que conoce. Incluso, da un paso mas y te permite incluir variables para que puedas extraer datos directamente de la linea de la historia ($path en este caso).

Una vez emparejado, acaba ejecutando:

1
2

get /uimockups

Lo cool de esto es que una vez has implementado un step, se puede reutilizar cada vez que se encuentra en una historia. Puedes incluso tener un conjunto de steps ya implementados para reutilizarlos en múltiples historias. Cambien, podridas crear una librería de steps para ser utilizados en otras aplicaciones. Así que aunque al principio, tienes un gasto de tiempo mas, es una inversión que con tiempo da fruto.

Estaba en la reunión del grupo de Ruby/Rails de BCN anoche y unos de los presentes expresó un duda sobre la fragilidad del sintaxis. De hecho, no hay problema porque story runner marcará como pendiente cualquier linea que no ha podido emparejar contra los steps que conoce, así que puedes identificar con facilidad un problema de sintaxis. Y si algo que ya ha reconocido se lanza una excepción, nos dará el stack trace apropiado indicando el step que lo causo.

Después de haber visto el screen-cast de Pat Maddox me ha convencido que utilizar el story runner es una buena manera de empezar de escribir tus especificaciones. Puedes simplemente empezar por escribir una historia que describe una funcionalidad y entonces mientras entras mas a fondo en la historia vas implementando los steps. Por el camino, encuentras que necesitas implementar controladores, modelos, helpers and vistas y antes de hacerlo escribes la especificación apropiada (solo lo bastante para tener la funcionalidad en la historia) que a su vez mueve la implementación del objeto.

Finalmente, llego a la razón principal que escribí este post.

Tengo ganas de poder hacer mis pruebas de integración con el story runner y también, de vez en cuando, hacer pruebas de aceptación en el mismo navegador y como también tenia ganas de jugar con FireWatir & SafariWatir decidí adaptar el código de Kerry un poquito.

Pero añadí otro requerimiento. Lo que realmente querría era una integración transparente entre pruebas de integración utilizando rspec mismo y pruebas de aceptación en el navegador y entonces ejecutara los mismos escenarios o incluso poder mezclar los como querría.

Después de un rato llegué al siguiente montaje (bastante similar al original de Kerry):

Estructura de directorios:

1
2
3
4
5
6
7
8
9
10
11
12
13

+-- lib
| +-- tasks/
|   +-- acceptance.rake
+-- stories/
| +-- all.rb
| +-- helper.rb
| +-- steps/
| | +-- project.rb
| | +-- watir.rb
| +-- stories/
| | +-- project.rb
| | +-- project.txt

Nota: Como en el articulo de Kerry he subdividido el directorio principal “stories” a los subdirectorios “stories” y “steps”. No hace falta si no tienes tantas historias para escribir pero a mi me gusta que esté todo organizado.

/stories/all.rb
1
2
3
4
5
6

dir = File.dirname(__FILE__)
require "#{dir}/helper"
Dir[File.expand_path("#{dir}/stories/**/*.rb")].uniq.each do |file|
  require file
end

/stories/stories/project.txt

1
2
3
4
5
6
7
8
9
10
11
12

Story: UI
  As a developer
  I want to go to the ui page
  So that I can see the mockup

  Scenario: Going to the /ui page when not logged in
    When the user goes to /ui
    Then the document title should be 'personal'
    And the page should contain the text 'done by Webtypes'
    And the page should have a field named 'strip-search-input'
    And the page should have a form named 'strip-search'

/stories/stories/project.rb

1
2
3
4

#Ejecutarme con: [BROWSER=firefox|safari|ie] ruby stories/stories/project.rb
require File.join(File.dirname(__FILE__), "../helper") 
run_story_with_steps_for (browser ? [:watir_project, :project] : [:project])

/stories/steps/project.rb

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

steps_for(:project) do
  Given "a test user" do
    User.delete_all
    User.create!(:name => 'test', :openid_url => 'http://dummy.openid/',
                :email => 'test@example.com')
  end
  
  When "the user goes to $path" do |path|
    get path
  end
  
  Then "the document title should be '$title'" do |title|
    response.should have_tag('title', title)
  end

  Then "the page should contain the text '$text'" do |text|
    response.should have_text(/#{text}/)
  end
  
  Then "the page should have a field named '$field'" do |field|
    response.should have_tag("input[type=text][id=?]", field)
  end
  
  Then "the page should have a form named '$form'" do |form|
    response.should have_tag("form[id=?]", form)
  end  

  Then "the page should have a submit button named '$name', with the label '$label'" do |name, label|
    response.should have_tag("input[type=submit][id=?][value=?]", name,label)
  end  
end

/stories/steps/watir_project.rb

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

steps_for(:watir_project) do
  When "the user goes to $path" do |path|
    browser.goto "http://localhost#{path}"
  end
  
  When "the user types '$text' into the $field field" do |text, field|
    browser.text_field(:name,field).set(text)
  end
  
  When "the user clicks the $button button" do |button|
    browser.button(:value, button).click
  end

  Then "the document title should be '$title'" do |title|
    browser.title.should == title
  end

  Then "the page should contain the text '$text'" do |text|
    browser.text.include?(text).should be_true
  end
  
  Then "the page should have a field named '$field'" do |field|
    (browser.text_field(:name, field).exists? || browser.text_field(:id, field).exists?).should be_true
  end
  
  Then "the page should have a form named '$form'" do |form|
    (browser.form(:name, form).exists? || browser.form(:id, form).exists?).should be_true
  end  

  Then "the page should have a submit button named '$name', with the label '$label'" do |name, label|
    tf = (browser.text_field(:name, field) || browser.text_field(:id, field)).exists?().should be_true
    tf.value.should == label
  end
end

/stories/helper.rb :

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

ENV["RAILS_ENV"] = "test"
require File.expand_path(File.dirname(__FILE__) + "/../config/environment")
require 'spec/rails/story_adapter'

# watir gem
require 'firewatir' if ENV['BROWSER'] && ENV['BROWSER'] == 'firefox'
require 'safariwatir' if ENV['BROWSER'] && ENV['BROWSER'] == 'safari'

def start_ff
  FireWatir::Firefox.new
end

def start_safari
  safari = Watir::Safari.new
end


#Require steps in steps dir
Dir[File.dirname(__FILE__) + "/steps/*.rb"].uniq.each { |file| require file }

#Require appropriate watir browser object
if !$ff && ENV['BROWSER'] == 'firefox'
  $ff = start_ff_with_logger
end

if !$sf && ENV['BROWSER'] == 'safari'
  $sf = start_safari_with_logger
end

#Choose which browser to use in steps
def browser
  $ff || $sf
end

def run_story_with_steps_for *steps
  with_steps_for *(steps.flatten) do
    # Pull the filename of the caller out of the stack. Must be a better way.
    run caller[3].sub(/\.rb:.*/, '.txt'), :type => RailsStory
  end
end

# By default, RSpec adds an ActiveRecordSafetyListener to the story runner. 
# This rolls back database changes between scenarios, which is great if your calling your code directly, 
# but obviously means that if you write to the database, the server that Selenium's talking to can't see them. There's probably a cleaner way of disabling it.
class Spec::Story::Runner::ScenarioRunner
  def initialize
    @listeners = []
  end
end

module ::ActionController #:nodoc:
  module TestProcess
    # Work around Rails ticket http://dev.rubyonrails.org/ticket/1937
    # Helps to remove annoying html parser warnings
    def html_document
      @html_document ||= HTML::Document.new(@response.body, true, true)
    end
  end
end

Una vez montado todo eso puedes ejecutar:

1
2
3

saimon@artemis~/dev/projects/myrailsapp$ 
ruby stories/stories/project.rb

para ejecutar la historia “project” utlizando rspec basico. Da el siguiente output:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

Running 1 scenarios

Story: UI

  As a developer
  I want to go to the ui page
  So that I can see the mockup

  Scenario: Going to the /ui page when not logged in

    Given an anonymous user

    When the user goes to /ui

    Then the document title should be 'personal'
    And the page should contain the text 'done by Webtypes'
    And the page should have a field named 'strip-search-input'
    And the page should have a form named 'strip-search'

1 scenarios: 1 succeeded, 0 failed, 0 pending

Genial! Ahora puedes coger el fichero project.txt y mandaselo a un cliente, un compañero desarrollador, una lista de correos de un proyecto etc…

Pero, vamos a dar el paso siguiente y ejecutar esa misma historia contra Firefox (ir a FireWatir y sigue las instrucciones. Son fáciles.)

Arranca firefox con -jssh
1
2
3
4
5
6
7
8

saimon@artemis~/dev/projects/myrailsapp$ 
/Applications/Firefox.app/Contents/MacOS/firefox -jssh

ejecuta la historia con el variable de entorno BROWSER:

saimon@artemis~/dev/projects/myrailsapp$ 
BROWSER=firefox ruby stories/stories/project.rb

y observa como FF es dirigido como si por magia a ejecutar todo lo que hay en los escenarios de tu historia(s). Cuando acabe de da el mismísimo output que antes pero esta vez como si lo hubieses hecho tu mismo en el navegador.

Lo que también hice fue escribir unos comandos de rake para simplificar ejecutar todos tus historias con o sin el navegador.

Añade este fichero:

/lib/tasks/acceptance.rake

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

desc "Run the acceptance tests, starting/stopping the test server."
task :acceptance_with_browser => ['acceptance:server:start'] do
  begin
    Rake::Task['acceptance:run'].invoke
  ensure
    Rake::Task['acceptance:server:stop'].invoke
  end
end
%w(firefox safari).each do |browser|
  Object.class_eval <<-EOS
    desc "Run the acceptance tests using the #{browser} browser."
    task :acceptance_with_#{browser} do
      $browser = '#{browser}'
      Rake::Task['acceptance_with_browser'].invoke
    end
  EOS
end
 
namespace :acceptance do
  desc "Run the acceptance tests."
  task :run do
    system "#{$browser ? "BROWSER='#{$browser}' " : ''}ruby stories/all.rb"
  end

  namespace :server do
    desc "Start the mongrel server"
    task :start do
      system 'script/server -e test -d'
      sleep 5
    end

    desc "Stop the mongrel server"
    task :stop do
      if File.exist? MONGREL_SERVER_PID_FILE
        pid = File.read(MONGREL_SERVER_PID_FILE).to_i
        Process.kill 'TERM', pid
        FileUtils.rm MONGREL_SERVER_PID_FILE
      else
        puts "#{MONGREL_SERVER_PID_FILE} not found"
      end
    end
  end
end

MONGREL_SERVER_PID_FILE = 'tmp/pids/mongrel.pid'

Y entonces:

1
2
3
4
5
6
7
8
9
10
11

saimon@artemis~/dev/projects/myrailsapp$ 
rake acceptance:run

o

rake acceptance_with_firefox

o 

rake acceptance_with_safari

y ya está… :)

He disfrutado mucho montado todo esto y aunque solo me planteo escribir especificaciones para el navegador muy de vez en cuando, mola tener la opción y por su valor geek.

Disfruta especificando… :)


Volver a articulos