By Nathan Donaldson
Tags: Development
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
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.