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@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][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.