Testing async emails, the Rails 4.2+ way
Say you’re building an app that needs to send emails. We all agree that we should never block the controller, so async delivery is the way to go. To achieve this, we’ll move our email sending code outside the original request/response cycle with the help of an asynchronous processing library that can handle jobs in the background.
How can we be confident that our code behaves as expected upon making this change? In this blog post, we’ll look at a way to test it. And we’ll use MiniTest (since it ships with Rails) but the concepts presented here can be easily translated to RSpec.
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 hadn’t just
mentioned it, you wouldn’t be able to tell, as a developer or a user. Setting up a queuing
system is a topic on it’s own, you can read more on getting started with Active Job here.
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 we would have to do 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 protected]'
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][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? We’d love to hear your experiences.
Share your thoughts with @engineyard on Twitter
OR
Talk about it on reddit