Writing Macros in RSpec
Thoughtbot put up a good post about creating "macro"s with Shoulda . Shoulda has some very nice built-in macros which help a lot in keeping tests DRY without sacrificing the documentation aspect of tests. (In fact I think these improve the readability of most tests because it removes some of the noise.) As Tammer Saleh said in the post these “macros” are nothing more than normal class methods, and so you can have the same sort of macros in any testing framework. I have been creating and using my own custom macros in RSpec for some time now. RSpec’s awesome DSL adds a little more complexity when creating such macros, but it is still easy to do once you understand some of RSpec’s DSL internals. In this article I explain RSpec’s DSL enough to provide insight on how to write macros. I will then show an example of writing a macro and illustrate how to extract it for reuse.
I should preface this explanation by saying that RSpec’s built in Shared Behaviour capabilities are very powerful and are usually the appropriate solution when abstracting common behaviour. Pat Maddox recently outlined some best practices of using shared behaviours in a refactoring . I generally use macros when I want to pass in an argument so that I can either a) use them multiple times in a given describe context, or b) customize the example and/or the description for specdoc output.
Macro Example: it_should_assign
A useful macro I use in my Rails controller specs isit_should_assign. For example, say we have this simple show action on a nested resource controller:
class UserPhotosController
def show
@user = User.find(params['user_id'])
@photo = @user.photos.find(params['id'])
@page_title = "Best photo evar."
end
end
And the specs dealing with assignment:
describe UserPhotosController do
describe "GET 'users/1/photos/2'" do
before(:each) do
User.stub!(:find).and_return( @user = mock_user )
@user.stub_association!(:photos, :find => (@users_photo = mock_model(Photo)) )
end
def do_get
get :show, :user_id => @user.id, :id => @users_photo.id
end
# ...
it "should assign the user to the view" do
do_get
assigns[:user].should == @user
end
it "should assign the photo to the view" do
do_get
assigns[:photo].should == @users_photo
end
it "should assign the page title to the view" do
do_get
assigns[:page_title].should == "Best photo evar."
end
end
end
As you can see, the three specs are very similar and have some duplication between them. I should point out that duplication in tests is expected and is usually not a bad thing since documentation and readability are primary goals of having a test/spec suite. When extracting duplication from your specs you need to be very careful that these two aspects of the specs are not diminished. A lot of this will depend on the situation and the skill level of your team. In this example I would argue that this is such a common pattern that an abstraction wouldn’t hurt readability and in fact would help reduce some of the implementation noise (such as do_get.)
How do we want the it_should_assign macro to work? This example illustrates the different ways I use it in my specs:
describe UserPhotosController do
describe "GET 'users/1/photos/2'" do
before(:each) do
User.stub!(:find).and_return( @user = mock_user)
@user.stub_association!(:photos, :find => (@users_photo = mock_model(Photo, :caption => "Best photo evar.")) )
end
def do_get
get :show, :user_id => @user.id, :id => @users_photo.id
end
it_should_assign :user # Here, the macro assumes assigns[:user].should == @user
it_should_assign :photo, "@users_photo" # Have flexibility when the name differs in the spec
it_should_assign :page_title, "Best photo evar." # Ability to check literals
end
end
Dissecting part of RSpec's DSL
Before I show the implementation, it is important to realize what the describe keyword really does. The describe keyword in rspec is actually a factory method (that in turn delegates to a factory object) that creates ExampleGroup sub-classes. In rspec source terminology your describe blocks are example groups and your it blocks are the examples. So, you can think of describe as being a sort of wrapper for class. To make this point more clear, we can forgo the use of the describe keyword and subclass the ExampleGroup class ourselves:
class UserPhotosControllerSpec < Spec::Rails::Example::ControllerExampleGroup
describe UserPhotosController, "GET 'users/1/photos/2'"
before(:each) do
User.stub!(:find).and_return( @user = mock_user)
@user.stub_association!(:photos, :find => (@users_photo = mock_model(Photo, :caption => "Best photo evar.")) )
end
def do_get
get :show, :user_id => @user.id, :id => @users_photo.id
end
it_should_assign :user
it_should_assign :photo, "@users_photo"
it_should_assign :page_title, "Best photo evar."
end
end
Inline the macro implementation into the current example group
Alright, so describe just subclasses the correct example group class for us and the block that gets passed in becomes our class definition. With that knowledge we can create our macro as a regular class method inside our example group:
describe UserPhotosController do
describe "GET 'users/1/photos/2'" do
before(:each) do
User.stub!(:find).and_return( @user = mock_user)
@user.stub_association!(:photos, :find => (@users_photo = mock_model(Photo)) )
end
def do_get
get :show, :user_id => @user.id, :id => @users_photo.id
end
def self.it_should_assign(variable_name, value=nil)
it "should assign #{variable_name} to the view" do
raise "Variable '@#{variable_name}' was not defined in the spec" if value.nil? && !instance_variables.include?("@#{variable_name}")
value ||= instance_variable_get("@#{variable_name}")
if value.kind_of?(String) && value.starts_with?("@")
value = instance_variable_get(value)
end
do_get
assigns[variable_name].should == value
end
end
it_should_assign :user
it_should_assign :photo, "@users_photo"
it_should_assign :page_title, "Best photo evar."
end
end
Pretty simple, huh? At this point the macro is only available to that example group. There are several ways you can extract it so that it can be used elsewhere. Before we extract the macro we need to address the problem with the do_get call.
In my controller specs I follow the convention of defining a do_get, do_put, etc. based on the HTTP verb the action responds to. In order for the assigns macro to work across specs for different actions it needs to be able to call the correct do_verb method. We can accomplish this by defining a do_action method which calls the correct method:
describe UserPhotosController do
describe "GET 'users/1/photos/2'" do
...
def do_action
verb = [:get, :post, :put, :delete].find{|verb| respond_to? :"do_#{verb}"}
raise "No do_get, do_post_ do_put, or do_delete has been defined!" unless verb
send("do_#{verb}")
end
def do_get
get :show, :user_id => @user.id, :id => @users_photo.id
end
def self.it_should_assign(variable_name, value=nil)
it "should assign #{variable_name} to the view" do
...
do_action
assigns[variable_name].should == value
end
end
...
end
end
Extracting the macro
With the do_verb calls abstracted into the do_action method we can now extract the macro. The most flexible option is to turn it into a module. The following is a standard ruby module taking advantage of the Module#included hook so both instance and class methods are mixed in:
module AssignMacro
module ExampleMethods
def do_action
verb = [:get, :post, :put, :delete].find{|verb| respond_to? :"do_#{verb}"}
raise "No do_get, do_post_ do_put, or do_delete has been defined!" unless verb
send("do_#{verb}")
end
end
module ExampleGroupMethods
def it_should_assign(variable_name, value=nil)
it "should assign #{variable_name} to the view" do
raise "Variable '@#{variable_name}' was not defined in the spec" if value.nil? && !instance_variables.include?("@#{variable_name}")
value ||= instance_variable_get("@#{variable_name}")
if value.kind_of?(String) && value.starts_with?("@")
value = instance_variable_get(value)
end
do_action
assigns[variable_name].should == value
end
end
end
def self.included(receiver)
receiver.extend ExampleGroupMethods
receiver.send :include, ExampleMethods
end
end
# Now, we can just include it wherever we need it...
describe UserPhotosController do
describe "GET 'users/1/photos/2'" do
include AssignMacro
...
end
end
Extracting the macros out into modules allows you to mix them only into example groups that you want them to be in. In the case of this macro it would be nice to have it available for all controller specs. This is possible by monkey patching the ControllerExampleGroup in your spec_helper.rb:
module Spec::Rails::Example
class ControllerExampleGroup
include AssignMacro
end
end
For controller macros I usually don’t take the extra step of extracting it into a standalone module. Instead, I just monkey patch my class and instance methods directly into ControllerExampleGroup. If you have a macro that you want available for all of your example groups rspec already has a place where you can put them:
module Spec::Example
module ExampleGroupMethods
# place example group methods (class methods) here
end
module ExampleMethods
# place your example helper methods (instance methods) like do_action here
end
end
Summary
Macros are useful when extracting granular facets of behaviour which you want to customize by passing in arguments. They are similar to shared behaviours but live at a different level (the class level) which allows for the additional flexibility. Shared behaviours are usually a better fit when a refactoring causes you to consolidate behaviour in a module or a class higher up in the inheritance chain. Macros tend to evolve out of common usage patterns within your specs and should be created when you want to dynamically create these slightly different specs. Documentation and readability are paramount when using both methods. If a macro saves you a couple of lines of duplicated code, but ends up hurting those two aspects of the spec, don’t use it!
I hope this article is helpful to people new to RSpec and to those who haven’t yet discovered this pattern. If anything is unclear just ask. :) Several people have asked me to release some of my macros and other rspec extensions that I use in all of my rails and merb projects. When I get the time I will clean them up and put them onto my github account . Until then, happy specing!
- Published:
- 08 Jun 2008