Customizing RSpec
Posted by Nick Sieger Tue, 02 Jan 2007 05:32:00 GMT
Update/Disclaimer: I refer to parts of RSpec that are not blessed as an extension API. Redefining before_context_eval
and using the @context_eval_module
variable directly may change in the future. I’ll keep this article updated to coincide with the changes. For now, these techniques should work fine with RSpec versions up to 0.7.5.
RSpec seems to be getting more attention lately as a viable, nay, preferred, alternative to Test::Unit. It’s possible that it’s just my personal feed-reader-echo-chamber, but consider this: Rubinius has started using RSpec alongside Test::Unit as an another way to test the alternate Ruby implementation. They’re even in the midst of building some snazzy extensions to allow the same specs to be run under a Ruby implementation of your choice. (Perhaps this will point the way to a new round of executable specs to accompany the fledgling community spec? Let’s wait and see how they do and leave that topic for another day.)
But extending and customizing RSpec to add a DSL on top of RSpec’s context/specify
framework doesn’t have to be the realm of experts. Here are some templates for how you can DRY up your specs by adding your own helper methods in such a way that they will be available to all your specs. But first, a little background.
Spec Helper
Most usages of RSpec that I’ve seen in the wild use a “spec helper” (spec_helper.rb
). This file, following the pattern of Rails’ test_helper.rb
, minimally contains require statements to pull in the RSpec code and any supporting code for running specs. By requiring the spec helper via a path relative to your spec (usually with require File.dirname(__FILE__) + '/spec_helper'
or similar), it also allows you the convenience of running your specs one at a time from anywhere (say, by launching from your editor) or with rake
or spec
. This file is where your shared helper methods will go, and where they’ll get registered to be pulled into the contexts.
What Context in context
?
context "A new stack" do
# <== What is the value of "self" here?
specify "should be empty" do
end
end
How do those contexts work anyway? The context
method that defines a context in which specs can be defined and run takes a block to define the individual specs, but what can really go in that block?
It turns out that RSpec jumps through metaprogramming hoops (using class_eval
) to make the block behave like a class definition. This means you can do things like put method definitions inside your context:
context "A new stack" do
def a_new_stack
Stack.new
end
specify "should be empty" do
a_new_stack.should_be_empty
end
end
Which is nice, but the reason we’re here is to hide that away in spec_helper.rb
. So, to get back to the point of the comment in the first example above, the self
inside the context block is an anonymous Module
object. It’s constructed in the initialize
method of a Context
(condensed from spec/runner/context.rb
in the RSpec codebase):
class Spec::Runner::Context
def initialize(name, &context_block)
@name = name
@context_eval_module = Module.new
@context_eval_module.extend ContextEval::ModuleMethods
@context_eval_module.include ContextEval::InstanceMethods
before_context_eval
@context_eval_module.class_eval(&context_block)
end
def before_context_eval
end
end
(Take note of that empty before_context_eval
method and the fact that it’s invoked during context initialization; that’s where we can plug in our custom extensions.)
The object held by the @context_eval_module
instance variable is being augmented in two ways: extension and inclusion. The object is extended with the ContextEval::ModuleMethods
module; these methods are being added to the object’s singleton class. This has the effect of making these methods visible within the context
block, functioning similar to “class” methods.
The object also has the ContextEval::InstanceMethods
module included. This has the effect of adding these as instance methods, making them visible from within specify
blocks, which are made to behave like instance methods on the same object.
Putting it together
Technique | Visibility | Use |
---|---|---|
@context_eval_module.extend | Context block | Custom setup, shared state declaration |
@context_eval_module.include | Specify block | Shared actions/functions, stub/expectation modification, encapsulate instance variables |
Adding specialized setup methods
spec_helper.rb
snippet:
module SharedSetupMethods
def setup_new_stack
setup do
@stack = Stack.new
end
end
end
class Spec::Runner::Context
def before_context_eval
@context_eval_module.extend SharedSetupMethods
end
end
Example spec:
context "A new stack" do
setup_new_stack
specify "should be empty" do
@stack.should_be_empty
end
end
Adding shared accessors
spec_helper.rb
snippet:
module StackMethods
attr_accessor :stack
def push_an_object
stack << mock("some object")
end
end
class Spec::Runner::Context
def before_context_eval
@context_eval_module.include StackMethods
end
end
Example spec:
context "A stack with an object pushed" do
setup do
@stack = Stack.new
end
specify "should not be empty" do
stack.should_be_empty
push_an_object
stack.should_not_be_empty
end
end
The examples are simple, but hopefully illustrate the techniques. For an example of some code that’s actually useful, check out my sample RSpec Selenium RC integration project, in particular the spec helper and the example spec. (More on this in the future if it proves useful, but for now if you check it out and run rake
on it, it should launch Selenium RC and run the example spec in a Firefox browser.)
By mixing and matching these techniques, you can layer a mini-DSL on top of RSpec and achieve DRY-er and even more readable and intention-revealing specs. Let me know if you’re able to find uses for these tips!
I’m using RSpec in my svntl project (http://code.google.com/p/svntl/) and had the same need to extend my contexts with custom methods. All I’ve done though was extending module Spec::Runner::ContextEval::ModuleMethods with methods (as I’ve described in this snippet: http://www.bigbold.com/snippets/posts/show/3210).
Your recipe solves the general problem, but most of the time you won’t need anything more than this simple straightfoward solution.
Great post - thanks Nick.