Close
Glad You're Ready. Let's Get Started!

Let us know how we can contact you.

Thank you!

We'll respond shortly.

LABS
An easy way to write named scope tests

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

Comments
  1. Matthew O'Connor says:

    Why a lambda and not a block?

  2. Zach Brock says:

    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.

  3. 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.

  4. Zach Brock says:

    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

  5. 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.

  6. Kelly Felkins says:

    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.

  7. Zach Brock says:

    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.

  8. 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

  9. 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

Post a Comment

Your Information (Name required. Email address will not be displayed with comment.)

* Copy This Password *

* Type Or Paste Password Here *