IdolHands.com :: Days in the Life of an Alpha Geek
I love writing RSpec tests, striving toward the goal of a field of green dots and 100% coverage (I know, it's a disease). But I also hate to repeat myself, and I don't really want to have to update my specs unless I'm adding significant new functionality.
A great example of this is data-driven behaviour; for example, controller methods that adapt based on a parameter (such as a filter or order_by parameter), the values of which aren't necessarily nailed down at the time the spec is written. You'll either end up not having tests for all possible parameter values, or you'll end up writing a lot of tests for a little functionality.
Larry Wall proved long ago that all good programmers are lazy. But there's lazy, and then there's lazy. Basically, you have three options:
Presumably, if you choose option 1, I can't help you. If, however, you want to be lazy and smart, there's a DRY way of writing very flexible tests.
Remember that although it looks like magic and seems to work like magic, RSpec's domain-specific language (DSL) is actually executable Ruby code. As such, you can do standard Ruby things with it and get expected results.
For example, there's no reason that you can't generate some specs in real-time, programmatically:
describe 'displays a listing for users' do
before do
Role.each{ |r| rand(10).times{ User.make(:role => r) } }
end
Role.each do |r|
it "of role #{r.name}" do
get :users, :show => r[:name]
assigns(:users).should == Users.find_all_by_role(r.id)
end
end
end
This generates specs for each defined role when the test is run. If any of them fail, you'll see the specific expectation that failed:
'UsersController CRUD GET index displays a listing for users of role nobody' FAILED
Cool, huh?
That's fine for iterating over models, but what about testing parameter-driven views? Suppose you have a filter-enabled view in a controller that you need to test. Each filter is defined by a named scope. You want to test each filter, but you don't want to write n tests, one for each filter.
In your model, you have defined a constant with values that map human-readable labels with the names of a named scopes:
FILTERS = [
{:scope => "all", :label => "All"},
{:scope => "active", :label => "Active"},
{:scope => "inactive", :label => "Inactive"},
{:scope => "visible", :label => "Visible"},
{:scope => "invisible", :label => "Not Visible"},
{:scope => "high_rated", :label => "High-Rated"},
{:scope => "low_rated", :label => "Low-Rated"},
{:scope => "broken", :label => "Uh Oh"}
]
And the corresponding named scopes:
named_scope :active, :conditions => { :is_active => true }
named_scope :inactive, :conditions => { :is_active => false }
named_scope :visible, :conditions => { :is_visible => true }
named_scope :invisible, :conditions => { :is_visible => false }
named_scope :high_rated, :conditions => [ "rating > ?", 3 ]
named_scope :low_rated, :conditions => [ "rating < ?", 3 ]
named_scope :also_broken, :conditions => { "bad_attribute = ?", "fail" ]
So your controller code looks something like this:
def index
@filters = Product::FILTERS
if params[:show] && @filters.collect{|f| f[:scope]}.include?(params[:show])
@products = Product.send(params[:show]).order_by(params[:by], params[:dir])
else
@products = Product.order_by(params[:by], params[:dir])
end
end
This method examines the :show parameter to determine which, if any, filter (defined in Product::FILTERS) to apply to the users that will be displayed in the view. If an invalid (or no) parameter is passed, it falls through to displaying all products.
A lazy way of testing this would be to have two tests: one in which the parameter is not set, and the other passing a hardcoded parameter through (like 'active'). The tests would pass and the coverage would be reported as 100% as far as this method was concerned. But what if there's a problem under the hood with the filter (like that 'broken' filter)? You'd never know it from your two tests.
That doesn't mean that you have to hand-code specs in your products controller for each filter, though. Again, generate them programmatically:
describe 'displays products' do
before do
rand(50).times{ Product.make }
end
Product::FILTERS.each do |f|
it "filtered by #{f.label}" do
get :products, :show => f[:scope]
assigns(:products).should == Product.send(params[:show])
end
end
end
This test will detect that bad filter and is future-proof to boot.
What about that bad named scope in the model, though?
named_scope :also_broken, :conditions => { "bad_attribute = ?", "fail" ]
Let's use some reflection and generate tests for our named scopes in the model spec. It took some digging to figure out how to retrieve a list of named scopes for a given model, but there is a way: Product.read_inheritable_attribute(:scopes).keys gives us [:active, :inactive, :visible, :invisible, :high_rated, :low_rated, :also_broken, :scoped]. So we can iterate over that list to call each named scope in turn:
describe 'named scope' do
_exceptions = [:ordered, :something_else_requiring_a_parameter, :scoped]
(SitePage.read_inheritable_attribute(:scopes).keys - _exceptions).each do |ns|
it "#{ns.to_s} returns products" do
SitePage.send(ns).size.should_not be_nil
end
end
end
Note that :scoped is a special case and needs to be removed from our list of named scopes. Similarly, you'll get a failure if any of your named scopes require parameters; in that case, you'll want to write specs for those scopes, and keep them out of the generated test by adding them to the _exceptions array.