Custom rspec matcher for Delayed::Job performable methods
Tuesday, May 22nd, 2012I’ve been working on code that delays a bunch of work using Delayed Job’s :delay method. Stuff like this:
class Album after_create :send_emails def send_emails recipients.each do |recipient| recipient.delay.send_email end end end
I tried a number of testing strategies:
- checking that the Delayed::Job.count changed
- testing for properties on Delayed::Job.last
- working off the jobs and testing that the work got done.
But they all seemed too indirect. In the end I decided to just write a custom matcher that lets me do this:
expect { Album.create :recipients => [janelle_monáe] }.to delay_method(janelle_monáe, :send_email)
Another reasonable route would be to mock :delay on the appropriate object, and then do a :should_receive on the mock. That only works if the object that’s getting the method delayed exists in your test scope. It won’t work if the object is created or looked up inside the method you’re testing…. unless you stub out the relevant :create and :find methods.
I don’t know. I’m still (3+ years in) learning how to decide how intensively to mock my unit tests. Some part of me thinks I’m just not using mocks enough. But the part that likes unit tests to be a little more flexible in the face of refactoring resists. We’ll see how it goes.
Use it like this:
# On a specific object: apple = Apple.new expect {...}.to delay_method(apple, :core) # On any instance of a class: expect {...}.to delay_method(Apple.any_instance, :core) # On whatever object the code returns: expect { Apple.create }.to delay_method(returned_object, :core)
And here’s the code:
# spec/support/delayed_job.rb module RspecDelayedJobMatcher class ReturnedObject end end def returned_object RspecDelayedJobMatcher::ReturnedObject end RSpec::Matchers.define :delay_method do |object, method_name| def jobs Delayed::Job.all.select do |job| (payload = job.payload_object) && equivalent_object?(payload.object) && payload.method_name == @method_name end.count end def equivalent_object?(payload) if @object == returned_object payload == @response elsif @object.is_a? RSpec::Mocks::AnyInstance::Recorder payload.is_a? @object.instance_variable_get("@klass") else payload == @object end end def description if @object == returned_object "the returned object (#{@response.inspect})" elsif @object.is_a? RSpec::Mocks::AnyInstance::Recorder "some instance of #{@object.instance_variable_get("@klass").to_s}" else @object.inspect end end match do |proc| @method_name = method_name @object = object count_before = jobs @response = proc.call (@count = jobs - count_before) == 1 end failure_message_for_should do |actual| "Expected block to create a job to invoke #{@method_name} on #{description}, but it created #{@count}" end failure_message_for_should_not do |actual| "Block created #{@count} job(s) to invoke #{@method_name} on #{description}, but expected none" end description do "expect block to delay #{@method_name} on a #{@object.class.to_s}" end end