乐者为王

Do one thing, and do it well.

使用Rails 4.2+ 测试异步邮件

英文原文:https://blog.engineyard.com/2015/testing-async-emails-rails-42

如果你正在构建的应用需要发送邮件,我们都赞同绝不能阻塞控制器,因此异步发送必不可少。为了实现这一点,我们需要借助可以在后台处理任务的异步处理库将邮件发送代码从原始请求/响应周期中移出。

我们如何确信我们的代码在进行此更改时会如预期的那样行事?在这篇博文中,我们将研究测试它的一种方法。我们将使用MiniTest(因为Rails内置了这个框架),但是这里介绍的概念可以很容易地转换为RSpec。

好消息是从Rails 4.2开始,异步发送邮件比以前更容易。我们将在我们的示例中使用Sidekiq作为队列系统,但是由于ActionMailer#deliver_later构建在Active Job之上,该接口很干净,使得异步处理库的使用不可知。这意味着如果我刚才没有提到它,作为开发者或者用户的你将不会知情。设置队列系统是另一个话题,你可以在Getting Started With Active Job了解更多。

不要依赖具体

在我们的示例中,我们假设Sidekiq和它的依赖关系已经正确配置,因此特定于此场景的唯一一段代码是声明Active Job将使用哪个队列适配器:

1
2
3
4
5
6
7
8
# config/application.rb

module OurApp
  class Application < Rails::Application
    ...
    config.active_job.queue_adapter = :sidekiq
  end
end

Active Job在隐藏所有的实质的队列实现细节上做得很好,使得它对于Resque、Delayed Job或其它队列可以用同样的方式工作。所以如果我们要换用Sucker Punch,唯一要做的更改就是在引用相应的依赖包后,将队列适配器从:sidekiq切换到:sucker_punch。

基于Active Job

如果你刚开始使用Rails 4.2,或者对Active Job不太了解,Ben Lewis’ intro to Active Job是一个很好的起点。不过,这篇文章还留给我一个细节去盼望,即找到一个干净的、地道的方法来测试一切都如预期的那样工作。

根据本文的目标,我们假设你有:

  • Rails 4.2+
  • 已经设置好使用队列后端的Active Job(例如Sidekiq、Resque等)
  • 一个Mailer

任何Mailer都应该使用这里描述的概念,我们将用以下这封“欢迎邮件”来使我们的示例更实际:

1
2
3
4
5
6
7
8
9
10
11
12
#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

为了保持事情简单并专注于什么是重要的,我们想在用户注册后给他发送一封“欢迎邮件”。

这就像Rails指南邮件程序示例中的那样:

1
2
3
4
5
6
7
8
9
10
# 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

Mailer应该做最后的任务

接下来,我们要确保控制器内的任务能完成我们预期的。

在测试指南中,在Custom Assertions And Testing Jobs Inside Other Components上面的部分教给我们大约半打这样的自定义断言。

或许你的第一本能是立刻使用[assert_enqueued_jobs][assert-enqueued-jobs]来测试每次创建新用户时我们是否将邮件发送任务放入队列。

你可以这样做:

1
2
3
4
5
6
7
8
9
10
11
12
# 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

即使你这样做,你也会惊讶于测试的失败,被告知assert_enqueued_jobs没有定义而无法使用。

这是因为我们的测试继承自ActionController::TestCase,它在编写时没有包含ActiveJob::TestHelper。

但是我们可以快速地修复这个问题:

1
2
3
4
5
6
# test/test_helper.rb

class ActionController::TestCase
  include ActiveJob::TestHelper
  ...
end

假设我们的代码能完成我们预期的,我们的测试现在应该是绿色的。

这是个好消息。我们可以继续重构代码,添加新功能,或者添加新测试。让我们跟随后者,测试我们的邮件是否被发送,以及它是否有预期的内容。

作为delivery_method选项被配置成:test的结果,ActionMailer能给我们提供一个所有发出邮件的数组。我们可以通过ActionMailer::Base.deliveries访问它。当发送的邮件串联时,测试邮件是否被真正发送就非常简单。我们要做的就是在行动完成后,检查交付数量增加1。用MiniTest来写的话,就像下面这样:

1
2
3
assert_difference 'ActionMailer::Base.deliveries.size', +1 do
  post :create, {...}
end

我们的测试是实时发生的,但正如我们在本文开头赞同的那样,不能阻塞控制器并在后台任务中发送邮件,所以我们现在需要协调一切,以确保我们的系统是确定的。因此,在我们的异步世界中,我们需要首先执行所有入队的任务,然后才能评估其结果。要执行待处理的Active Job任务,我们将使用perform_enqueued_jobs

1
2
3
4
5
6
7
8
9
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

缩短反馈循环

迄今为止,我们都在进行功能性测试以确保我们的控制器如预期的那样行事。但是,当代码中的更改可能会破坏我们发送的邮件时,如何单元测试邮件程序才能缩短反馈循环并获得快速洞察?

Rails测试指南建议在这里使用fixtures,但我发现它们太过脆弱。特别是在开始的时候,当还在试验邮件的设计或内容时,变化会很快地使它们过时,导致测试变红。相反,我偏向使用assert_match来关注应该是邮件正文部分的关键元素。

为了这个目的和更多(比如抽象处理多部分邮件的逻辑),我们可以构建我们的自定义断言。这使我们能够扩展MiniTest标准断言Rails专用断言。这也是创建我们自己的领域专用语言(DSL)进行测试的一个很好的示例。

让我们在test目录中创建一个shared文件夹来托管我们的SharedMailerTests模块。我们的自定义断言看起来像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# /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

接下来,我们要让邮件程序测试意识到这个自定义断言,所以我们把它混合在ActionMailer::TestCase中。我们可以以类似于我们在ActionController::TestCase中包含ActiveJob::TestHelper的方式来做到这点:

1
2
3
4
5
6
7
8
# test/test_helper.rb

require 'shared/shared_mailer_tests'
...
class ActionMailer::TestCase
  include SharedMailerTests
  ...
end

请注意,我们首先需要在test_helper中依赖我们的shared_mailer_tests。

这些就绪后,我们现在可以确保我们的邮件包含我们预期的关键元素。想象一下,我们想确保我们发送给用户的URL包含一些用于追踪的特定UTM参数。我们现在可以使用我们的自定义断言配合我们的老朋友perform_enqueued_jobs做到这些:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 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
end

结论

让ActionMailer基于Active Job之上,使得从同步发送邮件切换到通过队列发送邮件变得非常简单,就如同从deliver_now切换到deliver_later那样。

由于Active Job使得设置任务基础设施(不需要知道太多你正在使用的队列系统)如此容易,你的测试不再需要关心是否以后会切换到Sidekiq或Resque。

让你的测试设置正确以便于可以充分利用Active Job提供的新的自定义断言可能会有点棘手。希望本教程能使你对整个过程更加明白。

附:你有ActionMailer或Active Job的经验吗?有任何建议吗?有什么陷阱?我们很乐意听到你的经验。

Comments