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 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
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
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
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
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 Queuing
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
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
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
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, as I’ve written about before, 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
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.