ApplicationMailerのdeliver_nowメソッドが返す例外をRSpec Mocksでテスト

はじめに

こんにちは! イタンジ株式会社で更新退去くんというプロダクトを開発している沈です。

新卒一年目で学ぶことが多く、フロントエンド(Next.js)とバックエンド(Rails)両方の開発に携わり、システムがどのように動作するか、総合的に理解しています。直近は、Amazon SES(aws-sdk-ruby)が返すエラーに対して、エラーハンドリングを行いました。今回は、このエラーハンドリングを行った際のことを、ブログで発信していきたいと思います。

背景

メールの送信に失敗した場合に、エンドユーザーに原因が記載されたエラーメッセージを表示するため

仕様

SESから返されるエラーをApplicationMailerで捕捉し、それをユーザーフレンドリーなエラーメッセージに変換して、raiseし直します(raiseし直したエラーは、後にcatchして、APIレスポンスとして返します)。そしてフロントエンドで受け取り、画面に表示する。

課題

ApplicationMailerで各Mailerのエラーハンドリングを共通化し、エラーハンドリングの結果をRSpecでテストしたい

ActionMailer::MessageDeliveryのコードの定義

class HogeController < ApplicationController
  def fuga
    ...
    Notifier.welcome(User.first).deliver_now
  end
end

https://github.com/rails/rails/blob/6b93fff8af32ef5e91f4ec3cfffb081d0553faf0/actionmailer/lib/action_mailer/message_delivery.rb#L19

module ActionMailer
  class MessageDelivery < Delegator
    ...
    # Method calls are delegated to the Mail::Message that's ready to deliver.
    def __getobj__ # :nodoc:
      @mail_message ||= processed_mailer.message
    end

 # Returns the resulting Mail::Message
    def message
      __getobj__
    end
    ...
    # Delivers an email:
    #
    #   Notifier.welcome(User.first).deliver_now
    #
    def deliver_now
      processed_mailer.handle_exceptions do
        processed_mailer.run_callbacks(:deliver) do
          message.deliver
        end
      end
    end
  end
end

コントローラーでメールを送るコードは、 Notifier.welcome(User.first).deliver_nowです。welcome(User.first)メソッドはActionMailer::MessageDeliveryオブジェクトを1つ返して、コードは最終的にmessage.deliverに辿り着きます。

https://github.com/rails/rails/blob/6b93fff8af32ef5e91f4ec3cfffb081d0553faf0/actionmailer/lib/action_mailer/message_delivery.rb#L8-L13

    # The +ActionMailer::MessageDelivery+ class is used by
    # ActionMailer::Base when creating a new mailer.
    # <tt>MessageDelivery</tt> is a wrapper (+Delegator+ subclass) around a lazy
    # created +Mail::Message+. You can get direct access to the
    # +Mail::Message+, deliver the email or schedule the email to be sent
    # through Active Job.

コメントの通り、Delegatorを継承することで、MessageDeliveryはMail::Messageのメソッドをそのまま呼び出せるようになります。具体的にいうと、__getobj__メソッドは processed_mailer.message を呼び出して Mail::Message を取得し、それを @mail_message インスタンス変数にキャッシュします。ActionMailer::MessageDeliveryオブジェクトは、Mail::Messageをラップしていて、messageのclassはMail::Messageです。

RSpecでAmazon SESのエラーをMockを使用してテスト

ApplicationMailerのコード

class ApplicationMailer < ActionMailer::Base
  rescue_from Aws::SES::Errors::InvalidParameterValue do |e|
    ...
    raise MailerError.new(
      message: ...
    )
  end
end

rescue_fromは、特定の種類または複数の種類の例外を1つのコントローラ全体およびそのサブクラスで扱える手法です。 MailerのエラーハンドリングはApplicationMailerで行い、rescue_fromを使いAws::SES::Errors::InvalidParameterValueが発生した場合それを補足して、次にMailerErrorを返します(この時に、エラーメッセージをユーザーフレンドリーなものに変換します)。ここで、Aws::SES:Errors::InvalidParameterValueが起きた際の動作をRSpecでテストするために、Mockを使用します。

allow_any_instance_of

Mockを使用するには、いくつかの手法があります。その中でinstance_doubleメソッドは対象クラスのインスタンスをモックするために使用されます。一方、allow_any_instance_ofメソッドを使うと、対象クラスの全インスタンスに対して目的のメソッドをモック化できます。今回は、Mockしたいものが外部ライブラリ(Mail::Message)のインスタンスであり、instance_doubleを使ってモックを用意するのが難しいため、RSpec公式が非推奨と述べてますが、allow_any_instance_ofを使ってモックします。

def mock_raise_deliver_now_ses_error
  request_context = instance_double(Seahorse::Client::RequestContext)
  allow_any_instance_of(Mail::Message).to receive(:deliver).and_raise(
    Aws::SES::Errors::InvalidParameterValue.new(request_context, 'error message')
  )
end

deliver_nowの中身のメソッドをモックしたいので、Mail::Messageクラスの全インスタンス(message)に対して、deliverメソッドが呼ばれたときにAws::SES::Errors::InvalidParameterValueを返すようにします。妥当な引数を渡したいので、rails cshow-source ${クラス名} (show-source Aws::SES::Errors::InvalidParameterValue)をすると、定義箇所のコードを見ることができます

今回であれば 以下のようになっていました。

# @param [Seahorse::Client::RequestContext] context
# @param [String] message
# @param [Aws::Structure] data
def initialize(context, message, data = Aws::EmptyStructure.new)
  @code = self.class.code
  @context = context
  @data = data
  @message = message && !message.empty? ? message : self.class
  super(@message)
end

まとめ

今回はallow_any_instance_ofメソッドを使って、ApplicationMailerで共通化した例外処理のテストを行いました。 振り返るとはっきりしたが、実際の仕事には横からタスクやミーティングが入る状態で、膨大なシステムの動作とコードを確認することは、まるで巨大迷宮の中で試行錯誤しながら出口を探している感じです。リーダーと先輩のおかげで第一弾としてのエラー処理を遂行することができました。大きく進歩する実感があります。

参考資料

https://github.com/rails/rails/blob/6b93fff8af32ef5e91f4ec3cfffb081d0553faf0/actionmailer/lib/action_mailer/message_delivery.rb

https://qiita.com/jnchito/items/640f17e124ab263a54dd

https://railsguides.jp/action_mailer_basics.html