Kerry Buckley

What’s the simplest thing that could possibly go wrong?

Driving Selenium from the RSpec Story Runner (RBehave)

7 comments

[Update] The story runner is now included in the 1.1 release of RSpec, so a lot of the hackery mentioned below is no longer required. See http://rspec.info/ for details.

Background

A while ago I cobbled together some code to drive Selenium from Exactor (which was the acceptance testing framework we were using at the time). That project was offshored shortly afterwards, and I'm pretty sure Selenium never actually got integrated into the build (sneaking a peek at the continuous integration server reveals that even the pre-Selenium acceptance test build hasn't run successfully for months), but I was convinced of the value of Selenium for testing non-trivial web applications.

On my next project (the Web21C SDK portal) we used Selenium on Rails heavily for automated acceptance testing. This worked well, although the fact that the tests are deployed with the application limits what you can do – you can't interrogate the database after a test, for example, or dynamically set up stubs for systems you interface with (unless you also deploy the stubs as part of the application, and drive them from the browser).

With Mojo there's an additional complication: as well as being accessible through a browser, most functionality is also available as a RESTful API, using digest-based authentication. To keep our acceptance tests in one place, we wanted to be able to drive a browser using Selenium, alongside other tests which talked to the server directly. The obvious answer was to use Selenium Remote Control, and I also liked the look of RBehave, which has now been incorporated into RSpec (but not released yet).

The RSpec story runner

The story runner in the upcoming release of RSpec is an evolution of Dan North's RBehave, which in turn is based on JBehave (from the same author). In the past couple of weeks, David Chelimsky has done some excellent work on extracting the text of stories from the code, leading to the plain text story runner.

Read on for the gory details of my solution to running Selenium tests from the story runner.

Putting it all together

Here's what I did to get Selenium working from the story runner (you need to check out RSpec trunk – I'm currently using r2791 of rspec and r2814 or rspec_on_rails).

I'm sure when the next release of RSpec comes out a lot of this will be unnecessary, and there will probably be standard places for the stories and ways of running them, but for now I've just hacked something together that's simple and works. Apologies for any pieces of string or duct tape that are still showing!

The files described below are laid out as follows, relative to the root of the Rails project:

+-- lib
| +-- rspec/
| +-- rspec_on_rails/
| +-- selenium-ruby-client-driver/
| | +-- selenium.rb
| +-- selenium-server/
| | +-- selenium-server.jar
| +-- tasks/
|   +-- acceptance.rake
+-- stories/
| +-- all.rb
| +-- helper.rb
| +-- steps/
| | +-- login.rb
| | +-- selenium.rb
| +-- stories/
| | +-- login.rb
| | +-- login.txt

Here's an example story:

stories/stories/login.txt

RUBY:
  1. Story: Login
  2.   The front page should contain a login form
  3.  
  4.   Scenario: Loading the home page when not logged in
  5.     When the user goes to /
  6.     Then the title should be 'mojo'
  7.     And the page should contain the text 'Welcome to mojo'
  8.     And the page should contain the text 'Sign in using your OpenID'
  9.     And there should be a field named 'openid_url'
  10.     And there should be a submit button named 'login', with the label 'Sign In'
  11.  
  12.   Scenario: Logging in with an unregistered OpenID URL
  13.     When the user goes to /
  14.     And the user types 'some.openid.provider' into the openid_url field
  15.     And the user clicks the login button
  16.     Then the page should contain the text 'Could not find OpenID server'
  17.     And the page should contain the text 'Sign in using your OpenID'
  18.  
  19.   Scenario: Logging in successfully
  20.     Given a test user
  21.     When the user goes to /
  22.     And the user types 'http://dummy.openid/' into the openid_url field
  23.     And the user clicks the login button
  24.     Then the page should contain the text 'Hi test'

Each 'Given', 'When' and 'Then' corresponds to a step (an 'And' is just syntactic sugar for a repeat of the same type of step). The steps are defined in separate files, which can be included as required (see below). This story uses two sets of steps:

stories/steps/login.rb

RUBY:
  1. steps_for(:login) do
  2.   Given "a test user" do
  3.     User.delete_all
  4.     User.create!(:username => 'test', :openid_url => 'http://dummy.openid/',
  5.         :fullname => 'Test User', :email => 'test@example.com',
  6.         :mobile => '0123456789')
  7.     # ActiveRecord::Base.connection.commit_db_transaction
  8.   end
  9. end

stories/steps/selenium.rb

RUBY:
  1. steps_for(:selenium) do
  2.   When "the user goes to $path" do |path|
  3.     $selenium.open path
  4.   end
  5.   When "the user types '$text' into the $field field" do |text, field|
  6.     $selenium.type field, text
  7.   end
  8.   When "the user clicks the $button button" do |button|
  9.     $selenium.click button
  10.     $selenium.wait_for_page_to_load 5000
  11.   end
  12.   Then "the title should be '$title'" do |title|
  13.     $selenium.title.should == title
  14.   end
  15.   Then "the page should contain the text '$text'" do |text|
  16.     $selenium.should have_text_present(text)
  17.   end
  18.   Then "there should be a field named '$field'" do |field|
  19.     $selenium.should have_element_present(field)
  20.   end
  21.   Then "there should be a submit button named '$name', with the label '$label'" do |name, label|
  22.     $selenium.should have_element_present("//input[@type='submit'][@name='#{name}'][@value='#{label}']")
  23.   end
  24. end

Note the use of placeholder parameters, which are parsed out of the lines in the story. The Selenium steps use a global instance of Selenium::SeleniumDriver, which is created in the helper (again, see below). I originally created this in a "Given a browser" step, but it seemed simpler to just create it up front, before running the stories.

stories/stories/login.rb

RUBY:
  1. require File.expand_path(File.dirname(__FILE__) + '/../helper')
  2. run_story_with_steps_for :selenium, :login

This is just a wrapper for the textual story, which specifies which steps are required.

stories/all.rb

RUBY:
  1. dir = File.expand_path(File.dirname(__FILE__))
  2. require "#{dir}/helper"
  3.  
  4. Dir["#{dir}/stories/**/*.rb"].uniq.each { |file| require file }

Another simple wrapper, this time to run all stories.

stories/helper.rb

Warning: This file is where all the ugly code's hiding!

RUBY:
  1. ENV["RAILS_ENV"] = "test"
  2. require File.expand_path(File.dirname(__FILE__) + "/../config/environment")
  3.  
  4. # Hack to use trunk rspec without affecting the rest of the project
  5. $:.delete_if {|dir| dir =~ /rspec/}
  6. $: <<File.dirname(__FILE__) + '/../lib/rspec/lib'
  7. $: <<File.dirname(__FILE__) + '/../lib/rspec_on_rails/lib'
  8.  
  9. # Not sure why, but this is required when rspec_on_rails not running as plugin
  10. require 'spec/rails/matchers'
  11.  
  12. require 'spec/rails/story_adapter'
  13. require File.dirname(__FILE__) + '/../lib/selenium-ruby-client-driver/selenium'
  14.  
  15. Dir[File.dirname(__FILE__) + "/steps/*.rb"].uniq.each { |file| require file }
  16.  
  17. unless $selenium
  18.   $selenium = Selenium::SeleniumDriver.new 'localhost', 4444,
  19.       '*firefox', 'http://localhost:3000/', 10_000
  20.   $selenium.start
  21. end
  22.  
  23. # Don't add an ActiveRecordSafetyListener, or it'll roll stuff back
  24. class Spec::Story::Runner::ScenarioRunner
  25.   def initialize
  26.     @listeners = []
  27.   end
  28. end
  29.  
  30. # Runs the story in the file with the same name as the calling ruby file,
  31. # but with a .txt extension instead of .rb, using the specified steps.
  32. def run_story_with_steps_for *steps
  33.   with_steps_for *steps do
  34.     # Pull the filename of the caller out of the stack. Must be a better way.
  35.     run caller[3].sub(/\.rb:.*/, '.txt')
  36.   end
  37. end
  38.  
  39. # Alias some more ruby-like methods on SeleniumDriver, to make it play
  40. # better with rspec matchers.
  41. module Selenium
  42.   class SeleniumDriver
  43.     alias_method :original_method_missing, :method_missing
  44.     def method_missing method_name, *args
  45.       if method_name.to_s =~ /^has_.*\?$/
  46.         real_method = method_name.to_s.sub /has_(.*)\?$/, 'is_\1'
  47.         if respond_to? real_method
  48.           return send(real_method, args)
  49.         end
  50.       elsif respond_to?(real_method = "get_" + method_name.to_s)
  51.         return send(real_method)
  52.       end
  53.       return original_method_missing(method_name, args)
  54.     end
  55.   end
  56. end

Most of this code doesn't really belong 'loose' in a helper file, but I didn't want to expend too much effort on getting everything working tidily with a pre-release version of RSpec.Hopefully there are enough comments to give a vague idea what's going on, but here are some more notes:

  • Lines 5–10: There still seem to be some bugs in trunk RSpec which cause existing specs to fail (or possibly bugs in our specs that don't show up in 1.0.8). I've installed the trunk versions of the plugins under lib, and here I pull the real plugins out of the load path and insert the trunk versions instead.
  • Lines 17–21: Create a selenium driver, and open a browser session.
  • Lines 23–28: 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.
  • Lines 30–37: A convenience method to remove some code from the story wrappers. Basically just replaces '.rb' with '.txt' and runs that file with the specified steps. However, I can't find a clean way of finding the file name of the code that called a method, so I'm pulling it out of the call stack. Ugh.
  • Lines 39–56: The method names in the ruby interface to Selenium RC are rather java-like (eg get_title and is_text_present). I've used a bit of method_missing hackery to wrap these methods with more ruby-like names (title and has_text_present? for those specific cases), which allows us to use rspec matchers like $selenium.title.should == foo and $selenium.should have_title bar.

lib/tasks/acceptance.rake

Finally, I've created some rake tasks to wrap everything up, and also to start and stop the selenium server.

RUBY:
  1. desc "Run the acceptance tests, starting/stopping the selenium server."
  2. task :acceptance => ['selenium:start'] do
  3.   begin
  4.     Rake::Task['acceptance:run'].invoke
  5.   ensure
  6.     Rake::Task['selenium:stop'].invoke
  7.   end
  8. end
  9.  
  10. namespace :acceptance do
  11.   desc "Run the acceptance tests, assuming the selenium server is running."
  12.   task :run do
  13.     system 'ruby stories/all.rb'
  14.   end
  15.  
  16.   namespace :selenium do
  17.     desc "Start the selenium server"
  18.     task :start do
  19.       pid = fork do
  20.         exec 'java -jar lib/selenium-server/selenium-server.jar'
  21.         exit! 127
  22.       end
  23.       File.open SELENIUM_SERVER_PID_FILE, 'w' do |f|
  24.         f.puts pid
  25.       end
  26.       # wait a few seconds to make sure it's finished starting
  27.       sleep 3
  28.     end
  29.  
  30.     desc "Stop the selenium server"
  31.     task :stop do
  32.       if File.exist? SELENIUM_SERVER_PID_FILE
  33.         pid = File.read(SELENIUM_SERVER_PID_FILE).to_i
  34.         Process.kill 'TERM', pid
  35.         FileUtils.rm SELENIUM_SERVER_PID_FILE
  36.       else
  37.         puts "#{SELENIUM_SERVER_PID_FILE} not found"
  38.       end
  39.     end
  40.   end
  41. end
  42.  
  43. private
  44.  
  45. SELENIUM_SERVER_PID_FILE = 'tmp/pids/selenium_server.pid'

Still to do

There's plenty of scope for improvement here. For a start, I haven't figured out how to generate a report for the test run, apart from the plain text output from the console (which is only usable if you start the selenium server in a separate shell). Also, I can't see an obvious way of returning a success/fail status at the end of the run, which makes it difficult to use in a continuous integration process.

The story runner looks like a very promising tool though, and I can't wait for the stable release.

Technorati Tags: , , , ,

Written by Kerry

November 7th, 2007 at 11:57 am

Posted in Agile,Ruby,Software

7 Responses to 'Driving Selenium from the RSpec Story Runner (RBehave)'

Subscribe to comments with RSS or TrackBack to 'Driving Selenium from the RSpec Story Runner (RBehave)'.

  1. Thanks Kerry for the great post. I was looking for a good summary of the plain text stories and this was a great one. The Selenium integration seems like a very cool idea. I’ll have to look into Selenium.. Do you know if you can have Selenium run on multiple browsers and have them interact with the JS checking the DOM at each intermediate step?

    Ben Mabey

    8 Nov 07 at 4:25 am

  2. Hi Ben, glad you found it useful.

    You can definitely drive multiple browsers from Selenium, but I’m not sure about checking the DOM.

    Kerry

    8 Nov 07 at 8:15 am

  3. [...] week I stumbled upon acceptance testing in rSpec and a very pretty way of writing those [...]

  4. [...] Earlier I wrote about the new plain text story runner in RSpec and the integration of these stories with Selenium. [...]

  5. [...] forefront of my mind since that time and it was great to stumble upon Kerry Buckley’s blog on selenium-rc and rbehave.  In my opinion, the type of approach to testing that he discusses is an absolute necessity if we [...]

  6. [...] website, using plain text stories from David Chelimsky together with the Selenium runner from Kerry Buckley When I wanted to make the stories as clean as possible And I wanted to click a rails generated [...]

  7. Hack again?!

    Blackdanhil

    27 Jan 09 at 1:50 pm

Leave a Reply