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
endWith :test adapter:
- Jobs are not executed when enqueued — they're added to
ActiveJob::Base.queue_adapter.enqueued_jobs - You call
perform_enqueued_jobsexplicitly 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
endTesting 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
endOr 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
endperform_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
endTesting 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
endRSpec.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
endTesting 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
endTest 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
endTesting 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
endRSpec.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
endTesting 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
endThree Sidekiq test modes:
Sidekiq::Testing.fake!— jobs accumulate inSomeWorker.jobs, not executedSidekiq::Testing.inline!— jobs execute immediately when enqueuedSidekiq::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
endClearing 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
endOr 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
endTest 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
endSummary
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.