RSpec で ActiveModel::Errors のモックを作成する

ActiveRecord::RecordInvalid が発生するかどうかをテストするために ActiveModel::Errors のモックを作りたい。

実行環境

ruby 2.6.6
Rails 6.0.3.6

ActiveRecord::RecordInvalid が発生するかどうかのテスト

subjectが実行された際 ActiveRecord::RecordInvalid が発生するかどうかのテストコードを実装します。
なお、subjectは省略していますが、subjectで定義しているものには、user.save! の処理が含まれています。

# Userのインスタンスに対してsave!メソッド実行時にActiveRecord::RecordInvalidがraiseされるようにスタブ化
before { allow_any_instance_of(User).to receive(:save!).and_raise(ActiveRecord::RecordInvalid) }


it { expect { subject }.to raise_error(ActiveRecord::RecordInvalid) }

Model にerrors や class を定義するスタブを作成

つぎに user.save! に失敗した場合、以下のようにエラーメッセージをログに出力するコードが書かれているとします。

Rails.logger.error e.message

その場合に、subjectを実行し ActiveRecord::RecordInvalid が発生した時に、エラーログが出力されるかどうかのテストコードを実装します。

まずテストの前処理としてスタブの定義を行います。

let(:errored_model) do
  instance_double(
    User,
    errors: instance_double('errors', full_messages: ['nameを入力してください'], messages: { name: ['を入力してください'] }),
    class: class_double(User, i18n_scope: :activerecord),
  )
end
before do
  # Userのインスタンスに対してsave!メソッドが実行された時にActiveRecord::RecordInvalidがraiseされるようにスタブ化
  allow_any_instance_of(User).to receive(:save!).and_raise(ActiveRecord::RecordInvalid, errored_model)
  # Rails.loggerのオブジェクトに対してerrorメソッドを呼び出せるようにする
  allow(Rails.logger).to receive(:error)
end

and_raise について

rspec-mocks/lib/rspec/mocks/message_expectation.rb at main · rspec/rspec-mocks · GitHub
こちらにあるように and_raise 内で、raise メソッドが呼び出されているため、raise ActiveRecord::RecordInvalid, userとするように、
and_raise の引数に ActiveRecord::RecordInvalid, user を渡してあげるとよさそうです。

instance_double の errors や class について

User の InstanceDouble で errors を指定していないと以下のようなエラーが発生します。

RSpec::Mocks::MockExpectationError: #<InstanceDouble(User) (anonymous)> received unexpected message :errors with (no args)
from /app/.bundle/ruby/2.6.0/gems/rspec-support-3.10.2/lib/rspec/support.rb:102:in `block in <module:Support>'

ほかにも i18n_scope メソッドが定義されていないと以下のようなエラーが発生します。

NoMethodError: undefined method `i18n_scope' for RSpec::Mocks::InstanceVerifyingDouble:Class
from /app/.bundle/ruby/2.6.0/gems/activerecord-6.0.3.6/lib/active_record/validations.rb:22:in `initialize'

内部的に ActiveRecord において i18n_scope メソッドを呼び出しているようでした。
rails/activerecord/lib/active_record/translation.rb at main · rails/rails · GitHub

最終的なテストコード

let(:errored_model) do
  instance_double(
    User,
    errors: instance_double('errors', full_messages: ['nameを入力してください'], messages: { name: ['を入力してください'] }),
    class: class_double(User, i18n_scope: :activerecord),
  )
end
before do
  allow_any_instance_of(User).to receive(:save!).and_raise(ActiveRecord::RecordInvalid, errored_model)
  allow(Rails.logger).to receive(:error)
end

# 実際にRails.logger.errorが呼び出され、エラーメッセージが引数として渡されることをモックで確認する
it 'エラーログが出力されること' do
  subject
  expect(Rails.logger).to have_received(:error).with("バリデーションに失敗しました: nameを入力してください")
end

モック/スタブ実装のためのメソッド

上記で使用したメソッドを整理します。

そもそも RSpec におけるスタブとモックの違い

スタブは、他のメソッドが実行されないようにして欲しい値を返させるものです。

つまり、テストで確認したい内容のために何か都合のいい値を返すように設定したものをスタブと言います。

モックは、指定のメソッドが実行されたかどうかを、引数や頻度を指定してチェックするものです。

instance_double

特定のクラスに対して double(身代わり)を作成します。
指定したクラスで定義されているインスタンスメソッドのみが、スタブとして許可されます。
ほとんどdoubleと同様だが、allowなどで定義するメソッドが、引数で指定したクラス(今回の User)のインスタンスメソッドとして存在していないとテストが失敗します。

class_double

実際のクラスの情報と紐付いて実際のクラスに実装されていないメソッドのスタブ化はできないようになります。
ほとんどinstance_doubleと同様だが、こちらはクラスメソッドに対して適用されます。

allow_any_instance_of

生成されたインスタンスすべてに対して、スタブを設定できます。

参考にした記事

techracho.bpsinc.jp

zenn.dev