- ruby 3.3.8 (Debian の apt パッケージ) / ruby 4.0.7 (ソースからビルド)
- rspec-mocks 3.13.8 (2026-10-09 時点の最新安定版)
- rspec-core 3.13.6
- 比較用: rspec-mocks 3.12.6 (元記事の版) / rspec 4.0.0.beta1 一式 (core・expectations・mocks・support, ruby 4.0.7 の別環境)
2024年1月に「RSpecでメソッドの呼び出し回数に応じてエラーを発生させたい」を書いた。元記事の検証環境は ruby 3.1系 / rspec-mocks 3.12.6
当時はまだ AI が今ほど身近ではなく、詰まったら誰かの記事を読んでいた。そのお返しとして寄与したかったのが1つ。もう1つは、ライブラリはちゃんと中身を見てから使うという正攻法を共有したかった
その元記事のサンプルを最新の rspec-mocks で動かし直し、書いていなかった挙動を足しておきたい
- 元記事のサンプルは 3.13.8 / 4.0.0.beta1 でもそのまま動く
- 引数のあるメソッドには
->(*) {}かproc {}を渡せば arity を気にしなくていい -
expect(...).to receive+and_invoke(a, b, c)は暗黙に「3回呼ばれる」期待になる -
allow(...).thriceは上限としてだけ働く - キーワード引数は 3.13.2 以降で対応
allow(human).to receive(:say).with('dummy').and_invoke(
->(*) { 'hello' }, # 1回目
->(*) { raise CustomError }, # 2回目
proc { 'bye' } # 3回目以降
)
元記事のサンプルを再実行
Note: 以下、この定義を使い回しますー
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') # 1回目
expect { human.say }.to raise_error(error) # 2回目
expect(human.say).to eq('bye') # 3回目
# Note: 以降、何度`human.say`を実行しても'bye'を返す
カウンター付きブロック版、thrice の4回目、with(message) + 引数付き lambda も元記事どおり。4回目のエラーメッセージも同じ
(InstanceDouble(Human) (anonymous)).say(no args)
expected: 3 times with any arguments
received: 4 times
ruby 3.3.8 / 4.0.7、rspec-mocks 3.12.6 / 3.13.8 / 4.0.0.beta1 のどの組み合わせでも同じ
中身を見る
rspec-mocks のリポジトリは旧リポジトリ扱いになっていて、開発は rspec/rspec のモノレポに移っていた
and_invoke 本体。callable の数を期待回数に反映している
def and_invoke(first_proc, *procs, &_block)
# ...
procs.unshift(first_proc)
if procs.any? { |p| !p.respond_to?(:call) }
raise ArgumentError, "Arguments to `and_invoke` must be callable."
end
# Note: 渡した数と既存の期待回数の大きいほう。and_return にも同じ行がある
@expected_received_count = [@expected_received_count, procs.size].max unless ignoring_args? || (@expected_received_count == 0 && @at_least)
self.terminal_implementation_action = AndInvokeImplementation.new(procs)
nil
end
呼び出し側。最後の1つになるまで shift、以降は最後のものを使い回す。引数はそのまま callable に渡す
def call(*args, &block)
proc = if @procs_to_invoke.size > 1
@procs_to_invoke.shift
else
@procs_to_invoke.first
end
proc.call(*args, &block)
end
Changelog より
-
and_invokeは 3.11.0 (2022-02-09) で追加 - 3.13.2 で「
and_invokeに渡す callable でキーワード引数をサポート」
手元でも 3.10.3 には and_invoke が無く、3.11.0 にはあることを確認
arity の問題
引数をそのまま渡すので、引数なしの lambda だと落ちる
human = Human.new # Note: def say(message) を持つ実クラス
allow(human).to receive(:say).with('dummy').and_invoke(-> { 'hello' })
human.say('dummy')
# => ArgumentError: wrong number of arguments (given 1, expected 0)
->(*) か proc なら通る
allow(human).to receive(:say).with('dummy').and_invoke(
->(*) { 'hello' }, # 何個でも受け取って捨てる
proc { 'bye' } # proc は arity に寛容
)
expect(human.say('dummy')).to eq('hello')
expect(human.say('dummy')).to eq('bye')
引数を使いたいなら proc { |msg| "#{msg}!" } でも受け取れる
キーワード引数
def greet(name:) に対して
allow(obj).to receive(:greet).and_invoke(->(name:) { "got #{name}" })
obj.greet(name: 'kyto')
# 3.12.6: ArgumentError: wrong number of arguments (given 1, expected 0; required keyword: name)
# 3.13.8: "got kyto"
->(**kw) {} も proc { |name:| } も 3.12.6 では ArgumentError、3.13.8 では通る
and_return との比較
and_return も複数値を渡せる。尽きたら最後の値を返し続けるのは同じ
allow(human).to receive(:say).and_return('a', 'b', 'c')
5.times.map { human.say } # => ["a", "b", "c", "c", "c"]
ただし例外オブジェクトを混ぜても raise はされない
allow(human).to receive(:say).and_return('a', error, 'c')
human.say # => "a"
human.say # => error (raise ではなく return)
human.say # => "c"
and_raise と and_return を順に allow しても後勝ちで上書きされるだけ
allow(http).to receive(:get).and_raise(Timeout::Error)
allow(http).to receive(:get).and_return({ 'ok' => true })
# Note: 1回目から { 'ok' => true } が返る
使い分け
- 値を順に返すだけ:
and_return(a, b, c) - 毎回例外:
and_raise - 途中に例外を混ぜる、引数を使って返す:
and_invoke - 呼び出し回数以外の条件で分岐: ブロック
現実寄りの例: リトライのテスト
タイムアウトしたらリトライする API クライアント
require 'timeout'
class WeatherClient
class Error < StandardError; end
def initialize(http, max_attempts: 3, sleeper: ->(_sec) {})
@http = http
@max_attempts = max_attempts
@sleeper = sleeper # Note: テストでは待たない
end
def fetch(city)
attempts = 0
begin
attempts += 1
@http.get("/weather/#{city}")
rescue Timeout::Error
raise Error, "gave up after #{attempts} attempts" if attempts >= @max_attempts
@sleeper.call(2**attempts)
retry
end
end
end
「2回タイムアウトして3回目で成功」がそのまま書ける
RSpec.describe WeatherClient do
let(:http) { double('http') }
let(:client) { described_class.new(http, max_attempts: 3) }
it '2回タイムアウトしても3回目で成功すれば結果を返す' do
allow(http).to receive(:get).with('/weather/tokyo').and_invoke(
->(*) { raise Timeout::Error }, # 1回目
->(*) { raise Timeout::Error }, # 2回目
->(*) { { 'city' => 'tokyo', 'temp' => 21 } } # 3回目
)
expect(client.fetch('tokyo')).to eq('city' => 'tokyo', 'temp' => 21)
expect(http).to have_received(:get).exactly(3).times
end
it '3回ともタイムアウトしたら諦める' do
# Note: 1つだけ渡すと毎回それが呼ばれる
allow(http).to receive(:get).and_invoke(->(*) { raise Timeout::Error })
expect { client.fetch('tokyo') }
.to raise_error(WeatherClient::Error, 'gave up after 3 attempts')
expect(http).to have_received(:get).exactly(3).times
end
end
備考1: allow + thrice は上限だけ
4回目はその場でエラーになるが、2回しか呼ばなくても通る
allow(human).to receive(:say).thrice.and_invoke(-> { 1 }, -> { 2 }, -> { 3 })
human.say # 1
human.say # 2
# Note: ここで終わってもテストは通る
ちょうど3回を確認したいなら have_received を使う
expect(human).to have_received(:say).exactly(3).times
備考2: expect + and_invoke は渡した数が期待回数になる
expect(human).to receive(:say).and_invoke(-> { 1 }, -> { 2 }, -> { 3 })
human.say
# => expected: 3 times with any arguments
# received: 1 time with any arguments
and_return(1, 2, 3) でも同じ。「最後の値を使い回すから多めに書いておく」とすると expect のときだけ落ちる
備考3: 4回目のエラーは StandardError ではない
RSpec::Mocks::MockExpectationError は Exception を継承していて StandardError ではない。テスト対象側の rescue => e では握りつぶされない
RSpec::Mocks::MockExpectationError.ancestors.include?(StandardError) # => false
Note: テスト側で明示的に rescue すると、その example は通ってしまう
備考4: and_invoke に渡せないもの
allow(human).to receive(:say).and_invoke('hello')
# => ArgumentError: Arguments to `and_invoke` must be callable.
allow(human).to receive(:say).and_invoke(-> {}) { 'x' }
# => ArgumentError: Implementation blocks aren't supported with `and_invoke`
備考5: 元記事の参考リンク
元記事の公式リンクは旧リポジトリ rspec/rspec-mocks のコミットを指している。今は rspec/rspec のモノレポを見るのがよさそう
AI に聞けば and_invoke の名前は出てくるかもしれないが、引数の渡り方や期待回数はソースと Changelog を読んで確定した
公式:
- https://github.com/rspec/rspec/blob/main/rspec-mocks/lib/rspec/mocks/message_expectation.rb
- https://github.com/rspec/rspec/blob/main/rspec-mocks/Changelog.md
- https://github.com/rspec/rspec-mocks
その他:
Discussion
