Zach BrockZach Brock
An easy way to write named scope tests
edit Posted by Zach Brock on Thursday June 25, 2009 at 03:12AM

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 Matthew O'Connor on June 25, 2009 at 04:14AM

    Why a lambda and not a block?

  2. Zach Brock Zach Brock on June 25, 2009 at 06:13AM

    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. Larry Marburger Larry Marburger on June 25, 2009 at 03:33PM

    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. 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 Zach Brock on June 25, 2009 at 04:45PM

    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. Larry Marburger Larry Marburger on June 26, 2009 at 06:15PM

    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 Kelly Felkins on June 27, 2009 at 10:02AM

    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 Zach Brock on July 06, 2009 at 11:57PM

    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. Matt Van Horn Matt Van Horn on July 12, 2009 at 07:09AM

    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. Matt Van Horn Matt Van Horn on July 12, 2009 at 07:41AM

    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