Testing ActiveJob in Rails: Jobs, Queues, and Background Processing

Testing ActiveJob in Rails: Jobs, Queues, and Background Processing

Background jobs are notoriously undertested. They run asynchronously, fail silently, and interact with external services — all the characteristics that make testing uncomfortable. Rails ActiveJob provides testing tools that make job testing as straightforward as controller testing, without requiring a running queue worker.

Queue Adapter Configuration

Set the queue adapter to :test for unit and integration tests:

# config/environments/test.rb
Rails.application.configure do
  config.active_job.queue_adapter = :test
end

With :test adapter:

  • Jobs are not executed when enqueued — they're added to ActiveJob::Base.queue_adapter.enqueued_jobs
  • You call perform_enqueued_jobs explicitly to run them
  • No external queue infrastructure (Redis, etc.) required

Testing Job Enqueuing

The most common test: verify that a controller action or model callback enqueues the right job:

# spec/controllers/users_controller_spec.rb
RSpec.describe UsersController do
  describe "POST #create" do
    it "enqueues a welcome email job" do
      expect {
        post :create, params: { user: { email: "alice@example.com" } }
      }.to have_enqueued_job(WelcomeEmailJob)
    end

    it "enqueues the job with the new user's email" do
      post :create, params: { user: { email: "alice@example.com" } }

      expect(WelcomeEmailJob).to have_been_enqueued.with(
        hash_including(email: "alice@example.com")
      )
    end

    it "enqueues the job on the notifications queue" do
      post :create, params: { user: { email: "alice@example.com" } }

      expect(WelcomeEmailJob).to have_been_enqueued.on_queue("notifications")
    end
  end
end

Testing Job Execution

Use perform_enqueued_jobs to run jobs inline during a test:

RSpec.describe "User onboarding" do
  include ActiveJob::TestHelper

  it "sends welcome email after signup" do
    perform_enqueued_jobs do
      create(:user, email: "alice@example.com")
    end

    expect(ActionMailer::Base.deliveries.count).to eq(1)
    expect(ActionMailer::Base.deliveries.last.to).to include("alice@example.com")
  end
end

Or test job execution directly:

RSpec.describe WelcomeEmailJob do
  include ActiveJob::TestHelper

  describe "#perform" do
    it "sends a welcome email" do
      user = create(:user, email: "alice@example.com")

      expect {
        WelcomeEmailJob.perform_now(user)
      }.to change(ActionMailer::Base.deliveries, :count).by(1)
    end

    it "sends to the correct recipient" do
      user = create(:user, email: "alice@example.com")
      WelcomeEmailJob.perform_now(user)

      email = ActionMailer::Base.deliveries.last
      expect(email.to).to include("alice@example.com")
    end
  end
end

perform_now executes the job synchronously in the current process — no queue, no worker, no waiting.

Minitest with ActiveJob::TestCase

# test/jobs/welcome_email_job_test.rb
class WelcomeEmailJobTest < ActiveJob::TestCase
  def setup
    ActionMailer::Base.deliveries.clear
  end

  test "sends welcome email" do
    user = users(:alice)
    WelcomeEmailJob.perform_now(user)

    assert_equal 1, ActionMailer::Base.deliveries.count
    assert_equal [user.email], ActionMailer::Base.deliveries.last.to
  end

  test "enqueues on correct queue" do
    user = users(:alice)

    assert_enqueued_with(job: WelcomeEmailJob, queue: "notifications") do
      WelcomeEmailJob.perform_later(user)
    end
  end
end

Testing Scheduled Jobs

# app/jobs/cleanup_job.rb
class CleanupJob < ApplicationJob
  queue_as :maintenance

  def perform
    User.where("last_sign_in_at < ?", 1.year.ago).each do |user|
      user.deactivate!
    end
  end
end
RSpec.describe CleanupJob do
  describe "#perform" do
    it "deactivates users inactive for over a year" do
      active_user = create(:user, last_sign_in_at: 6.months.ago)
      stale_user = create(:user, last_sign_in_at: 13.months.ago)

      CleanupJob.perform_now

      expect(active_user.reload).to be_active
      expect(stale_user.reload).not_to be_active
    end

    it "does not touch recently active users" do
      user = create(:user, last_sign_in_at: 1.day.ago)

      expect { CleanupJob.perform_now }.not_to change { user.reload.status }
    end
  end
end

Testing Job Retries

ActiveJob supports automatic retry with backoff:

# app/jobs/payment_job.rb
class PaymentJob < ApplicationJob
  retry_on Stripe::APIConnectionError, wait: :exponentially_longer, attempts: 5
  discard_on Stripe::InvalidRequestError

  def perform(charge_id)
    Stripe::Charge.retrieve(charge_id)
  end
end

Test retry behavior:

RSpec.describe PaymentJob do
  include ActiveJob::TestHelper

  describe "retry handling" do
    it "retries on connection errors" do
      allow(Stripe::Charge).to receive(:retrieve)
        .and_raise(Stripe::APIConnectionError.new("timeout"))

      expect {
        PaymentJob.perform_now("ch_123")
      }.to have_been_retried
    end

    it "discards on invalid request" do
      allow(Stripe::Charge).to receive(:retrieve)
        .and_raise(Stripe::InvalidRequestError.new("invalid", "charge_id"))

      expect {
        PaymentJob.perform_now("ch_invalid")
      }.to have_been_discarded
    end
  end
end

Testing Job Callbacks

# app/jobs/report_job.rb
class ReportJob < ApplicationJob
  before_perform :log_start
  after_perform :log_completion
  around_perform :track_duration

  def perform(report_id)
    Report.find(report_id).generate!
  end

  private

  def log_start
    Rails.logger.info("Starting report job for #{arguments.first}")
  end

  def log_completion
    Rails.logger.info("Report job completed")
  end

  def track_duration
    start = Time.current
    yield
    elapsed = Time.current - start
    Metrics.record("report_job.duration", elapsed)
  end
end
RSpec.describe ReportJob do
  describe "callbacks" do
    it "tracks duration via metrics" do
      report = create(:report)
      allow(Metrics).to receive(:record)

      ReportJob.perform_now(report.id)

      expect(Metrics).to have_received(:record)
        .with("report_job.duration", anything)
    end
  end
end

Testing with Sidekiq

If you use Sidekiq as the queue backend, add Sidekiq's testing support:

# Gemfile
gem "sidekiq", group: :default
# spec/rails_helper.rb or spec/support/sidekiq.rb
require "sidekiq/testing"

RSpec.configure do |config|
  config.before(:each) do
    Sidekiq::Testing.fake!  # Default: accumulate jobs without executing
    Sidekiq::Worker.clear_all
  end
end

Three Sidekiq test modes:

  • Sidekiq::Testing.fake! — jobs accumulate in SomeWorker.jobs, not executed
  • Sidekiq::Testing.inline! — jobs execute immediately when enqueued
  • Sidekiq::Testing.disable! — jobs go to real Redis (integration/E2E tests)
RSpec.describe UserIndexer do
  describe ".perform_async" do
    it "enqueues the job" do
      expect {
        UserIndexer.perform_async(user.id)
      }.to change(UserIndexer.jobs, :size).by(1)
    end

    it "enqueues with correct arguments" do
      UserIndexer.perform_async(42)
      expect(UserIndexer.jobs.last["args"]).to eq([42])
    end
  end
end

# Inline mode — test actual job execution
RSpec.describe UserIndexer do
  around do |example|
    Sidekiq::Testing.inline! { example.run }
  end

  it "indexes the user" do
    user = create(:user)
    UserIndexer.perform_async(user.id)

    expect(user.reload.search_index).not_to be_nil
  end
end

Clearing Jobs Between Tests

Always clear jobs between tests to prevent leakage:

# spec/rails_helper.rb
RSpec.configure do |config|
  config.before(:each) do
    clear_enqueued_jobs    # ActiveJob test helper
    clear_performed_jobs
  end
end

Or with Sidekiq:

config.before(:each) { Sidekiq::Worker.clear_all }

Integration: Jobs + Mailers

A common pattern is Action Mailer delivering via ActiveJob with deliver_later:

class UserMailer < ApplicationMailer
  def welcome_email(user)
    mail(to: user.email, subject: "Welcome!")
  end
end

# In a callback:
after_create :send_welcome_email

def send_welcome_email
  UserMailer.welcome_email(self).deliver_later
end

Test the full chain:

RSpec.describe User do
  include ActiveJob::TestHelper

  it "sends welcome email asynchronously after creation" do
    expect {
      create(:user)
    }.to have_enqueued_job(ActionMailer::MailDeliveryJob)
      .with("UserMailer", "welcome_email", "deliver_now", anything)
  end

  it "delivers the email when the job runs" do
    perform_enqueued_jobs do
      create(:user, email: "alice@example.com")
    end

    expect(ActionMailer::Base.deliveries.count).to eq(1)
    expect(ActionMailer::Base.deliveries.last.to).to include("alice@example.com")
  end
end

Summary

ActiveJob testing has two distinct modes: testing enqueuing (did the right job get queued with the right arguments?) and testing execution (does the job do the right thing?). Keep them separate. Enqueue tests belong in controller or model specs where the trigger happens. Execution tests belong in job specs. Use perform_now for execution tests — synchronous execution eliminates timing issues and external queue dependencies. Reserve perform_enqueued_jobs for integration tests that need to verify a chain of side effects.

Read more

Start now free