Testing Rails Background Workers

Written: Nov 24, 2015

It’s not old, it’s vintage.

This post was last updated some years ago and hasn’t been updated recently. Be aware that some of the content, tools, and techniques described may not be completely up-to-date.

Set it and forget it

When it was released in Rails 4.2, Active Job was an important addition to the platform. Background jobs have been a part of the ecosystem for a long time, but this was the first time that developers had a single API to work with a variety of job queuing frameworks. Having a common interface has led to a shared base of knowledge and patterns for developing and testing workers.

In the last post, we looked at some good practices for writing well-designed background jobs in Rails using Active Job, but we didn’t get around to the question of how to test them. This post will focus on a step-by-step strategy for testing all of your application’s “set it and forget it” code - one that leverages the unified Active Job interface and the tools Rails and Minitest provide us.

Testing your business logic

In the last post, we said that moving business logic out of our background jobs and into plain old Ruby objects (POROs) was a good way of future-proofing our applications. As an example, we looked at a class that calculates the total score for a judge’s evaluation from an app I’ve been working on.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# app/models/assessment_scorer.rb
class AssessmentScorer
  def self.score(assessments)
    assessments.each do |assessment|
      score = calculate_score(assessment)
      assessment.score = score && assessment.save!
      yield assessment, score if block_given?
    end
  end

  def self.calculate_score(assessment)
    # ...
  end
end

By extracting the domain-specific processing into its own class, we keep it from becoming entangled with the other work our application does - persistence, serving web requests, or in our case, background processing. The resulting PORO is completely portable and can be used anywhere in the application where you need to calculate a score. Because the interface is so basic, it’s also dead simple to test.

1
2
3
4
5
6
7
8
9
10
11
12
13
# test/models/assessment_scorer_test.rb
class AssessmentScorerTest < ActiveSupport::TestCase
  test "scores for assessments are set" do
    assessments = [assessments(:good), assessments(:bad)]
    assessments.each { |j| assert_nil j.score }

    AssessmentScorer.score(assessments)
    assert_equal 46, assessments(:good).reload.score
    assert_equal 24, assessments(:bad).reload.score
  end

  # ...
end

AssessmentScorer.score has no meaningful return value, so we test it by making assertions about direct public side effects - in this case, that the score for each Assessment has been set to the expected value.

Testing the job

As a rule of thumb, I try to keep my background workers lean and mean - 10-15 lines of code is usually plenty. That’s only possible by limiting what the job is allowed to do. By removing all the business logic, you can reduce the #perform method to just a few basic responsibilities.

  • Fetching models from the database
  • Handling exceptions raised during business logic execution
  • Retrying jobs, scheduling additional jobs, other follow-up actions

In the previous post, you saw an example that followed these guidelines about extracting business logic but used the job itself to handle flow control.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# app/jobs/process_imported_users_job.rb
class ProcessImportedUsersJob < ActiveJob::Base
  queue_as :medium_priority

  rescue_from ActiveRecord::RecordNotFound, CSV::MalformedCSVError do |error|
    UserImportsMailer.import_failed(@import, error).deliver_now
  end

  def perform(import_id)
    @import = UserImport.find(import_id)
    csv = @import.user_data

    users, errors = UserBulkLoader.load(csv) do |user|
      logger.debug "Created new user account: #{ user.login }"
    end

    UserImportsMailer.import_completed(@import, users, errors).deliver_now
  end
end

All the code for transforming the uploaded data into new user accounts has been refactored away to the UserBulkLoader class, so the worker is actually responsible for very little. The test for the job can stay focused on two possible conditions: a successfully completed load and a rescued exception.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# test/jobs/process_imported_users_job_test.rb
class ProcessImportedUsersJobTest < ActiveJob::TestCase
  include ActionMailer::TestHelper

  setup do
    @import = user_imports(:admin_import)
    ActionMailer::Base.deliveries.clear
  end

  test "send a message upon completing an import" do
    assert_no_emails
    ProcessImportedUsersJob.perform_now(@import)

    assert_emails 1
    message = ActionMailer::Base.deliveries.last
    assert_equal [@import.user.email], message.to
    assert_match /Your User Import Task Has Completed/, message.subject
  end


  test "a completely failing import job should notify the creator" do
    kaboom = -> (data) { raise ActiveRecord::RecordNotFound, "Oh no!" }
    UserBulkLoader.stub(:load, kaboom) do
      ProcessImportedUsersJob.perform_now(@import)
    end

    assert_emails 1

    message = ActionMailer::Base.deliveries.last
    body = message.html_part.to_s
    assert_match /Your User Import Task Has Failed/, message.subject
    assert_match /ActiveRecord::RecordNotFound/, body
    assert_match /Oh no\!/, body
  end
end

Here we’re using perform_now to execute the job immediately. Since we’re only interested in what happens inside the #perform method, we don’t need to bother enqueuing an instance yet.

Testing job queueing

Background jobs are usually conditionally fired off from a model callback or a controller action. In the case of this user import process, I chose to queue it from the controller action that handles new UserImport creation.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# app/controllers/user_imports_controller.rb
class UserImportsController < ApplicationController
  def create
    @user_import = current_user.user_imports.build(user_import_params)
    authorize! :create, @user_import

    respond_to do |format|
      if @user_import.save
        ProcessImportedUsersJob.perform_later(@user_import)
        format.html { redirect_to user_imports_path, notice:
          'User import was successfully created and is being processed.' }
      else
        format.html { render :new }
      end
    end
  end

  # ...
end

To test this, we’ll want to demonstrate that the controller action queues a job when the new record is successfully saved and that it does nothing when the save fails.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# test/controllers/user_imports_controller_test.rb
describe UserImportsController, "as administrator" do
  include ActiveJob::TestHelper

  before do
    sign_in users(:admin)
  end

  describe "#create" do
    describe "with valid parameters" do
      it "redirects to the imports list" do
        assert_difference "UserImport.count" do
          post :create, user_import: { users_csv: csv_attachment }
        end
        assert_redirected_to user_imports_path
        expect(flash[:notice]).must_equal 'User import was successfully created and is being processed.'
      end

      it "enqueues one job to process the import" do
        assert_enqueued_with(job: ProcessImportedUsersJob) do
          post :create, user_import: { users_csv: csv_attachment }
        end
      end
    end

    describe "with no file upload specified" do
      it "displays the new screen again" do
        assert_no_enqueued_jobs do
          post :create, user_import: { users_csv: "" }
        end
        assert_response :success
      end
    end
  end

  # ...
end

You need to include the ActiveJob::TestHelper module in your test case to gain access to the assertions and helper methods that needed to check which jobs have been queued or performed during the test.

Also, while Sidekiq is usually my Active Job backend of choice, I switch to the Rails-supplied test adapter for better control over job execution when running tests. This is the default for all tests that subclass ActiveJob::TestCase, but I use it for all tests by including the following in my test helper.

1
2
3
4
# test/test_helper.rb
class ActiveSupport::TestCase
  Rails.application.config.active_job.queue_adapter = :test
end

Since Active Job provides a common interface for all supported queuing backends, we can swap in a different adapter with no changes to code or tests.

Testing your application end-to-end

Has this ever happened to you? All the components of your application are fully covered with tests, but still, there are bugs creeping in - maybe even bugs that show up only in production or, worse, randomly. Who hasn’t been bitten by bugs in code that seemed well tested at every level?

Running background jobs solves one problem in your application but creates another by spreading the work across multiple processes. Some classes of issues only show up in concurrent systems, and developers are historically really bad at isolating them. One classic example is when the background job attempts to work with data that the main application thread hasn’t committed to the database yet.

You’ll have a better shot at detecting these types of defects in development using some sort of end-to-end tests. I like acceptance testing with Capybara and Minitest for this kind of thing, but you can use whichever tools you prefer - just as long as they simulate the way real users will interact with your application.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# test/features/can_upload_user_csv_test.rb
feature "Can Upload User CSV" do
  include ActiveJob::TestHelper

  let(:user)     { users(:admin) }
  let(:csv_path) { File.join(Rails.root, "test/fixtures/files/users.csv") }

  scenario "upload a new batch of users" do
    visit root_path
    fill_in "email", with: user.email
    fill_in "password", with: "password"
    click_button "Sign In"
    expect(page).must_have_content "Hi, Admin User!"

    click_link "Create New User Import"
    expect(page).must_have_content "New User Batch Upload"

    assert_difference("User.count", 3) do
      assert_performed_with(job: ProcessImportedUsersJob) do
        attach_file "Users CSV File", csv_path
        click_button "Create User import"
        expect(page).must_have_content "User import was created and is being processed."
      end
    end
  end
end

The test above mimics an administrator logging into the application and uploading a CSV file with user data. As we already mentioned, using the Active Job test adapter ensures that jobs aren’t performed except when we explicitly permit it. In this case, only the jobs queued within the assert_performed_with block will be executed, and we wrap that in a further assert_difference block to verify that the visible side effect - the creation of new user accounts - actually occurs.

Automated acceptance tests like this one won’t guarantee that you find every possible integration bug, but they will improve your chances substantially over manual testing. They also happen to be great at surfacing regressions after code changes and problems with displayed data or DOM manipulation by client-side scripts.