Back to Repositories

Testing Job Lifecycle Hooks Implementation in Resque

This test suite examines the job hook functionality in Resque, focusing on the lifecycle hooks before_perform, after_perform, around_perform, and failure handling. It verifies the execution order, error handling, and hook behavior for job processing in the Resque job queue system.

Test Coverage Overview

The test suite provides comprehensive coverage of Resque’s job hook system:
  • Before/After perform hooks execution order
  • Around perform hook behavior and yield mechanics
  • Failure handling and exception propagation
  • Queue operation hooks (enqueue/dequeue)
  • Complex scenarios with multiple hooks interaction

Implementation Analysis

The testing approach uses RSpec-style describe blocks with focused test cases for each hook type. It employs a history array to track execution order and implements custom job classes to test different hook scenarios. The implementation leverages Ruby’s yield mechanism for around_perform hooks and exception handling for failure cases.

Technical Details

Testing tools and setup:
  • RSpec testing framework
  • Custom PerformJob module inclusion
  • Mock job classes with hook implementations
  • History tracking through array manipulation
  • StandardError and custom exception handling

Best Practices Demonstrated

The test suite exemplifies several testing best practices:
  • Isolated test cases for each hook type
  • Clear separation of concerns in test classes
  • Comprehensive error case coverage
  • Consistent use of assertions and expectations
  • Well-structured test organization

resque/resque

test/job_hooks_test.rb

            
require 'test_helper'

describe "Resque::Job before_perform" do
  include PerformJob

  class ::BeforePerformJob
    def self.before_perform_record_history(history)
      history << :before_perform
    end

    def self.perform(history)
      history << :perform
    end
  end

  it "it runs before_perform before perform" do
    result = perform_job(BeforePerformJob, history=[])
    assert_equal true, result, "perform returned true"
    assert_equal history, [:before_perform, :perform]
  end

  class ::BeforePerformJobFails
    def self.before_perform_fail_job(history)
      history << :before_perform
      raise StandardError
    end
    def self.perform(history)
      history << :perform
    end
  end

  it "raises an error and does not perform if before_perform fails" do
    history = []
    assert_raises StandardError do
      perform_job(BeforePerformJobFails, history)
    end
    assert_equal history, [:before_perform], "Only before_perform was run"
  end

  class ::BeforePerformJobAborts
    def self.before_perform_abort(history)
      history << :before_perform
      raise Resque::Job::DontPerform
    end
    def self.perform(history)
      history << :perform
    end
  end

  it "does not perform if before_perform raises Resque::Job::DontPerform" do
    result = perform_job(BeforePerformJobAborts, history=[])
    assert_equal false, result, "perform returned false"
    assert_equal history, [:before_perform], "Only before_perform was run"
  end
end

describe "Resque::Job after_perform" do
  include PerformJob

  class ::AfterPerformJob
    def self.perform(history)
      history << :perform
    end
    def self.after_perform_record_history(history)
      history << :after_perform
    end
  end

  it "it runs after_perform after perform" do
    result = perform_job(AfterPerformJob, history=[])
    assert_equal true, result, "perform returned true"
    assert_equal history, [:perform, :after_perform]
  end

  class ::AfterPerformJobFails
    def self.perform(history)
      history << :perform
    end
    def self.after_perform_fail_job(history)
      history << :after_perform
      raise StandardError
    end
  end

  it "raises an error but has already performed if after_perform fails" do
    history = []
    assert_raises StandardError do
      perform_job(AfterPerformJobFails, history)
    end
    assert_equal history, [:perform, :after_perform], "Only after_perform was run"
  end
end

describe "Resque::Job around_perform" do
  include PerformJob

  class ::AroundPerformJob
    def self.perform(history)
      history << :perform
    end
    def self.around_perform_record_history(history)
      history << :start_around_perform
      yield
      history << :finish_around_perform
    end
  end

  it "it runs around_perform then yields in order to perform" do
    result = perform_job(AroundPerformJob, history=[])
    assert_equal true, result, "perform returned true"
    assert_equal history, [:start_around_perform, :perform, :finish_around_perform]
  end

  class ::AroundPerformJobFailsBeforePerforming
    def self.perform(history)
      history << :perform
    end
    def self.around_perform_fail(history)
      history << :start_around_perform
      raise StandardError
      yield
      history << :finish_around_perform
    end
  end

  it "raises an error and does not perform if around_perform fails before yielding" do
    history = []
    assert_raises StandardError do
      perform_job(AroundPerformJobFailsBeforePerforming, history)
    end
    assert_equal history, [:start_around_perform], "Only part of around_perform was run"
  end

  class ::AroundPerformJobFailsWhilePerforming
    def self.perform(history)
      history << :perform
      raise StandardError
    end
    def self.around_perform_fail_in_yield(history)
      history << :start_around_perform
      begin
        yield
      ensure
        history << :ensure_around_perform
      end
      history << :finish_around_perform
    end
  end

  it "raises an error but may handle exceptions if perform fails" do
    history = []
    assert_raises StandardError do
      perform_job(AroundPerformJobFailsWhilePerforming, history)
    end
    assert_equal history, [:start_around_perform, :perform, :ensure_around_perform], "Only part of around_perform was run"
  end

  class ::AroundPerformJobDoesNotHaveToYield
    def self.perform(history)
      history << :perform
    end
    def self.around_perform_dont_yield(history)
      history << :start_around_perform
      history << :finish_around_perform
    end
  end

  it "around_perform is not required to yield" do
    history = []
    result = perform_job(AroundPerformJobDoesNotHaveToYield, history)
    assert_equal false, result, "perform returns false"
    assert_equal history, [:start_around_perform, :finish_around_perform], "perform was not run"
  end
end

describe "Resque::Job on_failure" do
  include PerformJob

  class ::FailureJobThatDoesNotFail
    def self.perform(history)
      history << :perform
    end
    def self.on_failure_record_failure(exception, history)
      history << exception.message
    end
  end

  it "it does not call on_failure if no failures occur" do
    result = perform_job(FailureJobThatDoesNotFail, history=[])
    assert_equal true, result, "perform returned true"
    assert_equal history, [:perform]
  end

  class ::FailureJobThatFails
    def self.perform(history)
      history << :perform
      raise StandardError, "oh no"
    end
    def self.on_failure_record_failure(exception, history)
      history << exception.message
    end
  end

  it "it calls on_failure with the exception and then re-raises the exception" do
    history = []
    assert_raises StandardError do
      perform_job(FailureJobThatFails, history)
    end
    assert_equal history, [:perform, "oh no"]
  end

  class ::FailureJobThatFailsBadly
    def self.perform(history)
      history << :perform
      raise SyntaxError, "oh no"
    end
    def self.on_failure_record_failure(exception, history)
      history << exception.message
    end
  end

  it "it calls on_failure even with bad exceptions" do
    history = []
    assert_raises SyntaxError do
      perform_job(FailureJobThatFailsBadly, history)
    end
    assert_equal history, [:perform, "oh no"]
  end

  it "throws errors with helpful messages if failure occurs during on_failure hooks" do
    err = assert_raises RuntimeError do
      perform_job(BadJobWithOnFailureHookFail)
    end
    assert_match('Additional error (RuntimeError: This job is just so bad!)', err.message)
    assert_match('occurred in running failure hooks', err.message)
    assert_match('for job (Job{testqueue} | BadJobWithOnFailureHookFail | [])', err.message)
    assert_match('Original error that caused job failure was RuntimeError: SyntaxError: Extra Bad job!', err.message)
  end
end

describe "Resque::Job after_enqueue" do
  include PerformJob

  class ::AfterEnqueueJob
    @queue = :jobs
    def self.after_enqueue_record_history(history)
      history << :after_enqueue
    end

    def self.perform(history)
    end
  end

  it "the after enqueue hook should run" do
    history = []
    @worker = Resque::Worker.new(:jobs)
    Resque.enqueue(AfterEnqueueJob, history)
    @worker.work(0)
    assert_equal history, [:after_enqueue], "after_enqueue was not run"
  end
end


describe "Resque::Job before_enqueue" do
  include PerformJob

  class ::BeforeEnqueueJob
    @queue = :jobs
    def self.before_enqueue_record_history(history)
      history << :before_enqueue
    end

    def self.perform(history)
    end
  end

  class ::BeforeEnqueueJobAbort
    @queue = :jobs
    def self.before_enqueue_abort(history)
      false
    end

    def self.perform(history)
    end
  end

  it "the before enqueue hook should run" do
    history = []
    @worker = Resque::Worker.new(:jobs)
    assert Resque.enqueue(BeforeEnqueueJob, history)
    @worker.work(0)
    assert_equal history, [:before_enqueue], "before_enqueue was not run"
  end

  it "a before enqueue hook that returns false should prevent the job from getting queued" do
    Resque.remove_queue(:jobs)
    history = []
    @worker = Resque::Worker.new(:jobs)
    assert_nil Resque.enqueue(BeforeEnqueueJobAbort, history)
    assert_equal 0, Resque.size(:jobs)
  end
end

describe "Resque::Job after_dequeue" do
  include PerformJob

  class ::AfterDequeueJob
    @queue = :jobs
    def self.after_dequeue_record_history(history)
      history << :after_dequeue
    end

    def self.perform(history)
    end
  end

  it "the after dequeue hook should run" do
    history = []
    @worker = Resque::Worker.new(:jobs)
    Resque.dequeue(AfterDequeueJob, history)
    @worker.work(0)
    assert_equal history, [:after_dequeue], "after_dequeue was not run"
  end
end


describe "Resque::Job before_dequeue" do
  include PerformJob

  class ::BeforeDequeueJob
    @queue = :jobs
    def self.before_dequeue_record_history(history)
      history << :before_dequeue
    end

    def self.perform(history)
    end
  end

  class ::BeforeDequeueJobAbort
    @queue = :jobs
    def self.before_dequeue_abort(history)
      false
    end

    def self.perform(history)
    end
  end

  it "the before dequeue hook should run" do
    history = []
    @worker = Resque::Worker.new(:jobs)
    Resque.dequeue(BeforeDequeueJob, history)
    @worker.work(0)
    assert_equal history, [:before_dequeue], "before_dequeue was not run"
  end

  it "a before dequeue hook that returns false should prevent the job from getting dequeued" do
    history = []
    assert_nil Resque.dequeue(BeforeDequeueJobAbort, history)
  end
end

describe "Resque::Job all hooks" do
  include PerformJob

  class ::VeryHookyJob
    def self.before_perform_record_history(history)
      history << :before_perform
    end
    def self.around_perform_record_history(history)
      history << :start_around_perform
      yield
      history << :finish_around_perform
    end
    def self.perform(history)
      history << :perform
    end
    def self.after_perform_record_history(history)
      history << :after_perform
    end
    def self.on_failure_record_history(exception, history)
      history << exception.message
    end
  end

  it "the complete hook order" do
    result = perform_job(VeryHookyJob, history=[])
    assert_equal true, result, "perform returned true"
    assert_equal history, [
      :before_perform,
      :start_around_perform,
      :perform,
      :finish_around_perform,
      :after_perform
    ]
  end

  class ::VeryHookyJobThatFails
    def self.before_perform_record_history(history)
      history << :before_perform
    end
    def self.around_perform_record_history(history)
      history << :start_around_perform
      yield
      history << :finish_around_perform
    end
    def self.perform(history)
      history << :perform
    end
    def self.after_perform_record_history(history)
      history << :after_perform
      raise StandardError, "oh no"
    end
    def self.on_failure_record_history(exception, history)
      history << exception.message
    end
  end

  it "the complete hook order with a failure at the last minute" do
    history = []
    assert_raises StandardError do
      perform_job(VeryHookyJobThatFails, history)
    end
    assert_equal history, [
      :before_perform,
      :start_around_perform,
      :perform,
      :finish_around_perform,
      :after_perform,
      "oh no"
    ]
  end

  class ::CallbacksInline
    @queue = :callbacks_inline

    def self.before_perform_record_history(history, count)
      history << :before_perform
      count['count'] += 1
    end

    def self.after_perform_record_history(history, count)
      history << :after_perform
      count['count'] += 1
    end

    def self.around_perform_record_history(history, count)
      history << :start_around_perform
      count['count'] += 1
      yield
      history << :finish_around_perform
      count['count'] += 1
    end

    def self.perform(history, count)
      history << :perform
      $history = history
      $count = count
    end
  end

  it "it runs callbacks when inline is true" do
    begin
      Resque.inline = true
      # Sending down two parameters that can be passed and updated by reference
      result = Resque.enqueue(CallbacksInline, [], {'count' => 0})
      assert_equal true, result, "perform returned true"
      assert_equal $history, [:before_perform, :start_around_perform, :perform, :finish_around_perform, :after_perform]
      assert_equal 4, $count['count']
    ensure
      Resque.inline = false
    end
  end
end