【譯】使用Rails 4.2+ 測試非同步郵件系統

OneAPM官方技術部落格發表於2015-05-31

李哲 — MAY 26, 2015 原文連結:Testing async emails, the Rails 4.2+ way

假設想寫一個需要傳送郵件的應用,我們都知道在這種情況是絕對不能block控制器的,因此非同步傳送才是解決之道。為了達到這個目的,我們需要 將郵件傳送程式碼從最初的request/response迴圈中移到後臺的非同步處理程式中去。

然而,做出這樣的改變之後,我們如何確保程式碼能夠一如往常的執行呢?在這篇博文中,我們會探索一種新方法來進行測試,我們將要使用的MiniTest(Rails已經內建了這個框架), 這裡的概念同樣使用Rspec

現在有一個好訊息,那就是從Rails 4.2開始,非同步傳送郵件已經比之前簡單多了。我們在例子中使用Sidekiq作為佇列系統。由於ActionMailer#deliver_later建立在ActiveJob之上, 介面非常的簡潔明瞭。這表示,要不是我剛才提了一下,身為開發者或使用者的你也不會知情。建立佇列系統是另外一個話題, 你可以在getting started with Active Job here中瞭解更多相關的資訊。

別太依賴小元件

在例子中,我們假定Sidekiq及其依賴元件配置正確,因此本場景特有的一段程式碼是宣告Active Job該使用哪一個佇列調節器。

# config/application.rb

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

Active Job在隱藏實質性的佇列配置細節方面功能非常強大,以至於若是使用ResqueDelayed Job或其他元件,程式碼也不需要太大的改動。 因此,如果我們轉而使用Sucker Punch,唯一的改變就是在引用相應的依賴包後,將queue_adapter:sidekiq改為:sucker_punch就可以了。

站在Active Job的肩膀上

如果你是Rails 4.2或者Active Job不太瞭解,https://blog.engineyard.com/2014/getting-started-with-active-job 是就是很好的入門讀物。然而,這篇文章留給我的一個小期許是,找到一種簡潔、地道的測試方法,從而讓所有元件都能正常的執行。

根據本文的目標,我們假定你已經部署了:

  • Rails 4.2或者一個更高的版本
  • 已經設定好queue_adapter的Active Job (Sidekiq, Resque, 等)
  • 一封郵件

任何郵件都應該能夠按照這裡描述的方式正常工作,這裡我們就用一封歡迎郵件來使這個例子更實用:

#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

為了保持程式簡單並有針對性,我們會在每個使用者註冊後傳送給他們一封歡迎郵件。

這和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

接下來,我們想確保控制器內的任務能如所期待的那樣執行。

在測試指南中,custom assertions for testing jobs inside other components 的章節介紹了大約六種這樣的自定義斷言方法。

或許直覺告訴你應該單刀直入,然後使用assert_enqueued_jobs assert-enqueued-jobs 來測試每次新增新使用者時,我們有否將郵件傳送任務放入佇列。

你可能會這麼做:

# 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

不過我們很快就可以修正這一點:

# test/test_helper.rb

class ActionController::TestCase
  include ActiveJob::TestHelper
end

假定我們的程式碼如期執行,那麼測試應該就能順利通過了。

這是好訊息。現在,我們可以重構我們的程式碼,增加新的功能,也可以增加新的測試。我們可以選擇後者,看看我們的郵件有否投遞成功,如果是的話,那就檢查投遞的內容是否正確。

ActionMailer能為我們提供一個包含所有發出郵件的佇列,前提是將delivery_method選項設定為:test,我們能通過ActionMailer::Base.deliveries讀取這個佇列。

當同步的地投遞郵件時,檢測郵件是否傳送成功是很容易的。我們只需檢查在動作完成後,投遞計數器加1。用MiniTest來寫的話,就像下面這樣:

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

我們的測試是實時發生的,但在開篇就已經知道不能阻攔控制器,需要在後臺程式中傳送郵件,現在我們把所有的元件都組裝起來,確定系統是沒有問題的。 因此,在非同步的世界裡,我們必須先執行所有佇列中的任務再去判定執行結果。為了執行pending中的Active Job任務,我們使用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

縮短反饋流程

目前為止,我們都在進行功能性測試以確保我們的控制器如期執行。但是,程式碼的變化足以破壞我們傳送的郵件,為什麼不對我們的郵件程式進行單元測試,從而縮短反饋流程,然後更快地洞察變化呢?

Rails測試指南建議在這裡使用fixtures,但是我覺得他們太生硬了。尤其是一開始,當我們還在嘗試設計郵件時,一個變化很快就會讓他們變得不可用,讓我們的測試無法通過。 我偏向使用assert_match以關注那些構成郵件主體的關鍵元素。

為此,也因為其他原因(比如抽離處理多部分郵件的邏輯結構),我們可以建立自定義斷言。這可以擴充套件MiniTest標準斷言或Rails專屬斷言。 這也是建立自己的領域專屬語言(Domain Specific Language)並用於測試的好例子。

讓我們在測試一資料夾內建立一個共享資料夾,用以存放SharedMailerTests模組。我們自定義的斷言可以這麼來寫:

# /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類中。 然後可以借鑑之前把ActiveJob::TestHelper類包含於ActionController::TestCase類的方法:

# test/test_helper.rb

require 'shared/shared_mailer_tests'

class ActionMailer::TestCase
  include SharedMailerTests
end

注意,我們首先需要在test_helperrequire shared_mailer_tests

這些辦好之後,我們現在可以確信我們的郵件中包含我們期望的關鍵元素。假設我們想確保傳送給使用者的URL包含一些用於追蹤的特定UTM引數。 我們現在可以將自定義斷言與老朋友perform_enqueued_jobs聯合起來使用,就像這樣:

# 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

結論

Active Job的基礎上,使用ActionMailer讓從同步傳送郵件到通過佇列傳送郵件的轉化變得如此簡單,就如同從deliver_now轉化到deliver_later

同時,由於使用Active Job大大簡化了設定工作基礎環境的流程,你可以對自己所用的佇列系統知之甚少。希望這篇教程能讓你對此過程有更多瞭解。


本文系OneAPM工程師編譯整理。OneAPM是中國基礎軟體領域的新興領軍企業。專注於提供下一代應用效能管理軟體和服務,幫助企業使用者和開發者輕鬆實現:緩慢的程式程式碼和SQL語句的實時抓取。想閱讀更多技術文章,請訪問OneAPM官方技術部落格

相關文章