Custom rspec matcher for Delayed::Job performable methods

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

Tags: ,

2 Responses to “Custom rspec matcher for Delayed::Job performable methods”

  1. David Backeus Says:

    Beautiful solution, thank you for the code!

  2. Naveed Says:

    brilliant idea! I <3 this approach thanks

Leave a Reply