Squirrel and the custom nut matcher

By Nathan Donaldson

Tags:

Squirrel and the custom nut matcher

I recently found squirrel, and I wanted to use it for a project we’re working on to simplify some complex finder statements. Squirrel allows turning something like this:

Task.find(:all,
:conditions => [
'active = ? and (updated_at > cache_version or cache_version IS NULL)', true
]
)

into:

Task.find(:all) do
active == true
any do
updated_at > cache_version
cache_version.nil?
end
end

The problem is testing

Then I ran into a serious problem – how to test this piece of code using rspec? Here was my first attempt:

it 'should find all active tasks where updated_at is greater than cache_version or cache_version is null' do
Task.should_receive(:find).with(:all).and_return(@tasks)
Task.update_cache
end

This doesn’t test the search conditions at all. So I moved on to yield:

Task.should_receive(:find).with(:all).and_yield
received unexpected message "active"

Then I started adding in the expectations:

Task.should_receive(:active)
Task.should_receive(:find).with(:all).and_yield

But how do I know that active is being compared to true? Now I’d have to use a mock to do that:

mock_active = mock(:active)
mock_active.should_receive(:==).with(true)
Task.should_receive(:active).and_return(mock_active)
Task.should_receive(:find).with(:all).and_yield

As you can see, this is getting quite painful. So it was time to abstract this out into something more meaningful. I created a class called FindWithSquirrel:

class FindWithSquirrel
include Spec::Matchers

def initialize(klass, expected)
@klass = klass
@expected = expected
end

def verify
end
end

And I added a way to gain access to the class:

class Class
def should_receive_squirrel_find_with(expected)
FindWithSquirrel.new(self, expected)
end
end

Now the new class needs to extend ActiveRecord to override find and record what happens:

class FindWithSquirrel
def initialize(klass, expected)
...
extend
end

def extend
@klass.class_eval %Q{
def self.find_with_finds
@find_with_finds ||= []
end

def self.find_with_find_with(*args, &blk)
find_with_finds << find_without_find_with(:query, &blk).to_find_conditions
find_without_find_with(*args, &blk)
end

class << self
alias_method :find_without_find_with, :find
alias_method :find, :find_with_find_with
end
}
end
end

So now I’ve got a variable on the model class holding an array of generated conditions. I can fill in the verify method:

class FindWithSquirrel
def finds
@klass.find_with_finds
end

def verify
finds.should include(@expected)
end
end

That’s all fine, but now I need to make rspec actually call my verify method. I can do that by reusing the way rspec mocks work. I can do that by adding my class instances to the same array that rspec adds it’s mock expectations:

class FindWithSquirrel
def initialize(klass, expected)
...
$rspec_mocks.add(self) unless $rspec_mocks.nil?
end
end

Now the rspec mock framework is expecting to call the methods ‘rspec_verify’ and ‘rspec_reset’, so:

class FindWithSquirrel
alias_method :rspec_verify :verify
def rspec_reset
end
end

Now I can run this spec, and it works great. But it seems to break all the subsequent specs that also use the find function. I have to flesh out that reset function a little. Remember my earlier extend function – I need to remove my ActiveRecord extensions:

class FindWithSquirrel
def unextend
@klass.class_eval %Q{
class << self
alias_method :find, :find_without_find_with
end
}
end

alias_method :rspec_reset, :unextend
end

So what does my spec look like?

it 'should find all active tasks where updated_at is greater than cache_version or cache_version is null' do
Task.should_receive_squirrel_find_with(
["(tasks.active = ? AND (tasks.updated_at > tasks.cache_version OR tasks.cache_version IS NULL))", true]
)

Task.update_cache
end

So the class is testing that squirrel is outputting the correct conditions. You could argue that squirrel’s own tests fulfill this testing need, but this class gives a good jumping point to testing that squirrel is receiving the correct parameters. The full file can be found here. Just include it from your spec_helper.

Make a bigger impact tomorrow