- オブジェクトの同一性にこだわらなければ、スタブのメソッドの引数には
be_an_instance_of
を使ったほうがいい - 特にgemが返すオブジェクトとの同一性比較は無理にやらないほうがいい
実例
Herという、RESTful APIで得たリソースをRubyオブジェクトにマッピングするgemがあります。
これを使って外部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