Verifying Background Emails in Rails
TL;DR
I made a gem to more easily test whether ActionMailer
emails will be delivered
later.
If you want to use the deliver_later_matchers
gem, you can find it on
RubyGems
and GitHub.
Providing Some Context
I was looking at one of our Rails apps to see how we
covered delayed mailer calls. So, for example, we have an application with an
EmailCampaign model. An EmailCampaign has_many :recipients
. Recipients
are essentially email addresses.
In typical Rails fashion, we have an EmailCampaignMailer
that we will use to
send out emails to all of an EmailCampaign
’s recipients after the
EmailCampaign
is created. Our controller action for creating an
EmailCampaign
, looks something like this:
class EmailCampaignsController < ApplicationController
def create
@email_campaign = EmailCampaign
.new(email_campaign_params)
@email_campaign.recipients = recipient_params
if @email_campaign.save
send_email_campaign
redirect_to :show, notice: 'Email Campaign will be sent'
else
render :new
end
end
private
def send_email_campaign
@email_campaign.recipients.each do |recipient|
EmailCampaignMailer
.campaign_email(recipient)
.deliver_later
end
end
end
Now, before I upset everybody who prefers their controllers skinny, we actually
do not have the logic to send an EmailCampaign
in our controller. I use the
Interactor gem extremely heavily
in practice; however, to avoid introducing new concepts, I will keep this example
as straightforward as possible.
So, my question was, given that an EmailCampaign is created, how can I verify
that an email is sent to each of its recipients. Furthermore, how can I verify
that the emails are sent in the background using deliver_later
?
First Attempt (stubbing)
My first thought was to stub out deliver_later
and verify that it was called;
however, that would require stubbing a chain of methods,
EmailCampaignMailer#campaign_email
and deliver_later
.
So a request spec for my create endpoint would look something like:
it 'delivers a campaign email later to each recipient' do
recipients = [
{ email: 'test1@test.com' },
{ email: 'test2@test.com' }
]
params = {
body: 'This is my email body',
subject: 'This is my email subject',
recipients: recipients
}
mail_double = double(deliver_later: nil)
allow(EmailCampaignMailer)
.to receive(:campaign_email)
.and_return(mail_double)
post '/email_campaigns', params: params
recipients.each do |recipient|
expect(EmailCampaignMailer)
.to have_received(:campaign_email)
.with(recipient)
end
expect(mail_double)
.to have_received(:deliver_later)
.twice
end
Not terrible, certainly not the worst test I have written, but it feels like the most complicated part of the test is setting up and verifying the doubles and stubs. Especially if a less experienced developer were to read to this test, the setup may be confusing.
Maybe There is a Better Way
I started searching for a better solution, something that would hide some of the test complexity, and I found an issue in the RSpec Rails repository where another developer, @fabn, had requested a new matcher for ActionMailer calls. One of the maintainers of RSpec Rails suggested that ActionMailer matchers provided an excellent opportunity for creating an extension gem.
I thought, I can do that; I can write gems. I started to look under the hood
of ActionMailer
and at the RSpec documentation
for writing custom matchers. Essentially, RSpec provides a really nice DSL for
creating one’s own custom matchers, so that piece of the gem ended up being
pretty straightforward. Perhaps the more interesting piece of the gem pertains
to how deliver_later
works.
What @fabn and I ultimately wanted was something like:
it 'delivers a campaign email later to each recipient' do
recipients = [
{ email: 'test1@test.com' },
{ email: 'test2@test.com' }
]
params = {
body: 'This is my email body',
subject: 'This is my email subject',
recipients: recipients
}
expect {
post '/email_campaigns', params: params
}.to enqueue_email(EmailCampaignMailer, :campaign_email)
.with(recipients.first)
.and enqueue_email(EmailCampaignMailer, :campaign_email)
.with(recipients.last)
end
That test case seems to match up with the real-life scenario much more
clearly. We expect that creating an EmailCampaign
will send emails to each
recipient. We will discuss how to achieve this below.
Diving Into ActionMailer
Alright, so, a crash course on ActionMailer and delayed emails. When you call
deliver_later
on an ActionMailer
email method
EmailCampaignMailer
.campaign_email(recipient)
.deliver_later
what happens? Well, ActionMailer
enqueues an ActiveJob
to deliver the
email. If you read the aforementioned
GitHub issue, you see that
ActionMailer
enqueues an ActionMailer::DeliveryJob
with the following
arguments:
['<MailerClass>', '<email_method>', 'deliver_now', *other_args]
So for our EmailCampaignMailer
example, an ActionMailer::DeliveryJob
with
['EmailCampaignMailer', 'campaign_email', 'deliver_now', recipient]
would be enqueued.
At this point, we know what we want to verify in our tests. We want to verify
that an ActionMailer::DeliveryJob
with a given set of arguments is enqueued.
Verifying Changes with a Block
Recall that the desired syntax for deliver_later_matchers
was:
expect {
EmailCampaignMailer
.campaign_email(recipient)
.deliver_later
}.to enqueue_email(EmailCampaign, :campaign_email)
.with(recipient)
So the matcher needs to run the given block and verify that the block adds a
matching ActionMailer::DeliveryJob
to the ActiveJob
queue. So, really, we
need to know which jobs are added by the block and then search through those
jobs to find one that matches our expected arguments.
It turns out that achieving this is not all that difficult. In
deliver_later.rb
, we have the matches?
method (shortened for brevity here):
def matches?(block)
existing_jobs_count = enqueued_jobs.size
block.call
job_range = existing_jobs_count..-1
jobs_from_block = enqueued_jobs[job_range]
matching_job_exists?(jobs_from_block)
end
matching_job_exists?
runs through the jobs enqueued by the block
and determines whether an ActionMailer::DeliveryJob
exists with the expected
arguments. This is actually the same strategy employed by the
ActiveJob Matchers
in RSpec Rails. There may have been some way to incorporate the ActiveJob
matchers to avoid duplicating some logic, but I was worried that doing so
would couple my gem too tightly to RSpec Rails.
Acknowlegdments
If you want to use the deliver_later_matchers
gem, you can find it on
RubyGems
and GitHub.
Thanks to @fabn for creating the issue in RSpec Rails
and sending me down the right path with regards to ActiveJob
.
Thanks to the folks at RSpec for awesome documentation.
Thanks to Thoughtbot and their
json_matchers gem from which
I borrowed the architecture for the deliver_later_matchers
gem.