🔻
RSpecでメソッドの呼び出し回数に応じてエラーを発生させたい
検証環境:
- ruby 3.1系
- rails 7.0.7
- rspec-mocks 3.12.6
背景:
RSpecで呼び出しのたびにメソッドの返り値を変更、もしくはエラーをraiseするようにしたかった
結論:
Method: RSpec::Mocks::MessageExpectation#and_invoke
class CustomError < RuntimeError; end
error = CustomError.new('something wrong!')
human = instance_double('Human')
allow(human).to receive(:say).and_invoke(
-> { 'hello' },
-> { raise error },
-> { 'bye' }
)
expect(human.say).to eq('hello')
expect { human.say }.to raise_error(error)
expect(human.say).to eq('bye')
# Note: 以降、何度`human.say`を実行しても'bye'を返す
expect(human.say).to eq('bye')
expect(human.say).to eq('bye')
旅路:
Note: 以下、この定義を使い回しますー
class CustomError < RuntimeError; end
error = CustomError.new('something wrong!')
human = instance_double('Human')
まず基本的なところ
# `human`が`#say`メソッドを受け取った時に'こんにちは'と返す
allow(human).to receive(:say).and_return('こんにちは')
expect(human.say).to eq('こんにちは')
# `human`が`#say`メソッドを受け取った時にエラーを返す
allow(human).to receive(:say).and_raise(error)
expect { human.say }.to raise_error(error)
and_raise
を使わずに例外を発生させる手段については、以下のようなものがある
# `human`が`#say`メソッドを受け取った時にエラーを返す
allow(human).to receive(:say) { raise error }
expect { human.say }.to raise_error(error)
これを応用して実行回数に応じてメソッドの返り値を変更、もしくはエラーをraiseするようにできる
# `human`が一度目に`#say`メソッドを受け取った時は'こんにちは'と返す
# `human`が二度目に`#say`メソッドを受け取った時はエラーを返す
# `human`が三度目に`#say`メソッドを受け取った時は'さようなら'と返す
@executions = 0
allow(human).to receive(:say) do
@executions += 1
if @executions == 1
'こんにちは'
elsif @executions == 2
raise error
else
'さようなら'
end
end
expect(human.say).to eq('こんにちは')
expect { human.say }.to raise_error(error)
expect(human.say).to eq('さようなら')
# 以降、何度`human.say`を呼び出しても'さようなら'を返す
expect(human.say).to eq('さようなら')
expect(human.say).to eq('さようなら')
この記述はかなり可読性が下がると思うので何か方法はないかと模索...
そもそもand_return
やand_raise
を駆使する以外にも適当なメソッドはないのかと公式リポジトリを訪問したところand_invoke
を発見
# メソッドのdocより参照(原文ママ):
allow(api).to receive(:get_foo).and_invoke(-> { raise ApiTimeout }, -> { raise ApiTimeout }, -> { :a_foo })
api.get_foo # => raises ApiTimeout
api.get_foo # => rasies ApiTimeout
api.get_foo # => :a_foo
api.get_foo # => :a_foo
api.get_foo # => :a_foo
# etc
ということで結論に繋がる
※ 再掲
allow(human).to receive(:say).and_invoke(
-> { 'こんにちは' },
-> { raise error },
-> { 'さようなら' }
)
expect(human.say).to eq('こんにちは')
expect { human.say }.to raise_error(error)
expect(human.say).to eq('さようなら')
# Note: 以降、何度`human.say`を実行しても'さようなら'を返す
expect(human.say).to eq('さようなら')
expect(human.say).to eq('さようなら')
備考1: コール回数を制限したい場合
コール回数を指定して、それ以上呼ばれるとテストを落とすこともできる
allow(human).to receive(:say).thrice.and_invoke(
-> { 'こんにちは' },
-> { raise error },
-> { 'さようなら' }
)
expect(human.say).to eq('こんにちは') # 1回目
expect { human.say }.to raise_error(error) # 2回目
expect(human.say).to eq('さようなら') # 3回目
expect(human.say).to eq('さようなら') # 4回目
# => RSpec::Mocks::MockExpectationError: (InstanceDouble(Human) (anonymous)).say(no args)
# expected: 3 times with any arguments
# received: 4 times
# from /usr/local/bundle/gems/rspec-support-3.12.1/lib/rspec/support.rb:108:in `block in <module:Support>'
備考2: 当該メソッドに引数がある場合
当該メソッドが引数を持つ場合はand_invoke
に渡すLambdaに適当な引数を設定する必要がある
class CustomError < RuntimeError; end
class Human
def say(message)
message
end
end
human = Human.new
message = 'dummy'
allow(human).to receive(:say).with(message).and_invoke(
-> (_) { 'hello' },
-> (_) { raise CustomError, 'something wrong!' },
-> (msg) { msg }
)
expect(human.say(message)).to eq('hello')
expect { human.say(message) }.to raise_error(CustomError, 'something wrong!')
expect(human.say(message)).to eq(message)
その他
invoke
はプログラミング的な文脈で実行する
という意味でcall
とほぼ同義っぽい
what-is-the-difference-between-call-and-invoke
Q. As the title says. What’s the difference between invoking a function and calling a function? Is it the same thing just named differently?
A. Yeah, it’s the same thing.
Discussion