Thursday, October 9, 2008

Using Ruby for shell scripting

Someone came to me the other day with a problem. He had downloaded a directory containing a large number of files with spaces in their filenames and needed to get rid of the spaces so he could load them into the tool he was using. He asked me if I knew of a mass rename tool. I didn't know of such a tool but thought it wouldn't be too hard to write something simple.

First I thought of capturing the output of a find and using a text editor search-and-replace create a whole number of mv old_file new_file_without_spaces commands that could all be run. But then I would have to work with some editor's search-and-replace syntax.

Then I thought of writing a shell script. I started to think about find . -e ... but realized I always get confused about the -e syntax and using sed or awk.

Suddenly I realized I could do this with Ruby. About 10 minutes later I had this simple 3 line script, ran it and was done.


Dir[File.expand_path("#{~/file_with_spaces}/*")].uniq.sort.each do |file|
system "mv '#{file}' '#{file.gsub(' ', '_')}'"
end


Ruby is my new shell scripting language and I don't think I'm ever writing a bash shell script again!

Wednesday, October 8, 2008

Undefining a constant in a rspec test

I want to share something I just figured out. I had to write some code that used a class from a plugin if it was there and but did not require the plugin. The problem I faced was how to test this.

My method looked like this
class User 
def lookup_additional_person_info
if defined? CommonServices::Person
person = CommonServices::Person.find_by_id(@person_id)
@address = person.address
else
@address = 'not available'
end
end
end

I knew how to define CommonServices::Person to test the positive case but what about the negative? I found out you can undefine a class by sending its parent :remove_constant

  it 'should default additional parameters if CommonServices::Person is not defined' do
defined?(CommonServices::Person).should be_nil
user = User.new(:person_id=123)
user.lookup_additional_person_info
user.address.should == 'not available'
end

it 'should not populate additional parameters if CommonServices::Person is not defined' do
begin
defined?(CommonServices::Person).should be_nil
module CommonServices
class Person
end
end
CommonServices::Person.expects(:find).with(123).returns(person=mock)
person.expects(:address).returns(address_from_service=mock)

user = User.new(:person_id=123)
user.lookup_additional_person_info
user.address.should == address_from_service
ensure
CommonServices.send(:remove_const, :Person)
end
end

Testing with RSpec stories

I recently completed a project where we used RSpec stories for our integration testing and wanted to share some of my experiences. Overall RSpec Stories are an incredibly powerful tool and have allowed us to take a huge step towards business analysts writing functional specifications (in the form of plain text stories) then allowing the developers to make them pass. Today I'd like to share how we setup our stories and some of the decisions we made along the way.

The first goal was to run the stories from rake so we created a rake task in lib/tasks/stories.rake to run the stories and fail if any of them fail - this was useful when running this from our cruise server.

task :all do
args = ENV['story_prefix'] || ''
stories_passed = system 'ruby', File.join(RAILS_ROOT, "stories", "run_with_all_steps.rb"), args
raise('story failed') unless stories_passed
end

You can invoke this task as rake stories:all or rake stories:all story_prefix=user_ and the second variant allow you to run just one (or a subset) of the stories which turned out to be useful as they take a long time to run. Now we'll move onto stories/run_with_all_steps.rb which is what actually runs the stories.

First some setup of the environment since we're invoked from the command line

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

Then two helper functions that load all stories in a directory or use the story_pattern passed in. This is something I haven't seen other people do with the RSpec story framework but seemed natural to me as I didn't want to have to maintain a list of stories to run and possibly (or probably) forget to add new ones. Stories also have a concept of steps that go along with stories but I didn't want to have to to manually associate steps with stories so decided to load them all. There were some clashes with different step files using the same phrase to define their steps but when that happened we just changed the phrasing of one or the other. The only trick in here is the sort on the step_files which was required because windows and the mac and linux loaded the files in a different order causing inconsistency across machines.

def all_local_stories(story_pattern = "")
story_path = "#{File.dirname(__FILE__)}/stories"
Dir[File.expand_path("#{story_path}/**/#{story_pattern}*.story")]
end

def all_steps_for_local_stories
steps_path = "#{File.dirname(__FILE__)}/steps"
step_files = Dir[File.expand_path("#{steps_path}/*.rb")].sort
step_files.collect do |file|
require file
file.match(/(\w+)\.rb\Z/).captures[0].underscore.to_sym
end
end

Lastly we invoke the rspec story runner. What's tricky here is that the story runner doesn't run here but registers itself using Kernel.at_exit (which I'd never seen before)

stories = all_local_stories(file_pattern)
stories.each do |file|
with_steps_for(*all_steps_for_local_stories) do
run file, :type => RailsStory
end
end

So up to here we can invoke our stories using rake ... but what are stories and how do they work? Stories are just text files written with the template

Story: Manage Users
As an Administrator
I want to manage the users who have access to my application
So that private stuff isn't seen by the wrong people

Scenario: Revoke access for a user
Given Alex is a registered user
When I delete the user Alex
Then Alex should not be able to access the sensitive content

This is pretty cool ... so how does it work? This is where the steps come in. We define a steps file to match each of the Given/When/Then clauses in our story and define what to do for each one.

require File.dirname(__FILE__) + '/../../../spec/spec_helper'

steps_for(:user_management) do
Given('$user_name is a registered user') do |user_name|
@users ||= []
@users << name =""> user_name})
end

When('I delete the user $user_name') do |user_name|
this_user = @users.detect {|a_user| a_user.name == user_name}
this_user.should_not be_nil
this_user.destroy
end

Then('$user_name should not be able to access the site') do |user_name|
get("/login?username=#{user_name}")
response.response_code.should == 401
end
end

As the rspec story runner parses the story file it looks for a step that matches invokes its block passing a parameter for each $-starting variable in the clause. The way I've written what's above we try to maintain some consistency between the variables referred to in the different clauses. If you refer to the user_name as 'Alex' in the Given clause we store that user away in the @users array and load it from there later on when we want to refer to the same user in the When clause.

We've had pretty good success working with stories in this way and as we move forward are going to continue to experiment with
  • Having our business analysts write most of the story files and the developers write the steps.
  • Thinking of ways to keep the steps files DRY
  • Working with WATIR or Selenium to create stories that drive the browser