暇 ひまネタNEWS(^o^) | スキマ時間にちょっとしたネタを提供スキマ時間に、話題のまとめを一気読み 全サイト自動巡回中
HIMA NETA ORIGINAL READER
本文だけモード Zenn IT・ガジェット 約14分で読めます

昔書いたRSpecのand_invokeの記事を最新のrspec-mocksで検証し直した

読みやすさ この端末に設定を保存します
書体
文字
背景

広告・ランキング・関連記事を除き、配信元の安全な文字装飾だけを残しています。

  • 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 を読んで確定した

公式:

その他:

Discussion

元のページを確認する

必要な場合のみ、広告などを含む配信元ページをフレームで表示できます。