Welcome @wspurgin as new maintainer for rspec-sidekiq!

Gem Version Github Actions CI

Simple testing of Sidekiq jobs via a collection of matchers and helpers.

Jump to Matchers » | Jump to Helpers »

Installation

# Gemfile
group :test do
  gem 'rspec-sidekiq'
end

rspec-sidekiq requires sidekiq/testing by default so there is no need to include the line require "sidekiq/testing" inside your spec_helper.rb.

IMPORTANT! This has the effect of not pushing enqueued jobs to Redis but to a job array to enable testing (see the FAQ & Troubleshooting Wiki page). Thus, only include gem "rspec-sidekiq" in environments where this behaviour is required, such as the test group.

Configuration

If you wish to modify the default behaviour, add the following to your spec_helper.rb file

RSpec::Sidekiq.configure do |config|
  # Clears all job queues before each example
  config.clear_all_enqueued_jobs = true # default => true

  # Whether to use terminal colours when outputting messages
  config.enable_terminal_colours = true # default => true

  # Warn when jobs are not enqueued to Redis but to a job array
  config.warn_when_jobs_not_processed_by_sidekiq = true # default => true
end

Matchers

enqueue_sidekiq_job

Describes that the block should enqueue a job. Optionally specify the specific job class, arguments, timing, and other context

# Basic
expect { AwesomeJob.perform_async }.to enqueue_sidekiq_job

# A specific job class
expect { AwesomeJob.perform_async }.to enqueue_sidekiq_job(AwesomeJob)

# with specific arguments
expect { AwesomeJob.perform_async "Awesome!" }.to enqueue_sidekiq_job.with("Awesome!")

# On a specific queue
expect { AwesomeJob.set(queue: "high").perform_async }.to enqueue_sidekiq_job.on("high")

# At a specific datetime
specific_time = 1.hour.from_now
expect { AwesomeJob.perform_at(specific_time) }.to enqueue_sidekiq_job.at(specific_time)

# In a specific interval (be mindful of freezing or managing time here)
freeze_time do
  expect { AwesomeJob.perform_in(1.hour) }.to enqueue_sidekiq_job.in(1.hour)
end

# Combine and chain them as desired
expect { AwesomeJob.perform_at(specific_time, "Awesome!") }.to(
  enqueue_sidekiq_job(AwesomeJob)
  .with("Awesome!")
  .on("default")
  .at(specific_time)
)

# Also composable
expect do
  AwesomeJob.perform_async
  OtherJob.perform_async
end.to enqueue_sidekiq_job(AwesomeJob).and enqueue_sidekiq_job(OtherJob)

have_enqueued_sidekiq_job

Describes that there should be an enqueued job with the specified arguments

AwesomeJob.perform_async 'Awesome', true
# test with...
expect(AwesomeJob).to have_enqueued_sidekiq_job('Awesome', true)

You can use the built-in RSpec args matchers too:

AwesomeJob.perform_async({"something" => "Awesome", "extra" => "stuff"})

# using built-in matchers from rspec-mocks:
expect(AwesomeJob).to have_enqueued_sidekiq_job(hash_including("something" => "Awesome"))
expect(AwesomeJob).to have_enqueued_sidekiq_job(any_args)
expect(AwesomeJob).to have_enqueued_sidekiq_job(hash_excluding("bad_stuff" => anything))

# composable as well
expect(AwesomeJob).to have_enqueued_sidekiq_job(any_args).and have_enqueued_sidekiq_job(hash_including("something" => "Awesome"))

Testing scheduled jobs

Use chainable matchers #at, #in and #immediately

time = 5.minutes.from_now
AwesomeJob.perform_at time, 'Awesome', true
# test with...
expect(AwesomeJob).to have_enqueued_sidekiq_job('Awesome', true).at(time)
AwesomeJob.perform_in 5.minutes, 'Awesome', true
# test with...
expect(AwesomeJob).to have_enqueued_sidekiq_job('Awesome', true).in(5.minutes)
# Job scheduled for a date in the past are enqueued immediately.
AwesomeJob.perform_later 5.minutes.ago, 'Awesome', true # equivalent to: AwesomeJob.perform_async 'Awesome', true
# test with...
expect(AwesomeJob).to have_enqueued_sidekiq_job('Awesome', true).immediately

Testing queue set for job

Use the chainable #on matcher

class AwesomeJob
  include Sidekiq::Job

  sidekiq_options queue: :low
end

AwesomeJob.perform_async("a little awesome")

# test with..
expect(AwesomeJob).to have_enqueued_sidekiq_job("a little awesome").on("low")

# Setting the queue when enqueuing
AwesomeJob.set(queue: "high").perform_async("Very Awesome!")

expect(AwesomeJob).to have_enqueued_sidekiq_job("Very Awesome!").on("high")

Testing ActiveMailer jobs

user = User.first
AwesomeActionMailer.invite(user, true).deliver_later

expect(Sidekiq::Worker).to have_enqueued_sidekiq_job(
  "AwesomeActionMailer",
  "invite",
  "deliver_now",
  user,
  true
)

Testing a job is not enqueued

The negative case for have_enqueued_sidekiq_job is provided, but it's important to remember that have_enqueued_sidekiq_job is an expectation that a job is enqueued with specific arguments. In other words, passing no arguments to have_enqueued_sidekiq_job is implicitly telling the matcher to look for jobs without arguments.

In short, unless you tell the matcher that no jobs with any arguments should be enqueued, you'll get the wrong result:

# example this is a test that we'd expect to fail
AwesomeJob.perform_async "Actually not awesome"

### BAD - saying there shouldn't be a job enqueued _without_ args
expect(AwesomeJob).not_to have_enqueued_sidekiq_job
# => passes! 😱 Our job was enqueued _with_ args so no job exists without args.

### Good
expect(AwesomeJob).not_to have_enqueued_sidekiq_job(any_args)
# => fails

be_processed_in

Describes the queue that a job should be processed in

sidekiq_options queue: :download
# test with...
expect(AwesomeJob).to be_processed_in :download # or
it { is_expected.to be_processed_in :download }

be_retryable

Describes if a job should retry when there is a failure in its execution

sidekiq_options retry: 5
# test with...
expect(AwesomeJob).to be_retryable true # or
it { is_expected.to be_retryable true }
# ...or alternatively specify the number of times it should be retried
expect(AwesomeJob).to be_retryable 5 # or
it { is_expected.to be_retryable 5 }
# ...or when it should not retry
expect(AwesomeJob).to be_retryable false # or
it { is_expected.to be_retryable false }

save_backtrace

Describes if a job should save the error backtrace when there is a failure in its execution

sidekiq_options backtrace: 5
# test with...
expect(AwesomeJob).to save_backtrace # or
it { is_expected.to save_backtrace }
# ...or alternatively specify the number of lines that should be saved
expect(AwesomeJob).to save_backtrace 5 # or
it { is_expected.to save_backtrace 5 }
# ...or when it should not save the backtrace
expect(AwesomeJob).to_not save_backtrace # or
expect(AwesomeJob).to save_backtrace false # or
it { is_expected.to_not save_backtrace } # or
it { is_expected.to save_backtrace false }

be_unique

Describes when a job should be unique within its queue

sidekiq_options unique: true
# test with...
expect(AwesomeJob).to be_unique
it { is_expected.to be_unique }

be_expired_in

Describes when a job should expire

sidekiq_options expires_in: 1.hour
# test with...
it { is_expected.to be_expired_in 1.hour }
it { is_expected.to_not be_expired_in 2.hours }

be_delayed

This matcher is deprecated. Use of it with Sidekiq 7+ will raise an error. Sidekiq 7 dropped Delayed Extensions.

Describes a method that should be invoked asynchronously (See Sidekiq Delayed Extensions)

Object.delay.is_nil? # delay
expect(Object.method :is_nil?).to be_delayed
Object.delay.is_a? Object # delay with argument
expect(Object.method :is_a?).to be_delayed(Object)

Object.delay_for(1.hour).is_nil? # delay for
expect(Object.method :is_nil?).to be_delayed.for 1.hour
Object.delay_for(1.hour).is_a? Object # delay for with argument
expect(Object.method :is_a?).to be_delayed(Object).for 1.hour

Object.delay_until(1.hour.from_now).is_nil? # delay until
expect(Object.method :is_nil?).to be_delayed.until 1.hour.from_now
Object.delay_until(1.hour.from_now).is_a? Object # delay until with argument
expect(Object.method :is_a?).to be_delayed(Object).until 1.hour.from_now

#Rails Mailer
MyMailer.delay.some_mail
expect(MyMailer.instance_method :some_mail).to be_delayed

Example matcher usage

require 'spec_helper'

describe AwesomeJob do
  it { is_expected.to be_processed_in :my_queue }
  it { is_expected.to be_retryable 5 }
  it { is_expected.to be_unique }
  it { is_expected.to be_expired_in 1.hour }

  it 'enqueues another awesome job' do
    subject.perform

    expect(AnotherAwesomeJob).to have_enqueued_sidekiq_job('Awesome', true)
  end
end

Helpers

Batches

If you are using Sidekiq Batches (Sidekiq Pro feature), You can opt-in with stub_batches to make rspec-sidekiq mock the implementation (using a NullObject pattern). This enables testing without a Redis instance. Mocha and RSpec stubbing is supported here.

:warning: Caution: Opting-in to this feature, while allowing you to test without having Redis, does not provide the exact API that Sidekiq::Batch does. As such it can cause surprises.

RSpec.describe "Using mocked batches", stub_batches: true do
  it "uses mocked batches" do
    batch = Sidekiq::Batch.new
    batch.jobs do
      SomeJob.perform_async 123
    end

    expect(SomeJob).to have_enqueued_sidekiq_job

    # Caution, the NullObject pattern means that the mocked Batch implementation
    # responds to anything... even if it's not on the true `Sidekiq::Batch` API
    # For example, the following fails
    expect { batch.foobar! }.to raise_error(NoMethodError)
  end
end

within_sidekiq_retries_exhausted_block

sidekiq_retries_exhausted do |msg|
  bar('hello')
end
# test with...
FooClass.within_sidekiq_retries_exhausted_block {
  expect(FooClass).to receive(:bar).with('hello')
}

Testing

bundle exec rspec spec

Maintainers

  • @wspurgin

Alumni

  • @packrat386
  • @philostler

Contribute

Please do! If there's a feature missing that you'd love to see then get in on the action!

Issues/Pull Requests/Comments all welcome...