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.