You've added background jobs to your Rails app. Emails send asynchronously. Images process in the background. Webhooks fire without slowing down requests.
But how do you test all of this?
Jobs run outside the normal request cycle. They don't appear in your system tests. They don't show errors in your browser. When they fail, your users don't see it. They just don't get their welcome email.
Let me show you how to test every job your app queues.
Why Testing Jobs Is Different
Regular controller tests are straightforward. You make a request. You check the response.
Job tests are different because:
| Challenge | Why It Matters |
|---|---|
| Jobs run later | You need to verify they were queued, not just executed |
| Jobs can fail silently | No user sees the error |
| Jobs have retries | You need to test retry logic |
| Jobs depend on data | The data might change between queueing and execution |
Testing jobs requires a different approach. But Rails gives you all the tools you need.
Part 1: Testing That Jobs Are Enqueued
The most basic test: verify that your controller queues the right job.
Basic Enqueue Testing
# spec/requests/users_spec.rb
require "rails_helper"
RSpec.describe "Users", type: :request do
describe "POST /users" do
let(:valid_params) { { user: { email: "alice@example.com", name: "Alice" } } }
it "enqueues a welcome email job" do
expect {
post users_path, params: valid_params
}.to have_enqueued_job(WelcomeEmailJob)
end
it "enqueues the job with the correct arguments" do
expect {
post users_path, params: valid_params
}.to have_enqueued_job(WelcomeEmailJob).with(instance_of(Integer))
end
end
end
Testing Multiple Jobs
it "enqueues both welcome email and analytics job" do
expect {
post users_path, params: valid_params
}.to have_enqueued_job(WelcomeEmailJob).and have_enqueued_job(AnalyticsJob)
end
Testing Job Count
it "enqueues exactly one job" do
expect {
post users_path, params: valid_params
}.to have_enqueued_job.exactly(1).times
end
it "enqueues no jobs when validation fails" do
expect {
post users_path, params: { user: { email: "", name: "" } }
}.not_to have_enqueued_job
end
Testing Queue Name
it "enqueues on the default queue" do
expect {
post users_path, params: valid_params
}.to have_enqueued_job(WelcomeEmailJob).on_queue("default")
end
Part 2: Testing Job Execution
Sometimes you need to test what the job actually does, not just that it was queued.
Using perform_now for Testing
# spec/jobs/welcome_email_job_spec.rb
require "rails_helper"
RSpec.describe WelcomeEmailJob, type: :job do
let(:user) { create(:user, email: "alice@example.com") }
it "sends a welcome email" do
expect {
WelcomeEmailJob.perform_now(user.id)
}.to change(ActionMailer::Base.deliveries, :count).by(1)
end
it "sends to the correct email address" do
WelcomeEmailJob.perform_now(user.id)
email = ActionMailer::Base.deliveries.last
expect(email.to).to eq(["alice@example.com"])
end
it "includes the user's name in the email" do
WelcomeEmailJob.perform_now(user.id)
email = ActionMailer::Base.deliveries.last
expect(email.body.encoded).to include(user.name)
end
end
Testing Database Changes
RSpec.describe ProcessOrderJob, type: :job do
let(:order) { create(:order, status: "pending") }
it "updates the order status" do
expect {
ProcessOrderJob.perform_now(order.id)
}.to change { order.reload.status }.from("pending").to("processed")
end
it "creates an invoice record" do
expect {
ProcessOrderJob.perform_now(order.id)
}.to change(Invoice, :count).by(1)
end
end
Testing External API Calls
RSpec.describe WebhookDeliveryJob, type: :job do
let(:webhook) { create(:webhook, url: "https://api.example.com/webhook") }
let(:payload) { { event: "user.created", id: 123 } }
before do
stub_request(:post, "https://api.example.com/webhook")
.to_return(status: 200, body: '{"status":"ok"}')
end
it "makes the HTTP request" do
WebhookDeliveryJob.perform_now(webhook.id, payload)
expect(WebMock).to have_requested(:post, "https://api.example.com/webhook")
.with(body: payload.to_json)
end
it "handles failed requests gracefully" do
stub_request(:post, "https://api.example.com/webhook")
.to_return(status: 500)
expect {
WebhookDeliveryJob.perform_now(webhook.id, payload)
}.to raise_error("Webhook failed with 500")
end
end
Part 3: Testing Jobs That Use perform_later
When you queue a job with perform_later, you need to tell RSpec to actually run it.
Using perform_enqueued_jobs
it "sends the email when the job runs" do
expect {
perform_enqueued_jobs do
post users_path, params: valid_params
end
}.to change(ActionMailer::Base.deliveries, :count).by(1)
end
Testing the Full Flow
RSpec.describe "Order checkout flow", type: :request do
let(:cart) { create(:cart, items_count: 3) }
it "processes the order and sends confirmation email" do
expect {
perform_enqueued_jobs do
post checkout_path, params: { cart_id: cart.id }
end
}.to change(ActionMailer::Base.deliveries, :count).by(1)
expect(response).to redirect_to(order_confirmation_path)
expect(Order.last.status).to eq("completed")
end
end
Testing Specific Jobs Only
it "only runs welcome email jobs, not analytics jobs" do
perform_enqueued_jobs(only: WelcomeEmailJob) do
post users_path, params: valid_params
end
# Welcome email was sent
expect(ActionMailer::Base.deliveries.count).to eq(1)
# Analytics job was queued but not executed
expect(AnalyticsJob).to have_been_enqueued
end
Part 4: Testing Job Retries and Failures
Jobs fail. Your tests should verify that your app handles failures correctly.
Testing Retry Logic
RSpec.describe WebhookDeliveryJob, type: :job do
let(:webhook) { create(:webhook) }
let(:payload) { { data: "test" } }
before do
stub_request(:post, webhook.url).to_return(status: 500).times(2)
stub_request(:post, webhook.url).to_return(status: 200).times(1)
end
it "retries failed attempts" do
expect {
WebhookDeliveryJob.perform_now(webhook.id, payload)
}.not_to raise_error
# Should have made 3 attempts (2 failures, 1 success)
expect(WebMock).to have_requested(:post, webhook.url).times(3)
end
end
Testing Maximum Retries
RSpec.describe UnreliableApiJob, type: :job do
it "gives up after maximum retries" do
stub_request(:post, "https://api.example.com/data")
.to_return(status: 500)
expect {
UnreliableApiJob.perform_now("test")
}.to raise_error(StandardError)
# Should have retried 3 times (default)
expect(WebMock).to have_requested(:post, "https://api.example.com/data").times(3)
end
end
Testing Job Timeouts
RSpec.describe SlowJob, type: :job do
it "raises an error when it times out" do
allow_any_instance_of(SlowJob).to receive(:do_work).and_wrap_original do |m|
sleep(60) # Simulate slow work
m.call
end
expect {
SlowJob.perform_now
}.to raise_error(Timeout::Error)
end
end
Part 5: Testing Jobs with Attachments
Jobs that process file uploads need special attention.
RSpec.describe AvatarProcessingJob, type: :job do
let(:user) { create(:user) }
let(:image_file) { fixture_file_upload("avatar.jpg", "image/jpeg") }
before do
user.avatar.attach(image_file)
end
it "resizes the uploaded image" do
expect {
AvatarProcessingJob.perform_now(user.id)
}.to change { user.avatar.blob.byte_size }.to be < 1.megabyte
end
it "creates multiple sizes" do
AvatarProcessingJob.perform_now(user.id)
expect(user.avatar.variant(:thumb)).to be_attached
expect(user.avatar.variant(:medium)).to be_attached
expect(user.avatar.variant(:large)).to be_attached
end
end
Part 6: Testing Scheduled Jobs
Jobs that run at specific times require time manipulation.
RSpec.describe ReviewReminderJob, type: :job do
let(:order) { create(:order, completed_at: 7.days.ago) }
it "sends reminder after 7 days" do
travel_to(8.days.from_now) do
expect {
ReviewReminderJob.perform_now(order.id)
}.to change(ActionMailer::Base.deliveries, :count).by(1)
end
end
it "does not send before 7 days" do
travel_to(6.days.from_now) do
expect {
ReviewReminderJob.perform_now(order.id)
}.not_to change(ActionMailer::Base.deliveries, :count)
end
end
end
Testing Scheduled Enqueuing
it "schedules the job for 7 days later" do
expect {
post orders_path, params: { order_id: order.id }
}.to have_enqueued_job(ReviewReminderJob).at(7.days.from_now)
end
Part 7: Testing Job Uniqueness
Some jobs should only run once per resource.
RSpec.describe SyncUserJob, type: :job do
let(:user) { create(:user) }
it "prevents duplicate jobs for the same user" do
expect {
2.times { SyncUserJob.perform_later(user.id) }
}.to have_enqueued_job(SyncUserJob).exactly(1).times
end
it "allows jobs for different users" do
user2 = create(:user)
expect {
SyncUserJob.perform_later(user.id)
SyncUserJob.perform_later(user2.id)
}.to have_enqueued_job(SyncUserJob).exactly(2).times
end
end
Part 8: Testing Jobs in System Tests
Sometimes you need to test the entire flow, including background jobs.
# spec/system/order_flow_spec.rb
require "rails_helper"
RSpec.describe "Order flow", type: :system do
scenario "user completes order and receives confirmation email" do
# Enable job processing for system tests
ActiveJob::Base.queue_adapter = :test
ActiveJob::Base.queue_adapter.perform_enqueued_jobs = true
visit product_path(product)
click_button "Add to Cart"
click_button "Checkout"
fill_in "Email", with: "alice@example.com"
click_button "Place Order"
expect(page).to have_content("Order placed!")
# Email should have been sent
email = ActionMailer::Base.deliveries.last
expect(email.to).to eq(["alice@example.com"])
expect(email.subject).to include("Order Confirmation")
end
end
Testing with perform_enqueued_jobs
RSpec.describe "Order flow", type: :system do
it "processes background jobs during the test" do
perform_enqueued_jobs do
visit checkout_path
click_button "Place Order"
end
expect(page).to have_content("Order placed! Check your email.")
end
end
Part 9: Testing Job Arguments
Ensure your jobs are receiving the right data.
RSpec.describe ExportReportJob, type: :job do
let(:user) { create(:user) }
let(:date_range) { Date.today.all_week }
it "receives the correct arguments" do
expect {
ExportReportJob.perform_later(user.id, date_range)
}.to have_enqueued_job(ExportReportJob).with(user.id, date_range)
end
it "handles nil arguments" do
expect {
ExportReportJob.perform_later(user.id, nil)
}.to have_enqueued_job(ExportReportJob).with(user.id, nil)
end
end
Part 10: Complete Test Suite Example
Here's a complete test suite for an order processing job:
# spec/jobs/process_order_job_spec.rb
require "rails_helper"
RSpec.describe ProcessOrderJob, type: :job do
let(:user) { create(:user) }
let(:order) { create(:order, user: user, status: "pending") }
describe "enqueuing" do
it "is enqueued after order creation" do
expect {
create(:order)
}.to have_enqueued_job(ProcessOrderJob)
end
it "is enqueued with the order ID" do
expect {
create(:order)
}.to have_enqueued_job(ProcessOrderJob).with(instance_of(Integer))
end
end
describe "execution" do
before do
allow(OrderMailer).to receive(:confirmation).and_return(double(deliver_now: true))
end
it "changes order status to processed" do
expect {
ProcessOrderJob.perform_now(order.id)
}.to change { order.reload.status }.from("pending").to("processed")
end
it "charges the customer" do
expect(Stripe::Charge).to receive(:create).once
ProcessOrderJob.perform_now(order.id)
end
it "sends confirmation email" do
ProcessOrderJob.perform_now(order.id)
expect(OrderMailer).to have_received(:confirmation).with(order)
end
it "creates an invoice" do
expect {
ProcessOrderJob.perform_now(order.id)
}.to change(Invoice, :count).by(1)
end
context "when payment fails" do
before do
allow(Stripe::Charge).to receive(:create).and_raise(Stripe::CardError.new("Declined"))
end
it "does not change order status" do
expect {
ProcessOrderJob.perform_now(order.id)
}.to raise_error(Stripe::CardError)
expect(order.reload.status).to eq("pending")
end
it "does not create an invoice" do
expect {
ProcessOrderJob.perform_now(order.id)
}.to raise_error(Stripe::CardError)
expect(Invoice.count).to eq(0)
end
it "sends a payment failure email" do
expect {
ProcessOrderJob.perform_now(order.id)
}.to raise_error(Stripe::CardError)
expect(OrderMailer).to have_received(:payment_failed).with(order)
end
end
end
describe "retries" do
it "retries on network errors" do
allow(Stripe::Charge).to receive(:create).and_raise(Net::ReadTimeout)
expect {
ProcessOrderJob.perform_now(order.id)
}.to raise_error(Net::ReadTimeout)
# Should have attempted 3 times
expect(Stripe::Charge).to have_received(:create).exactly(3).times
end
end
end
The Testing Checklist
Use this checklist for every background job you write.
Basic Tests
- [ ] Job is enqueued correctly
- [ ] Job receives correct arguments
- [ ] Job runs without errors
- [ ] Job updates database correctly
Execution Tests
- [ ] Email is sent (if applicable)
- [ ] API calls are made (if applicable)
- [ ] Database records are created/updated
- [ ] Side effects happen as expected
Failure Tests
- [ ] Job handles invalid data gracefully
- [ ] Retry logic works
- [ ] Timeouts are handled
- [ ] Errors are logged
Edge Cases
- [ ] Job handles nil arguments
- [ ] Job handles missing records
- [ ] Job handles duplicate execution
- [ ] Scheduled jobs run at the right time
Integration Tests
- [ ] Full flow works in request tests
- [ ] System tests include background jobs
- [ ] Multiple jobs work together
Summary
Testing background jobs is different from testing regular code. But Rails gives you everything you need.
| Test Type | Tool | Purpose |
|---|---|---|
| Enqueue testing | have_enqueued_job |
Verify job was queued |
| Execution testing | perform_now |
Test job behavior |
| Full flow testing | perform_enqueued_jobs |
Test with actual execution |
| Retry testing | Stub failures | Verify retry logic |
| System testing | perform_enqueued_jobs |
Test complete user flows |
Quick Reference
# Test job was queued
expect { ... }.to have_enqueued_job(MyJob)
# Test job was queued with args
expect { ... }.to have_enqueued_job(MyJob).with(arg1, arg2)
# Test job was queued on specific queue
expect { ... }.to have_enqueued_job(MyJob).on_queue("high_priority")
# Run all queued jobs during test
perform_enqueued_jobs do
# test code
end
# Run only specific jobs
perform_enqueued_jobs(only: MyJob) do
# test code
end
# Test job execution
MyJob.perform_now(arg1, arg2)
Test your jobs thoroughly. Your users won't see background job failures. But they will notice missing emails and unprocessed orders.
Make sure everything works before it reaches production.