Testing Sidekiq Workers: Unit Tests, Fake Mode, and Integration Patterns

Testing Sidekiq Workers: Unit Tests, Fake Mode, and Integration Patterns

Sidekiq is the standard background job processor for Ruby/Rails. Its built-in testing support includes fake queues, inline execution, and RSpec matchers that make worker testing straightforward.

Testing Modes

Sidekiq provides three testing modes via sidekiq/testing:

Mode Behavior
Sidekiq::Testing.fake! Jobs enqueued but not executed (default for tests)
Sidekiq::Testing.inline! Jobs execute immediately when enqueued
Sidekiq::Testing.real! Normal Redis-backed behavior

Setup

# spec/spec_helper.rb or spec/rails_helper.rb
require 'sidekiq/testing'

RSpec.configure do |config|
  config.before(:each) do
    Sidekiq::Testing.fake!
  end

  config.after(:each) do
    Sidekiq::Worker.clear_all
  end
end

Unit Testing Worker Logic

Extract business logic from the worker to test it in isolation:

# app/workers/welcome_email_worker.rb
class WelcomeEmailWorker
  include Sidekiq::Worker

  sidekiq_options queue: :mailers, retry: 3

  def perform(user_id)
    user = User.find(user_id)
    UserMailer.welcome(user).deliver_now
    user.update!(welcome_email_sent_at: Time.current)
  end
end
# spec/workers/welcome_email_worker_spec.rb
require 'rails_helper'

RSpec.describe WelcomeEmailWorker, type: :worker do
  describe '#perform' do
    let(:user) { create(:user) }
    
    it 'sends welcome email to user' do
      expect(UserMailer).to receive(:welcome).with(user).and_return(
        double(deliver_now: true)
      )
      
      described_class.new.perform(user.id)
    end
    
    it 'updates welcome_email_sent_at timestamp' do
      allow(UserMailer).to receive(:welcome).and_return(double(deliver_now: true))
      
      expect {
        described_class.new.perform(user.id)
      }.to change { user.reload.welcome_email_sent_at }.from(nil).to(be_within(2.seconds).of(Time.current))
    end
    
    it 'raises ActiveRecord::RecordNotFound for missing user' do
      expect {
        described_class.new.perform(0)
      }.to raise_error(ActiveRecord::RecordNotFound)
    end
  end
end

Testing Enqueuing with Fake Mode

In fake mode, assert that jobs were enqueued without executing them:

RSpec.describe 'User registration', type: :request do
  it 'enqueues welcome email job after signup' do
    expect {
      post '/users', params: { user: { email: 'alice@example.com', password: 'secret' } }
    }.to change(WelcomeEmailWorker.jobs, :size).by(1)
    
    job = WelcomeEmailWorker.jobs.last
    expect(job['args']).to include(User.last.id)
    expect(job['queue']).to eq('mailers')
  end
  
  it 'enqueues with correct retry count' do
    post '/users', params: { user: { email: 'bob@example.com', password: 'secret' } }
    
    job = WelcomeEmailWorker.jobs.last
    expect(job['retry']).to eq(3)
  end
end

Using RSpec Matchers

# With sidekiq-rspec matchers (gem 'rspec-sidekiq')
RSpec.describe UsersController, type: :controller do
  describe 'POST #create' do
    it 'enqueues welcome email' do
      expect {
        post :create, params: { user: { email: 'alice@example.com' } }
      }.to enqueue_sidekiq_job(WelcomeEmailWorker)
    end
    
    it 'enqueues with user ID argument' do
      post :create, params: { user: { email: 'alice@example.com' } }
      
      expect(WelcomeEmailWorker).to have_enqueued_sidekiq_job(User.last.id)
    end
  end
end

Testing Scheduled Jobs

# app/workers/report_worker.rb
class ReportWorker
  include Sidekiq::Worker

  def perform(report_type)
    Report.generate(report_type)
  end
end

# spec/workers/report_worker_spec.rb
it 'schedules report for 1 hour from now' do
  expect {
    ReportWorker.perform_in(1.hour, 'daily')
  }.to change(ReportWorker.jobs, :size).by(1)
  
  job = ReportWorker.jobs.last
  scheduled_at = Time.at(job['at'])
  expect(scheduled_at).to be_within(5.seconds).of(1.hour.from_now)
end

it 'schedules cron-based job' do
  Sidekiq::Cron::Job.create(
    name: 'daily-report',
    cron: '0 9 * * *',
    class: 'ReportWorker',
    args: ['daily']
  )
  
  job = Sidekiq::Cron::Job.find('daily-report')
  expect(job).to be_present
  expect(job.cron).to eq('0 9 * * *')
end

Inline Mode for Integration Tests

RSpec.describe 'Order processing', type: :request do
  around do |example|
    Sidekiq::Testing.inline! { example.run }
  end
  
  it 'sends invoice email immediately after order' do
    # With inline mode, PaymentWorker executes synchronously
    expect(InvoiceMailer).to receive(:order_confirmation).and_return(
      double(deliver_later: true)
    )
    
    post '/orders', params: { order: { product_id: 1, quantity: 2 } }
    
    expect(response).to have_http_status(:created)
  end
end

Testing Retries

RSpec.describe PaymentWorker do
  describe 'retry behavior' do
    it 'retries on Stripe::RateLimitError' do
      allow(Stripe::Charge).to receive(:create).and_raise(Stripe::RateLimitError)
      
      # The worker should raise, Sidekiq will retry
      expect {
        described_class.new.perform('ord-1', 9999)
      }.to raise_error(Stripe::RateLimitError)
    end
    
    it 'does not retry on permanent errors' do
      # Mark specific errors as non-retryable
      allow(Stripe::Charge).to receive(:create).and_raise(
        Stripe::CardError.new('Card declined', nil, 'card_declined')
      )
      
      expect {
        described_class.new.perform('ord-1', 9999)
      }.to raise_error(Stripe::CardError)
      
      # Verify the order is marked failed, not retried
      expect(Order.find('ord-1').status).to eq('payment_failed')
    end
  end
end

Testing Dead Job Sets

it 'moves job to dead set after exhausting retries' do
  Sidekiq::Testing.real! do
    # Inject a failing job directly
    Sidekiq::Client.push(
      'class' => 'WelcomeEmailWorker',
      'args' => [999],
      'retry' => 0  # No retries
    )
    
    # Process it
    Sidekiq::Processor.new(nil, nil).process_one
    
    dead = Sidekiq::DeadSet.new
    expect(dead.size).to eq(1)
    expect(dead.first['class']).to eq('WelcomeEmailWorker')
  end
end

Testing ActiveJob with Sidekiq Adapter

# config/application.rb
config.active_job.queue_adapter = :sidekiq

# spec/jobs/invoice_job_spec.rb
RSpec.describe InvoiceJob, type: :job do
  it 'queues job to invoices queue' do
    expect {
      InvoiceJob.perform_later(order_id: 'ord-1')
    }.to have_enqueued_mail(InvoiceMailer, :send_invoice)
    # Or with rspec-sidekiq:
    # }.to enqueue_sidekiq_job(InvoiceJob).on_queue('invoices')
  end
end

Summary

Sidekiq testing is well-supported:

  • Sidekiq::Testing.fake! — fast unit tests, assert on enqueued jobs
  • Sidekiq::Testing.inline! — integration tests, execute jobs synchronously
  • Direct worker.perform(args) — test handler logic in isolation without any queue

Use fake mode as the default. Switch to inline for tests that need to verify the full request→background job→side effect chain. Always call Sidekiq::Worker.clear_all in after(:each) to prevent job leakage between tests.

Read more