RSpec is a wonderful Behaviour Driven Development
framework that is also very suitable for Rails applications. After
giving a talk on it for the Scotland Ruby usergroup I found that people are most impressed with its
plain text story runner feature. I have found documentation on this
rather meager so I created this post on how to setup a rails app
from scratch that has a story with some scenarios that all pass. Once I had this in place the rest was a piece of cake*...
To find out more about how the story runner works have a look at
the links at the bottom of this post. The peepcode on the issue is
good and between the posts by David Chelimsky - the creator of this stuff - there is
some useful information as well.
The central idea is to define each part of an acceptance test (given ... when ... then ...) as an English sentence. These sentences
are then defined as 'steps' which perform the corresponding action or
test. Once you have built up a decent library of steps a client can
have a good shot at writing his own acceptance tests. Any sentences that are not yet
covered by a step will show up as pending.
On with the practical business!
We do not need much of a rails app for us to be able to test it with a
story. I will just use a fresh rails install with the needed RSpec and restful_authentication plugins. Like so:
$ rails storyapp
$ cd storyapp
$ rake db:create:all # after setting up database.yml for your system
$ ./script/plugin install git://github.com/dchelimsky/rspec.git
$ ./script/plugin install git://github.com/dchelimsky/rspec-rails.git
$ ./script/generate rspec
$ ./script/plugin install git://github.com/technoweenie/restful-authentication.git
$ ./script/generate authenticated user
$ rake db:migrate
$ rake db:test:prepare
Add the following snippet to your config/routes.db to define login/logout routes:
map.with_options :controller => "sessions" do | page |
page.login "/login", :action => "new"
page.logout "/logout", :action => "destroy"
end
At this stage you should be able to start your rails server and
find a login form at http://localhost:3000/login. Our
application behaves as follows: if you login with invalid credentials
(all of them are until a user is created) you will be shown the login
form again. If you create a user from http://localhost:3000/users/new
this user will be created in the database and you will be redirected
to "/" (for now still showing the default rails page). So let's write
a story that tests this behaviour. First there is some more setting up to do though...
I am choosing the convention to name my text stories stories/foo_story and
the corresponding runner stories/foo_story.rb. All steps will be named
stories/steps/foo_steps.rb. My spec_helper will automatically require
all the step files in the stories/steps directory. This is how we set
this up:
$ mkdir stories/steps
and add stuff to your stories/helper.rb so that it looks like this:
ENV["RAILS_ENV"] = "test"
require File.expand_path(File.dirname(__FILE__) + "/../config/environment")
require 'spec/rails/story_adapter'
require 'ruby-debug'
dir = File.dirname(__FILE__)
Dir[File.expand_path("#{dir}/steps/*.rb")].uniq.each do |file|
require file
end
##
# Run a story file relative to the stories directory.
def run_local_story(filename, options={})
run File.join(File.dirname(__FILE__), filename), options
end
This is the story we will be using, so go ahead and save that as stories/login_story:
Story: User logging in
As a user
I want to login with my details
So that I can get access to the site
Scenario: User uses wrong password
Given a username 'jdoe'
And a password 'letmein'
When the user logs in with username and password
Then the login form should be shown again
Scenario: Creates a new user
Given a username 'jdoe'
And a password 'letmein'
And an email 'jdoe@test.com'
And there is no user with this username
When the user creates an account with username, password and email
Then there should be a user named 'jdoe'
And should redirect to '/'
And this is the corresponding stories/login_story.rb which just runs the file we just created using the steps we are about to define:
require File.dirname(__FILE__) + "/helper"
with_steps_for(:login) do
run_local_story "login_story", :type => RailsStory
end
Save the steps for this story to stories/steps/login_steps.rb:
steps_for(:login) do
Given "a username '$username'" do |username|
@username = username
end
Given "a password '$password'" do |password|
@password = password
end
Given "an email '$email'" do |email|
@email = email
end
Given "there is no user with this username" do
User.find_by_login(@username).should be_nil
end
When "the user logs in with username and password" do
post "/sessions/create", :user => { :login => @username, :password => @password }
end
When "the user creates an account with username, password and email" do
post "/users/create", :user => { :login => @username,
:password => @password,
:password_confirmation => @password,
:email => @email }
end
Then "the login form should be shown again" do
response.should render_template("sessions/new")
end
Then "there should be a user named '$username'" do |username|
User.find_by_login(username).should_not be_nil
end
Then "should redirect to '$path'" do |path|
response.should redirect_to(path)
end
end
When you put a $something in the sentence of a step, RSpec will
extract a value from a matching sentence in your story and pass that
into the block. Note that what you put after your $ is just fluff. It
is good practice to put quotes around your values so that there are
less cases where the regexp matcher will get confused and split things
up in a way you did not expect. Some character based regexp
expressions are allowed as well. So Then "it has $n users?" will match
"then it has 2 users" as well as "then it has 1 user".
We're all set! Run:
$ ruby stories/login_story.rb
et voilà:
2 scenarios: 2 succeeded, 0 failed, 0 pending
Success!
Here is a tarball with the git repos of the steps taken in this article.
Additional info on RSpec's plain text story runner:
*: the cake is a lie!
Update 2008-05-27: I have changed the plugin install commands to use the git repositories instead. The original install commands were:
$ ./script/plugin install svn://rubyforge.org/var/svn/rspec/trunk/rspec
$ ./script/plugin install svn://rubyforge.org/var/svn/rspec/trunk/rspec_on_rails
$ ./script/plugin install http://svn.techno-weenie.net/projects/plugins/restful_authentication/