Testing Sidekiq Jobs in Rails: Unit, Integration, and Retry Logic

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
end

Unit 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
end

Testing 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
end

Direct 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
end

Testing 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
end

Testing 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
end

Testing 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
end

Testing 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
end
RSpec.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
end

Integration 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
end
RSpec.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
end

Testing 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
end
RSpec.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
end

Testing 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
end
RSpec.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
end

Testing 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
end
RSpec.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
end

Testing 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
end

Testing 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.

Read more