Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

RSpec: スタブのメソッドの引数にリテラル以外を渡すときは素直に be_an_instance_of を使ったほうがいい

  • オブジェクトの同一性にこだわらなければ、スタブのメソッドの引数には be_an_instance_of を使ったほうがいい
  • 特にgemが返すオブジェクトとの同一性比較は無理にやらないほうがいい

実例

Herという、RESTful APIで得たリソースをRubyオブジェクトにマッピングするgemがあります。

remi/her - GitHub

これを使って外部APIを叩きます。
その結果としてHerがパースできないbodyが返ってきた場合、特定のSlackチャンネルに通知するという rescue_from の処理を書きます。

class ApplicationController < ActionController::Base
  rescue_from Her::Errors::ParseError do |e|
    system_name = '外部システムAPIエラー'
    channel_type = 'hoge'
    notify_exception(e, data: { system_name: }, channel_type:)
    # ...
  end

  # 他にもエラー種類に応じて別チャンネルに notify_exception している
  # ...

  def notify_exception(exception, data: default_notify_data, channel_type: 'common')
    # ...
  end
end

ここで、 Her::Errors::ParseError が返ってきたときに、どのチャンネル向けにどのデータが送られることになるか確認するため、正しい引数で notify_exception が行われているかをテストします。
引数にはエラーオブジェクトを含むため、愚直に引数を比較すると with で期待するものと実際に渡されるものを同一のオブジェクトにしないといけません。

context '外部APIからパースできないbodyが返ってきた場合' do
  let(:uri) { ... }
  let(:body) { (パースできない文字列) }

  before do
    WebMock.stub_request(:get, Addressable::Template.new(uri)).to_return(status: 500, body:)
  end

  it 'ExceptionがSlack通知される' do
    message = (予め調査した Her::Errors::ParseError の初期化時に渡されるであろう引数)
    error = Her::Errors::ParseError.new(message)
    # これで rescue_from に回収されるエラーオブジェクトが error になるといいなあ……
    allow(Her::Errors::ParseError).to receive(:new)
                                        .with(message)
                                        .and_return(error)
    expect_any_instance_of(ApplicationController).to receive(:notify_exception).with(error, data: { system_name: '外部システムAPIエラー' }, channel_type: 'hoge')
    get users_url # ここで外部APIを叩いている
  end
end

このテストは通りません。

Failures:

  1) ErrorHandling 外部APIからパースできないbodyが返ってきた場合 ExceptionがSlack通知される
     Failure/Error: notify_exception(e, data: { system_name: }, channel_type:)

       #<UsersController:0x00000000014eb0> received :notify_exception with unexpected arguments
         expected: (#<Her::Errors::ParseError: Response from the API must behave like a Hash or an Array (last JSON respo...")>, {:channel_type=>"hoge", :data=>{:system_name=>"外部システムAPIエラー"}}) (options hash)
              got: (#<Her::Errors::ParseError: Response from the API must behave like a Hash or an Array (last JSON respo...")>, {:channel_type=>"hoge", :data=>{:system_name=>"外部システムAPIエラー"}}) (keyword arguments)
       Diff:

このメッセージを見て何が原因だと思うでしょうか?
RSpecが自身に与えられたキーワード引数をハッシュと誤認しているように見えませんか?自分はそう思いました。
でも実際には違います。そんなわけない。

こういうときはちゃんとgemのコードを見て、どうやってエラーを投げているのか確認しましょう。(自戒)

# https://github.com/remi/her/blob/1cd2105b5eaca038a7e6365137d9f62edcda08fd/lib/her/middleware/parse_json.rb#L12-L13
rescue MultiJson::LoadError
  raise Her::Errors::ParseError, message

Her::Errors::ParseError.new ではなくて Her::Errors::ParseError をraiseしている!これでは Her::Errors::ParseError.new をスタブしても無意味です。
つまり、 ApplicationController#notify_exception が期待する第一引数と、テスト中に実際に渡される第一引数のオブジェクトは別物です。これがFailureの原因です。

なんとしてでも同一のエラーオブジェクトを渡したいのであれば Kernel#raise をスタブすることになると思うのですが、以下の理由から採用できません。

  • Kernelのメソッドをスタブするのがとてもやりたくない
  • gemの内部実装に依存してテストが落ちる可能性がある。具体的には、今後Herが raise Her::Errors::ParseError.new するようになった場合にテストが落ちる
  • よく考えれば、ここで本当にチェックしたいのは第二引数以降が正しいかであり、第一引数が期待と同一のオブジェクトだと確認することにほとんど意味がない

ということで、 be_an_instance_of を使ったほうがいいです。

Method: RSpec::Matchers#be_an_instance_of — Documentation for rspec-expectations (3.12.2)

context '外部APIからパースできないbodyが返ってきた場合' do
  # ...
  it 'ExceptionがSlack通知される' do
    expect_any_instance_of(ApplicationController).to receive(:notify_exception).with(be_an_instance_of(Her::Errors::ParseError), data: { system_name: '外部システムAPIエラー' }, channel_type: 'hoge')
    get users_url
  end
end

関連記事

ソフトウェアテストでstubを使うコストを考える(翻訳)

[Rails] RSpecのモックとスタブの使い方


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。