Skip to Content
Course content

133: Testing Background Jobs

Click on the "Edit" button in the top corner of the screen to edit your slide content.

When you first start moving logic into background jobs—maybe using Sidekiq or ActiveJob—there is a very strong temptation to keep testing those jobs exactly how you tested the synchronous code they replaced. You’ve got a user signing up, and you want to make sure they get a welcome email. It feels natural to write a test that creates a user and then immediately asserts that an email was delivered.

The trap of testing the side effect

The naive approach looks like this: you trigger the action that kicks off the job, and then you check if the final result happened. If you're using ActiveJob, you might even call perform_now in your test environment to force the job to run immediately so you can see the result.

it "sends a welcome email when a user is created" do
  user = User.create!(email: "dev@example.com")
  # Naive: checking the final side effect in the trigger test
  expect(ActionMailer::Base.deliveries.last.to).to include("dev@example.com")
end

I've seen this in countless codebases, and while it feels "thorough" because you're testing the whole chain, it's actually a liability. First, it makes your tests slow. As your app grows, you'll have dozens of jobs triggered by a single request. If every single one of those jobs actually executes during your controller or service tests, your test suite will crawl.

More importantly, you're violating the principle of single responsibility in your tests. If the WelcomeEmailJob fails because of a typo in the mailer template, your User creation test fails. You now have a failing test telling you that user creation is broken, when in reality, the user was created perfectly fine—it was the background worker that choked. That's a frustrating way to debug.

Decoupling the trigger from the execution

The better way is to split your testing into two distinct concerns: did I schedule the work, and does the work itself actually work?

In your trigger test (the one creating the user), you only care that the job was put on the queue with the right arguments. You don't care what the job does; that's the job's responsibility. In Rails, we use have_enqueued_job for this. It's fast because it doesn't actually run the job logic; it just checks the queue metadata.

it "enqueues a welcome email job" do
  expect {
    User.create!(email: "dev@example.com")
  }.to have_enqueued_job(WelcomeEmailJob).with("dev@example.com")
end

Then, you write a separate, isolated test for the WelcomeEmailJob itself. In this spec, you aren't creating a user through the API or a service; you're just calling perform on the job class and asserting that the email goes out. This is where you test the edge cases—like what happens if the user deleted their account in the three seconds between the job being enqueued and being executed.

Where the trade-off actually lands

Some developers argue that this approach "misses" the integration. They worry that you could have a passing trigger test and a passing job test, but the job still fails in production because the arguments passed don't match what the job expects.

I'll be honest: that is a theoretical risk. But the trade-off is worth it. By decoupling, you get a test suite that runs in seconds instead of minutes, and you get pinpoint accuracy when something breaks. If the job spec fails, you know exactly where the bug is. If the trigger spec fails, you know the orchestration is broken. I'll take that clarity over a "full chain" test any day of the week.




📋 Practical Task

Implement and Test a NewsletterSubscriptionJob

You are building a newsletter subscription feature. When a user submits their email via a NewsletterSubscriptionService, a NewsletterSubscriptionJob should be enqueued to handle the external API call to the mailing list provider.

Your task:

  • Create a NewsletterSubscriptionJob that takes an email string and logs "Subscribing #{email} to newsletter" (to simulate the API call).
  • Create a NewsletterSubscriptionService with a call(email) method that enqueues this job.
  • Write two separate tests:
    1. A test for the NewsletterSubscriptionService that asserts the NewsletterSubscriptionJob was enqueued with the correct email, without executing the job.
    2. A test for the NewsletterSubscriptionJob that calls perform directly and verifies the "Subscribing..." message is logged or the expected logic is executed.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.