Sham - Lightweight Factories for Ruby on Rails Testing

06 December 2010

In general test-driven development in Rails couldn't be easier, but while writing your tests you'll eventually run into the case where you need to create a valid model so that you can test some extended functionality. Take a look at this RSpec test.

describe Cart do
  it "should be able to calculate the total price" do
    user = User.create \
      :username => "username@example.com",
      :password => "password", 
      :password_confirmation => "password"
    one = Item.create :name => "Item One", :price => 10.00, :weight => 1.0
    two = Item.create :name => "Item Two", :price => 20.00, :weight => 2.0
    cart = Cart.create :user => user
    li1 = LineItem.create :cart => cart, :item => one, :quantity => 1
    li2 = LineItem.create :cart => cart, :item => two, :quantity => 1
    cart.price.should == li1.price+li2.price
  end
end

The problem with this approach is that if you modify the validations on Item, Cart or User you'll end up going back and adjusting your test. For example, if you required that items had a quantity that was positive, you would have to go back and add a quantity parameter every time you create an item - major pain in the ass. Not to mention your tests start to become quite wordy.

one = Item.create :name => "Item One",
        :price => 10.00,
        :weight => 1.0,
        :quantity => 10

Fortunately there are a couple of gems that are available for creating factories. Factories are "minimally valid models" that can be reused to create valid models. I have experimented with a couple of options, like factory girl or machinist. But I ran into issues with sequences (sequentially generated attributes that are used to avoid validation errors) or I disliked the way the gems and factories were setup and used in my tests. This led me to create my own lightweight factory gem called sham.

Here's how you can get started using Sham.

1. Install the Sham Gem.

gem install sham

Or add it to your Gemfile:

gem "sham"

And re-install your Bundle:

bundle install

2a. If you are using RSpec or Test::Unit, enable Sham in your test.rb file.

config.after_initialize do
  Sham::Config.activate!
end

2b. If you are using Cucumber, enable Sham in your features/support/env.rb file.

require 'sham'
Sham::Config.activate!

3. Create factories with your default attributes.

# in sham/item_sham.rb
class Item::Sham
  def self.options
    { :quantity => 10, :weight => 1.0, :price => 10.0, :name => Sham.string! }
  end
end

# in sham/line_item_sham.rb
class LineItem::Sham
  def self.options
    { :quantity => 1, :item => Sham::Base.new(Item) }
  end
end

# in sham/cart_sham.rb
class Cart::Sham
  def self.options
    { :user => Sham::Base.new(User) }
  end
end

# in sham/user_sham.rb
class user::Sham
  def self.options
    {
      :username => "#{Sham.string!}@example.com",
      :password => "password",
      :password_confirmation => "password"
    }
  end
end

4. Write Your Tests.

describe Cart do
  it "should be able to calculate the total price" do
    cart = Cart.sham!
    li1 = LineItem.sham! :cart => cart
    li2 = LineItem.sham! :cart => cart
    cart.price.should == li1.price+li2.price
  end
end

Much Simpler! Here are a couple things to keep in mind:

You can override attributes.

You can override any attributes that have been defined in a sham. For example, either of the following is perfectly acceptable:

Item.sham! :quantity => 100
Item.sham!

You can create random strings.

Sham.string! will generates a random string that can be used as a filler or to avoid validation issues. For example if there is a requirement that your usernames are unique, you can use Sham.string! to avoid users having the same username.

Normally, this would fail because the two users would have the same username:

User.create :username => "username@example.com"
User.create :username => "username@example.com"

But, this will not fail because each username will be globally unique:

User.sham! :username => "#{Sham.string!}@example.com"
User.sham! :username => "#{Sham.string!}@example.com"

You can nest shams.

Sham::Base.new(User) tells Sham that when it's creating a model it should create a User sham if one is not specified. That means that either of these two calls is valid:

Cart.sham! :user => User.sham!(:username => "person@example.com")
Cart.sham!

Check out the documentation for additional examples and features. Feel free to file an issue if something is not working as you would expect. There's much more you can do with this gem and while I do plan on continuing to develop features, they will remain lightweight and simple.