I’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