🔻

RSpecでメソッドの呼び出し回数に応じてエラーを発生させたい

2024/01/30に公開

検証環境:

  • 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_returnand_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