Disclaimer: These "lab notes" were originally written for and published on Engine Yard's blog back in April.
While putting them together, Noah Slater's patient feedback changed the tone and the flow of this article significantly for the better.
Having said that, let's dig in!
Say you're building an app that needs to do send emails. And yes, we agree to never block the controller, so async delivery is the way to go. So we'll move our code from happening inline to using an asynchronous processing library to handle our jobs in the background.
What do we need to do to be confident that our code behaves as expected upon making this change? We'll use MiniTest (since it ships with Rails) but the concepts presented here can be easily translated to RSpec. Still, can we write our tests the same way, or what idiomatic changes do we need to make? How can we leverage Rails to reduce the complexity of our application as it aims for webscale?
The good news is that since Rails 4.2, sending emails asynchronously is easier than ever before. We'll use Sidekiq as the queuing system in our example, but since ActionMailer#deliver_later
is built on top of ActiveJob
, the interface is clean and agnostic of the asynchronous processing library used. This means that if I wouldn't have just mentioned it you couldn't tell, either form a developer’s perspective, or when it comes to user experience. Setting up a queuing system is a topic on it's own, and beyond the scope of this article, so…
Don't Sweat the Small Stuff
In our example, we assume that Sidekiq and its dependencies are properly configured, so the only piece of code that is specific to this scenario is declaring which queue adapter should Active Job use:
# config/application.rb
module OurApp
class Application < Rails::Application
# …
config.active_job.queue_adapter = :sidekiq
end
end
Active Job does a great job at hiding away all the nitty gritty queue implementation details, such that this works the same way for Resque, Delayed Job or anything else. So if we were to use Sucker Punch instead, the only change would be to switch the queue adapter from :sidekiq
to :sucker_punch
, after meeting the gem dependency.
On the Shoulders of Active Job
If you're new to Rails 4.2, or to Active Job in general, Ben Lewis' intro to Active Job is a great place to start. One detail it leaves me wishing for, though, is a clean, idiomatic approach to testing that everything works as expected.
So for the purpose of this article, we'll assume you have:
- Rails 4.2 or greater
- Active Job set up to use a queueing backend (e.g. Sidekiq, Resque, etc.)
- A Mailer
Any Mailer should work with the concepts described here, but we'll use this welcome email to make keep our examples pragmatic:
#app/mailers/user_mailer.rb
class UserMailer < ActionMailer::Base
default from: 'email@example.com'
def welcome_email(user:)
mail(
to: user.email,
subject: "Hi #{user.first_name}, and welcome!"
)
end
end
To keep things simple and focus on what's important, we want to send the user a welcome email once they join.
This is just like in the Rails guides mailer example:
# app/controllers/users_controller.rb
class UsersController < ApplicationController
# …
def create
# …
# Yes, Ruby 2.0+ keyword arguments are preferred
UserMailer.welcome_email(user: @user).deliver_later
end
end
The Mailer Should Do Its Job, Eventually
Next we want to ensure the job inside the controller does what we expect.
In the testing guides, the section on custom assertions for testing jobs inside other components teaches us about half a dozen of such custom assertions.
Perhaps your first instinct is to dive right in and use assert_enqueued_jobs
to test if we're enqueueing a mail delivery job every time we're creating a new user.
Here’s how you’d do that:
# test/controllers/users_controller_test.rb
require 'test_helper'
class UsersControllerTest < ActionController::TestCase
# …
test 'email is enqueued to be delivered later' do
assert_enqueued_jobs 1 do
post :create, {…}
end
end
end
If you do this though, you’ll surprised by the failing test that tells you assert_enqueued_jobs
is not defined for us to use.
This is because our test inherits from ActionController::TestCase
which, at the time of writing, does not include ActiveJob::TestHelper
.
But we can quickly fix this:
# test/test_helper.rb
class ActionController::TestCase
include ActiveJob::TestHelper
# …
end
# …
Assuming our code does what we expect, our test should now be green.
This is good news. We can either move to refactoring our code, adding new functionality, or adding new tests. Let's go with the latter and test if our email is delivered and if it has the expected content.
ActionMailer
provides us with an array of all the emails sent out with the delivery_method
option configured to :test
. We can access it through ActionMailer::Base.deliveries
. When delivering emails inline, testing that our action was successful and the email actually gets delivered is very easy. All we need to do is to check that our deliveries count was incremented by one upon completing our action. Translating this to MiniTest, it would look like this:
assert_difference 'ActionMailer::Base.deliveries.size', +1 do
post :create, {…}
end
Our tests are happening in real time, but as we agreed in the very beginning of this article to never block the controller and send emails in a background job, we now need to orchestrate everything to ensure our system is deterministic. For this reason, in our async world we need first to execute all enqueued job before we can evaluate their results. To execute the pending ActiveJob jobs we will use perform_enqueued_jobs
:
test 'email is delivered with expected content' do
perform_enqueued_jobs do
post :create, {…}
delivered_email = ActionMailer::Base.deliveries.last
# assert our email has the expected content, e.g.
assert_includes delivered_email.to, @user.email
end
end
Shorten the Feedback Loop
We've touched functional testing so far, ensuring our controller is behaving as expected. But what about unit testing our mailers to shorten the feedback loop and get quick insights when the changes in our code could break the emails that we send out?
The Rails guide on testing suggests using fixtures here, but I find them to be too brittle. Especially in the beginning, when still experimenting with the design or the content of the email, a change can quickly render them outdated and make our tests red. Instead, my preference is to use assert_match
to focus on key elements that should be part of the email's body.
For this purpose and more (like abstracting away the logic of handling emails that are multipart or not) we can build our custom assertions. This enables us to extend the standard MiniTest assertions or the Rails specific assertions. It is also a good example of creating our own Domain Specific Language (DSL) for testing.
Let's create a shared
folder within the test
one to host our SharedMailerTests
module. Our custom assert can look something like this:
# /test/shared/shared_mailer_tests.rb
module SharedMailerTests
# …
def assert_email_body_matches(matcher:, email:)
if email.multipart?
%w(text html).each do |part|
assert_match matcher, email.send("#{part}_part").body.to_s
end
else
assert_match matcher, email.body.to_s
end
end
end
Next, we need to make our mailer tests aware about this custom assertion, so let's mix it in ActionMailer::TestCase
. We can do this in a similar fashion to the way we included ActiveJob::TestHelper
in ActionController::TestCase
earlier:
# test/test_helper.rb
require 'shared/shared_mailer_tests'
# …
class ActionMailer::TestCase
include SharedMailerTests
# …
end
Note that we first need to require our shared_mailer_tests
in the test_helper
.
With this in place, we can now ensure that our emails contain the key elements that we expect. Imagine we want to make sure the URL we send the user contains some specific UTM parameters for tracking. We can now use our custom assertion in conjunction with our old friend perform_enqueued_jobs
like so:
# test/mailers/user_mailer_test.rb
class ToolMailerTest < ActionMailer::TestCase
# …
test 'emailed URL contains expected UTM params' do
UserMailer.welcome_email(user: @user).deliver_later
perform_enqueued_jobs do
refute ActionMailer::Base.deliveries.empty?
delivered_email = ActionMailer::Base.deliveries.last
%W(
utm_campaign=#{@campaign}
utm_content=#{@content}
utm_medium=email
utm_source=mandrill
).each do |utm_param|
assert_email_body_matches utm_param, delivered_email
end
end
end
Conclusion
Having ActionMailer standing on the shoulders of Active Job makes switching from sending emails right away to sending them via the queue as easy as switching deliver_now
to deliver_later
.
Since Active Job makes setting up your job infrastructure (without knowing too much about what queueing system you're using) so much easier, your tests shouldn't care if you switch to Sidekiq or Resque in the future.
It can be a little bit tricky to get your tests set up correctly so that they can take full advantage of the new custom assertions provided by Active Job. Hopefully, this tutorial made the process a little more transparent for you.
P.S. Have you had experience with ActionMailer or Active Job? Any tips? Any gotchas? Let us know by leaving a comment.
Cover image credit: http://www.railway-technology.com/contractors/track/cater/cater1.html