Testing Bulk Mailers in Rails
Building large email systems is a little different than other systems because the #1 priority is that you don’t accidentally send out emails. At the same time, you still need to profile and test it like you would any large scale system to look for flaws or bottlenecks. It’s very helpful to build in hooks to override certain aspects of delivering a campaign. Let’s start with a basic code snippet that sends an email campaign:
def deliver_all(users)
users.each {|user| deliver user.email}
end
The first thing you’ll want to do is try out this method — but user shouldn’t receive an email. We can improve deliver_all with an options hash that takes an optional email override:
def deliver_all(users, options={})
users.each {|user| deliver options.fetch(:email_override, user.email)}
end
This gets us two things. First, you can pass in {:email_override => nil} and have your mailer bail just before sending if the recipient is nil. You can also pass in your own email address to test that the email shows up in your inbox and looks correctly. But, if you have a lot of users, you might end up with a lot of email. deliver_all can be improved again with an optional limit:
def deliver_all(users, options={})
(options[:limit].nil? users : users.first(options[:limit]).each do |user|
deliver options.fetch(:email_override, user.email)
end
end
We use the conditional on options[:limit] to .each over the entire list or just the first options[:limit] number:
deliver_all(users, :email_override => ‘user@example.org’, :limit => 1)
If you’re delivering emails asynchronously, it can be hard to tell if a bug is in the email itself or just the asynchronous deliver mechanism. It’s best to eliminate async sending as a failure point as early as you can, and we can improve deliver_all to quickly switch between the two:
def deliver_all(users, options={})
(options[:limit].nil? users : users.first(options[:limit]).each do |user|
deliver_method = options[:sync] ? :deliver : :deliver_async
send deliver_method, options.fetch(:email_override, user.email)
end
end
Based on the value of options[:async], we switch between two different delivery methods. An end to end test that sends one email synchronously to an email address you specify is as simple as:
deliver_all(users, :email_override => ‘user@example.org’, :limit => 1, :sync => true)
and calling it for real to actually send to your users would still be:
deliver_all(users)
Let’s look at another way you might have solved this problem:
def deliver_all(users, options={})
if options[:limit]
users.first(options[:limit]).each do |user|
email = options.fetch(:email_override, user.email)
if options[:sync]
deliver email
else
deliver_async email
end
end
else
users.each do |user|
email = options.fetch(:email_override, user.email)
if options[:sync]
deliver email
else
deliver_async email
end
end
end
end
Other than not being DRY, this seems like a reasonable approach. However, using the same ‘test’ and ‘production’ methods end up going down two completely different code paths. Setting a :limit takes you down the first if-branch and setting :sync takes you down another if-branch. It’s entirely possible that the (:limit, :sync) code path is bug free, but the `production` code path could still be hiding bugs because its code path is not exercised. Because of this, it’s important that the hooks you build in for testing the campaign use as many of the same code paths as the production call would use. In the example above, you’d most likely be iterating and testing using the (:limit, :sync) code path and just be diligent that anything you fix in the test branch also gets fixed in the production branch. That ends up being extra information to juggle in your head as you’re building, and it’s easier to just eliminate that as a source of problems.