The project I’m working on has a lot of named scopes which are really great. If you’re not using them already you should really try them out. Since we test drive everything we do, we needed a really easy way to write tests for all these named scopes. We came up with a little test helper method that I thought I’d share so that other people could use it.
Here’s the code:
def test_named_scope(all_objects, subset, condition)
subset.should_not be_empty
subset.each do |obj|
condition.call(obj).should be_true
end
other_objects = all_objects - subset
other_objects.should_not be_empty
other_objects.each do |obj|
condition.call(obj).should be_false
end
end
To use it, just pass a superset of objects, the subset you want to test and then a lambda as a condition. The lambda should be true for all items in the subset and false for all the items outside of it.
It sounds complicated but it’s really easy! Here’s an example
Let’s look at a simple tag class that has a status column indicating whether the tag is on a whitelist or a blacklist. It could look like this.
class Tag < ActiveRecord::Base
WHITELISTED = 1
BLACKLISTED = 0
end
We want to be able to easily grab all the whitelisted tags, so we need to add a named scope.
Here’s the spec we write first:
describe Tag do
describe "whitelisted named_scope" do
it "returns the whitelisted tags" do
test_named_scope(Tag.all, Tag.whitelisted, lambda{|tag|
tag.status == Tag::WHITELISTED })
end
end
end
end
We run the spec, watch it fail and then go add the named scope to our Tag class.
class Tag < ActiveRecord::Base
WHITELISTED = 1
BLACKLISTED = 0
named_scope :whitelisted, :conditions => {:status => WHITELISTED}
end
Then we just rerun the spec and watch it pass. Easy!
Update2: Josh Susser emailed me a really nice refactoring with the enumerable partition method and Kelly fixed a bug I introduced.
def test_named_scope(all_objects, subset, condition)
scoped_objects, other_objects = all_objects.partition(&condition)
scoped_objects.should_not be_empty
other_objects.should_not be_empty
scoped_objects.should == subset
other_objects.should == all_objects - subset
end
Why a lambda and not a block?
June 25, 2009 at 4:14 am
Just because I think passing multiple variables and a block looks weird:
test_named_scope(Tag.all, Tag.whitelisted){|tag| tag.status == Tag::WHITELISTED }
It’d be pretty easy to rewrite it to take a block if you prefer that syntax.
June 25, 2009 at 6:13 am
I’ve needed to do the exact same thing, but ended up with a simpler approach. [Check out some code I ripped from that project.](http://gist.github.com/135781) I’m sure it’s obvious, but to be clear I’m using shoulda, factory_girl, and matchy.
The general idea is to verify the records returned from my named scope `Video.processed` are identical to those returned from `Video.all(:conditions => { :processed => true })`. Because I know Rails will return the data I want using that query, I should simply be able to compare the two result sets and assert they’re equal. If they’re not, something’s broken. If, for some reason, Rails is gives me unprocessed videos with that finder call, then I have much bigger problems.
June 25, 2009 at 3:33 pm
Makes sense Larry, but a lot of our named_scopes are pretty complicated. Here for example is one we’re using to check if users have left feedback on a project
named_scope :with_no_evaluation_by, lambda{|user|
{:conditions => <<-SQL
not exists (select * from evaluations
where evaluations.project_id = projects.id
and creator_id = #{user.id})
SQL
}}
The spec for the named scope looks like this
describe “.with_no_evaluation_by(user)” do
it “returns all etasks without evaluations by the given user” do
jane = users(:jane)
test_named_scope(Project.all, Project.with_no_evaluation_by(jane), lambda{|project| !project.evaluations.map(&:creator).include?(jane) })
end
end
June 25, 2009 at 4:45 pm
Ah! I knew there was a reason you chose your approach instead of the more obvious. That’s quite a hairy scope. I totally understand why you need to explicitly validate the results to guarantee you’re receiving the correct results. Nicely done.
June 26, 2009 at 6:15 pm
I really like Josh’s refactoring, but it doesn’t include your tests that ensure that your test data actually includes items in and out of the conditions defined by the scope. You may want to add:
scoped_objects.should_not be_empty
other_objects.should_not be_empty
and all together:
def test_named_scope(all_objects, subset, condition)
scoped_objects, other_objects = all_objects.partition(&condition)
scoped_objects.should_not be_empty
other_objects.should_not be_empty
scoped_objects.should == subset
other_objects.should == all_objects – subset
end
At first I thought making sure that you had items in both the scoped and non-scoped categories was a little pedantic. On second thought, the point is that you are testing scoping and if your test data doesn’t actually contain objects of both types then your test may be faulty.
Cool stuff. Thanks.
June 27, 2009 at 10:02 am
Ooh, good catch Kelly, I’ll fix the post. Yeah, the tests to make sure both sets actually have objects in them has caught a few bugs in code and more than a few fixture issues.
July 6, 2009 at 11:57 pm
I’m liking this, but it needs a little work to deal with named_scopes that have limits on them.
I modified like this for a quick fix:
def test_named_scope(all_objects, subset, condition, limit = 0)
scoped_objects, other_objects = all_objects.partition(&condition)
other_objects += scoped_objects.slice!(limit..scoped_objects.size) if limit > 1
subset.should_not be_empty
scoped_objects.should == subset
other_objects.should == all_objects – subset
end
July 12, 2009 at 7:09 am
Here’s a more robust version, but it is still trading the ability to use limits for the ability to use ordering:
def test_named_scope(all_objects, subset, condition, limit = 0)
scoped_objects, other_objects = all_objects.partition(&condition)
other_objects += scoped_objects.slice!(limit..scoped_objects.size) if limit > 0
subset.should_not be_empty
scoped_objects.should == subset
other_objects.sort{|a,b|a.id< =>b.id}.should == (all_objects – subset).sort{|a,b|a.id< =>b.id}
end
July 12, 2009 at 7:41 am