Testing Sidekiq Jobs in Rails: Unit, Integration, and Retry Logic
Background jobs are a core part of most Rails applications — email delivery, image processing, webhook dispatch, data export. But they're also one of the most commonly undertested parts of a codebase. Jobs execute asynchronously in production, making failures easy to miss and hard to debug.
Sidekiq is the dominant background job library in the Rails ecosystem. It ships with sidekiq/testing — a testing helper that makes jobs synchronous and inspectable. This guide covers unit testing, integration testing, and the tricky edge cases: retries, failures, and dead letter queues.
Test Modes
Sidekiq provides three testing modes:
Fake mode (default for testing): Jobs are queued but not executed. You assert they were enqueued.
Inline mode: Jobs execute immediately and synchronously when enqueued. No worker process needed.
Real mode (disabled): Normal async behavior — requires a running Sidekiq process. Avoid in tests.
# spec/rails_helper.rb or spec/support/sidekiq.rb
require 'sidekiq/testing'
RSpec.configure do |config|
config.before(:each) do
Sidekiq::Testing.fake! # Default: queue jobs but don't run
end
config.after(:each) do
Sidekiq::Worker.clear_all # Clear queues after each test
end
endUnit Testing Workers
Testing in Fake Mode
In fake mode, test that jobs are enqueued with the right arguments:
# app/workers/email_notification_worker.rb
class EmailNotificationWorker
include Sidekiq::Worker
sidekiq_options queue: :notifications, retry: 3
def perform(user_id, notification_type, metadata = {})
user = User.find(user_id)
NotificationMailer.send(notification_type, user, metadata).deliver_now
end
end# spec/workers/email_notification_worker_spec.rb
require 'rails_helper'
RSpec.describe EmailNotificationWorker, type: :worker do
describe '.perform_async' do
it 'enqueues a job with the correct arguments' do
expect {
described_class.perform_async(1, 'welcome', { source: 'signup' })
}.to change(described_class.jobs, :size).by(1)
end
it 'enqueues to the notifications queue' do
described_class.perform_async(1, 'welcome')
job = described_class.jobs.last
expect(job['queue']).to eq('notifications')
end
it 'includes correct arguments' do
described_class.perform_async(42, 'password_reset')
job = described_class.jobs.last
expect(job['args']).to eq([42, 'password_reset'])
end
end
endTesting in Inline Mode
In inline mode, the actual job logic executes synchronously:
RSpec.describe EmailNotificationWorker, type: :worker do
describe '#perform' do
around(:each) do |example|
Sidekiq::Testing.inline! { example.run }
end
let(:user) { create(:user) }
it 'sends a welcome email' do
expect(NotificationMailer)
.to receive(:welcome)
.with(user, {})
.and_return(double(deliver_now: true))
described_class.perform_async(user.id, 'welcome')
end
it 'raises for unknown notification type' do
expect {
described_class.new.perform(user.id, 'nonexistent_type')
}.to raise_error(NoMethodError)
end
end
endDirect Instantiation
For complex job logic, test perform directly:
RSpec.describe ImageProcessingWorker do
subject(:worker) { described_class.new }
let(:image) { create(:image, status: 'pending') }
describe '#perform' do
context 'when the image exists' do
it 'processes the image' do
worker.perform(image.id)
expect(image.reload.status).to eq('processed')
end
it 'generates thumbnail variants' do
worker.perform(image.id)
expect(image.reload.variants).to include(:thumb, :medium, :large)
end
end
context 'when the image does not exist' do
it 'raises ActiveRecord::RecordNotFound' do
expect { worker.perform(-1) }.to raise_error(ActiveRecord::RecordNotFound)
end
end
context 'when processing fails' do
before { allow(ImageProcessor).to receive(:process).and_raise(StandardError) }
it 'marks the image as failed' do
expect { worker.perform(image.id) }.to raise_error(StandardError)
expect(image.reload.status).to eq('failed')
end
end
end
endTesting Retry Logic
Sidekiq retries jobs on failure with exponential backoff. Testing retry behavior requires understanding which exceptions trigger retries.
Configuring Retry Options
class CriticalPaymentWorker
include Sidekiq::Worker
sidekiq_options queue: :critical, retry: 5
sidekiq_retry_in do |count, exception|
case exception
when Stripe::RateLimitError
10 * (count + 1) # 10s, 20s, 30s, 40s, 50s
when NetworkError
30 # Always 30s
else
count ** 4 + 15 # Default exponential
end
end
sidekiq_retries_exhausted do |msg, ex|
# Called when all retries are exhausted
Sentry.capture_exception(ex, extra: { job: msg })
DeadLetterQueue.add(job_id: msg['jid'], payload: msg['args'])
end
def perform(payment_id, amount)
PaymentProcessor.charge(payment_id, amount)
end
endTesting the Retry Count
RSpec.describe CriticalPaymentWorker do
it 'retries up to 5 times' do
expect(described_class.get_sidekiq_options['retry']).to eq(5)
end
it 'has correct retry delay for rate limit errors' do
worker = described_class.new
retry_in = worker.class.sidekiq_retry_in_block
expect(retry_in.call(0, Stripe::RateLimitError.new)).to eq(10)
expect(retry_in.call(1, Stripe::RateLimitError.new)).to eq(20)
expect(retry_in.call(4, Stripe::RateLimitError.new)).to eq(50)
end
endTesting the Exhausted Callback
RSpec.describe CriticalPaymentWorker do
describe 'when retries are exhausted' do
it 'adds job to dead letter queue' do
msg = { 'jid' => 'abc123', 'args' => [payment.id, 500] }
ex = Stripe::CardError.new('Card declined', nil, nil)
expect(DeadLetterQueue).to receive(:add).with(
job_id: 'abc123',
payload: [payment.id, 500]
)
described_class.sidekiq_retries_exhausted_block.call(msg, ex)
end
it 'reports exception to Sentry' do
msg = { 'jid' => 'abc123', 'args' => [payment.id, 500] }
ex = StandardError.new('Unexpected error')
expect(Sentry).to receive(:capture_exception).with(
ex,
extra: { job: msg }
)
described_class.sidekiq_retries_exhausted_block.call(msg, ex)
end
end
endTesting Unique Jobs (sidekiq-unique-jobs)
If you use sidekiq-unique-jobs to prevent duplicate jobs:
class InvoiceGeneratorWorker
include Sidekiq::Worker
sidekiq_options queue: :billing,
unique: :until_executed,
unique_args: ->(args) { [args.first] } # Unique by invoice_id
def perform(invoice_id)
Invoice.find(invoice_id).generate_pdf
end
endRSpec.describe InvoiceGeneratorWorker do
include SidekiqUniqueJobs::Testing
before { SidekiqUniqueJobs::Testing.enable! }
after { SidekiqUniqueJobs::Testing.disable! }
it 'does not enqueue duplicate jobs for the same invoice' do
invoice = create(:invoice)
InvoiceGeneratorWorker.perform_async(invoice.id)
InvoiceGeneratorWorker.perform_async(invoice.id)
expect(InvoiceGeneratorWorker.jobs.size).to eq(1)
end
it 'enqueues separate jobs for different invoices' do
invoice_1 = create(:invoice)
invoice_2 = create(:invoice)
InvoiceGeneratorWorker.perform_async(invoice_1.id)
InvoiceGeneratorWorker.perform_async(invoice_2.id)
expect(InvoiceGeneratorWorker.jobs.size).to eq(2)
end
endIntegration Testing with Jobs
Test the full flow where your service code enqueues jobs:
# app/services/user_registration_service.rb
class UserRegistrationService
def call(params)
user = User.create!(params)
WelcomeEmailWorker.perform_async(user.id)
AnalyticsWorker.perform_async('signup', { user_id: user.id })
user
end
endRSpec.describe UserRegistrationService do
describe '#call' do
subject(:result) { described_class.new.call(valid_params) }
let(:valid_params) { { email: 'alice@example.com', name: 'Alice' } }
it 'creates the user' do
expect { result }.to change(User, :count).by(1)
end
it 'enqueues a welcome email' do
expect { result }.to change(WelcomeEmailWorker.jobs, :size).by(1)
end
it 'enqueues the welcome email with the new user id' do
result
job = WelcomeEmailWorker.jobs.last
expect(job['args']).to eq([User.last.id])
end
it 'enqueues an analytics event' do
result
job = AnalyticsWorker.jobs.last
expect(job['args']).to eq(['signup', { 'user_id' => User.last.id }])
end
context 'when user creation fails' do
let(:valid_params) { { email: 'invalid-email' } }
it 'does not enqueue any jobs' do
expect { result rescue nil }
.not_to change(WelcomeEmailWorker.jobs, :size)
end
end
end
endTesting Scheduled Jobs (perform_in / perform_at)
class TrialExpiryReminderWorker
include Sidekiq::Worker
def self.schedule_for(user)
reminder_date = user.trial_ends_at - 3.days
perform_at(reminder_date, user.id)
end
def perform(user_id)
user = User.find(user_id)
TrialMailer.expiry_reminder(user).deliver_now
end
endRSpec.describe TrialExpiryReminderWorker do
describe '.schedule_for' do
let(:user) { create(:user, trial_ends_at: 10.days.from_now) }
it 'schedules the reminder 3 days before trial ends' do
described_class.schedule_for(user)
job = described_class.jobs.last
expected_time = (user.trial_ends_at - 3.days).to_f
expect(job['at']).to be_within(1).of(expected_time)
end
end
endTesting with Sidekiq Batch
For Sidekiq Pro's batch jobs:
class DataExportWorker
include Sidekiq::Worker
def perform(export_id)
export = DataExport.find(export_id)
batch = Sidekiq::Batch.new
batch.on(:success, DataExportBatchCallback, export_id: export_id)
batch.jobs do
export.chunks.each do |chunk|
ChunkExportWorker.perform_async(chunk.id)
end
end
export.update!(batch_id: batch.bid)
end
endRSpec.describe DataExportWorker, type: :worker do
let(:export) { create(:data_export, :with_chunks, chunk_count: 3) }
around(:each) { |ex| Sidekiq::Testing.inline! { ex.run } }
it 'creates a batch with one job per chunk' do
batch_double = instance_double(Sidekiq::Batch, bid: 'test-batch-id')
allow(Sidekiq::Batch).to receive(:new).and_return(batch_double)
allow(batch_double).to receive(:on)
allow(batch_double).to receive(:jobs).and_yield
expect(ChunkExportWorker).to receive(:perform_async).exactly(3).times
described_class.new.perform(export.id)
end
endTesting Error Handling and Idempotency
Jobs should be idempotent — safe to run multiple times without causing duplicates:
class OrderFulfillmentWorker
include Sidekiq::Worker
sidekiq_options retry: 3
def perform(order_id)
order = Order.find(order_id)
# Idempotency check
return if order.fulfilled?
ActiveRecord::Base.transaction do
order.fulfill!
InventoryService.deduct(order.items)
ShippingService.create_label(order)
end
end
endRSpec.describe OrderFulfillmentWorker do
let(:order) { create(:order) }
describe 'idempotency' do
it 'does not double-fulfill an order' do
worker = described_class.new
worker.perform(order.id)
expect { worker.perform(order.id) }.not_to raise_error
expect(order.reload.fulfillments.count).to eq(1)
end
it 'is safe to retry after partial failure' do
call_count = 0
allow(ShippingService).to receive(:create_label) do
call_count += 1
raise StandardError if call_count == 1 # Fail first time
true
end
worker = described_class.new
expect { worker.perform(order.id) }.to raise_error(StandardError)
# Second attempt (retry) should succeed
worker.perform(order.id)
expect(order.reload).to be_fulfilled
end
end
endTesting with RSpec Matchers (rspec-sidekiq)
The rspec-sidekiq gem provides custom matchers:
# Gemfile
gem 'rspec-sidekiq', group: :test# More readable assertions
RSpec.describe User do
describe '#after_create' do
let(:user) { create(:user) }
it 'enqueues a welcome email' do
expect(WelcomeEmailWorker).to have_enqueued_sidekiq_job(user.id)
end
it 'enqueues to the mailer queue' do
expect(WelcomeEmailWorker).to have_enqueued_sidekiq_job(user.id).on(:mailer)
end
it 'schedules in 5 minutes' do
expect(TrialReminderWorker).to have_enqueued_sidekiq_job(user.id).in(5.minutes)
end
end
endTesting Sidekiq workers thoroughly covers a significant surface area: the job logic itself, the enqueue behavior in calling code, retry configuration, and idempotency. The combination of fake mode for integration tests and direct perform calls for unit tests covers most cases cleanly.