freeeの開発情報ポータルサイト

RSpec の allow のコードを読んでみよう

みなさんこんにちは。freee Developers Hub 編集部の bucyou (ぶちょー) と申します。今年も、freee Advent Calendar 2025 の時期がやってまいりました。この企画は、12月1日から12月25日まで、1日1記事ずつ公開していくブログ企画になります。

adventar.org

今年で11年目です。お気軽な記事から、今のホットなトピック、技術研究など様々なトピックをお送りしていきたいと思っております。この記事は、freee Advent Calendar 2025 における1日目の記事になります。

フリー株式会社として開催している Advent Calendar については、他にも QA, DEI の企画もございます。


さて、私は普段、関西支社の所属となっておりまして、普段はそちらを開放して開催されている kyobashi.rb に参加することが多いです。ありがたいことに程よい大きさのコミュニティで、刺激的な場になっています。主催されているのは、freee からは hachi さんと、Rubyコミッターの ydah さんです。内容はかなり自由であり、電子工作の話から、パーサーの話、普段の業務で発見した出来事など多種多様です。Ruby が全く出てこない回もあります。なぜか、ローレイヤーな話題で盛り上がることが多いのです。

kyobashirb.connpass.com

私も負けじと発表の時間をいただくことがあります(アットホームな規模感なので、手を挙げれば登壇しやすい雰囲気です)。今日は、そこでやろうと思ってた内容をここに残しておこうと思います。


背景

去年と今年の大きな変化は何かといえば、周りがみんなLLMによる支援を受けながらコーディングをすることが当たり前になったことでしょう。多少プロンプトや、ツールをうまく整える必要はあるものの、それさえ乗り越えればコーディング作業をいい感じに進めてくれるようになりました。

特に、RSpec によるテスト記述については、粒度さえコントロールできれば今までより格段に早くコーディングが行えるようになったと思います。

表層やビジネスとしては喜ばしいところで、自分が書くよりも格段にマシな表現を整えてくれるところもあるのでありがたい限りなのです。一方で、深層としては、なんとなく書いている「あれ」とか「これ」が結局何やってるんだろうという疑問を、エンジニアとしてちゃんと解決しておきたいという気持ちが出てきてしまいました。

そこで今日は、Claude Code の手も借りながら、RSpec の Mock に関するコードが、結局何をやっているのかを知っていこうという活動をしようと思います。

知っておこう と書かれた画像

ℹ️ この記事の内容は 2025/12/1 時点に main にある RSpec のソースコードを元に記載しています。

おさらい: allow ってなんだっけ?

テストを書くときに、対象のモジュールやクラスを動かすために必要なオブジェクトを、全て用意するのが難しい場合があります。そこで、ニセモノのオブジェクトをツールとして用意してやると、うまく扱えるようになります。

例えば、外部のオブジェクトを参照するための SomethingClient が内部で gRPC 通信を行っているとすると、テストで毎回ホンモノの gRPC 通信を行ってしまうと非常に都合が悪いことになります。仮にモックのない世界だと、サーバを用意してやる必要はありますし、テストのたびにそのサーバが呼ばれることになるので安定しないテストになります。特に単体テストは通常、いつ、誰が、何度実行しても同じ結果になるべきという「決定性」が求められるため、外部通信やランダム性を持つ処理については対処が必要です。

そこで、RSpec の場合は、以下のように記載することで SomethingClient の挙動を変えることができます。

before do
  my_something_client = instance_double(SomethingClient)
  allow(SomethingClient).to receive(:new).and_return(my_something_client)

  fetched_object = double('Something')
  allow(my_something_client).to receive(:fetch).and_return(fetched_object)
end

it 'MyProcedureの実行に成功する' do
  # あまり良い設計ではないが、MyProcedure の new で SomethingClient.new がされているものとする
  procedure = MyProcedure.new

  # 例えば、fetch でエラーが出なければ true を返すといった挙動
  # before で fetch に対する振る舞いを記載しているので、問題なく動く
  expect(procedure.exec).to eq(true)
end

ドキュメント: https://rspec.info/features/3-12/rspec-mocks/

これができるのは、ぱっと見は魔法のようですが、Ruby は非常に柔軟な言語であり、クラスやモジュールの振る舞いを後から書き換えることは容易に行うことができます。例えば、RSpec を使わなくても、以下のようにモックを作ることができます。

class SomethingClient
  def initialize
    puts "面倒な処理"
  end

  def fetch_user(id)
    puts ">> 本物の通信が発生: ID #{id}"
    "Real User Data for ID: #{id}"
    {name: 'Yamada Taro'}
  end
end

def SomethingClient.new
  # ここで返すオブジェクトが「モック」になります。
  # Object.new で空のオブジェクトを作り、そこにメソッドを動的に生やします。
  mock_object = Object.new

  # このオブジェクトだけに fetch_user メソッドを定義します(特異メソッド)。
  # これが RSpec の `allow(client).to receive(:fetch_user)` に相当します。
  def mock_object.fetch_user(id)
    puts ">> [MOCK] 通信をスキップしました"
    {name: "Mock Dayo"}
  end

  # 作成したモックを返す
  mock_object
end

# 元々のほうではなく、上書きされたほうが使われる
client = SomethingClient.new
result = client.fetch_user(1)
puts result

​ これを実行すると、以下のような出力になります。

>> [MOCK] 通信をスキップしました
{name: "Mock Dayo"}

要するにモンキーパッチと呼ばれるものです。では RSpec や、Mock ライブラリを使わず、モンキーパッチを単純に使うだけで良いのではないか? となるかもしれませんが、それはおすすめできないでしょう。以下のような問題が発生します。

  • 上書きした後に戻す手段が提供されない。手動でやると書き換えたらそのままになる。
  • 実際のクラスに存在しないメソッドを生やすことができてしまう。テストと実際の乖離。
  • 可読性の観点。ぱっと見何をやっているかわからない。

ライブラリが持つ機能を利用することで、これらの問題を解消することができます。

ℹ️ モックをするときに、昔はモック対象に対して直接 .stub を行う記法が存在していたようです。こちらの方法は、モンキーパッチを行うことになるため設計的には適切ではありません。

RSpec 3 ではこの記法が非推奨 (デフォルトで無効) となり、 allow によるモックが標準的になりました。この中で、技術用語の「スタブ」を使うより、自然言語である allow を使うという提案がなされました。

ただし、この記法を持っているフレームワークはあまり存在せず、少し戸惑うかもしれません。私は、Ruby での開発を始めた当初、かなり戸惑いました。

allow のコードを読もう

RSpec のコードは、モノレポ構成となっており、 https://github.com/rspec/rspec の中に rspec-mocks が含まれています。こちらのコードをクローンして読んでみましょう。

⚠️ 去年までは、https://github.com/rspec/rspec-mocks による管理も行われていましたが、モノレポ構成による管理に統合されたため、こちらはアーカイブされています。

allow のコードに辿り着くために、 rspec コマンドの実行から順を追って読んでいきましょう。

設定フェーズ

rspec コマンドが実行されると、rspec-core/exe/rspec が呼ばれます。

#!/usr/bin/env ruby

require 'rspec/core'
RSpec::Core::Runner.invoke

rspec/rspec-core/exe/rspec at 0c37a88d4ff511debd563d68e10c1c7672318c3c · rspec/rspec · GitHub

実行されると、 rspec-core/lib/rspec/core/runner が初期化されますが、 Runner の中で RSpec.configuration が呼ばれます。

def initialize(options, configuration=RSpec.configuration, world=RSpec.world)
  @options       = options
  @configuration = configuration
  @world         = world
end

rspec/rspec-core/lib/rspec/core/runner.rb at 0c37a88d4ff511debd563d68e10c1c7672318c3c · rspec/rspec · GitHub

その中で、 mock_with によって、モック用のフレームワークが読み込まれるようになっています。

MOCKING_ADAPTERS = {
  :rspec    => :RSpec,
  :flexmock => :Flexmock,
  :rr       => :RR,
  :mocha    => :Mocha,
  :nothing  => :Null
}

# ... 省略 ...

def mock_with(framework)
  framework_module =
    if framework.is_a?(Module)
      framework
    else
      const_name = MOCKING_ADAPTERS.fetch(framework) do
        raise ArgumentError,
              "Unknown mocking framework: #{framework.inspect}. " \
              "Pass a module or one of #{MOCKING_ADAPTERS.keys.inspect}"
      end

      RSpec::Support.require_rspec_core "mocking_adapters/#{const_name.to_s.downcase}"
      RSpec::Core::MockingAdapters.const_get(const_name)
    end

  new_name, old_name = [framework_module, @mock_framework].map do |mod|
    mod.respond_to?(:framework_name) ? mod.framework_name : :unnamed
  end
end

rspec/rspec-core/lib/rspec/core/configuration.rb at 0c37a88d4ff511debd563d68e10c1c7672318c3c · rspec/rspec · GitHub

ここでわかることは、モックに利用するライブラリは rspec だけでなく、 mocha, flexmock など複数のものに対応しているということです。お好みのライブラリがあれば、別のものに切り替えることができるようになっているようです。特に指定がない場合は rspec-mocks が読み込まれるようになっています。

https://rspec.info/features/3-12/rspec-core/mock-framework-integration/use-mocha/

ドキュメントにもしっかり書いてありますね。これは知らなかったです。

ExampleGroup での利用

ExampleGroup (describe や、 context によって作られるインスタンス) が作成されると、 it などお馴染みの機能をセットアップしますが、この中で Configuration の、 configure_mock_framework を呼びます。

def self.ensure_example_groups_are_configured
  unless defined?(@@example_groups_configured)
    RSpec.configuration.configure_mock_framework
    RSpec.configuration.configure_expectation_framework
    # rubocop:disable Style/ClassVars
    @@example_groups_configured = true
    # rubocop:enable Style/ClassVars
  end
end

rspec/rspec-core/lib/rspec/core/example_group.rb at 0c37a88d4ff511debd563d68e10c1c7672318c3c · rspec/rspec · GitHub

ここで、 ExampleGroup に対して、モックが include されるという動きになります。この処理は、クラス変数により1度だけ実行されるように設定されています。

def configure_mock_framework
  RSpec::Core::ExampleGroup.include(mock_framework)
end

rspec/rspec-core/lib/rspec/core/configuration.rb at 0c37a88d4ff511debd563d68e10c1c7672318c3c · rspec/rspec · GitHub

モックライブラリを rspec にしている場合、RSpec::Core::MockingAdapters::RSpec が呼び出されます。

require 'rspec/mocks'

module RSpec
  module Core
    module MockingAdapters
      # @private
      module RSpec
        include ::RSpec::Mocks::ExampleMethods

        def self.framework_name
          :rspec
        end

        def self.configuration
          ::RSpec::Mocks.configuration
        end

        def setup_mocks_for_rspec
          ::RSpec::Mocks.setup
        end

        def verify_mocks_for_rspec
          ::RSpec::Mocks.verify
        end

        def teardown_mocks_for_rspec
          ::RSpec::Mocks.teardown
        end
      end
    end
  end
end

rspec/rspec-core/lib/rspec/core/mocking_adapters/rspec.rb at 0c37a88d4ff511debd563d68e10c1c7672318c3c · rspec/rspec · GitHub

Example (it や、 specify などによって作られるインスタンス) では、 before の段階で example_groupsetup_mocks_for_rspec を行い、 after の段階で、 teardown_mocks_for_rspec が呼ばれるのが読めます。このタイミングで、 verify_mocks が行われ、必要に応じてモックの検証が行われます。(今回は、verify_mocks については深く扱いません。)

def run_before_example
  @example_group_instance.setup_mocks_for_rspec
  hooks.run(:before, :example, self)
end
# ... (省略) ...
def run_after_example
  assign_generated_description if defined?(::RSpec::Matchers)
  hooks.run(:after, :example, self)
  verify_mocks
ensure
  @example_group_instance.teardown_mocks_for_rspec
end

rspec/rspec-core/lib/rspec/core/example.rb at 0c37a88d4ff511debd563d68e10c1c7672318c3c · rspec/rspec · GitHub

RSpec::Core::MockingAdapters::RSpec は、 RSpec::Mocks::ExampleMethodinclude しています。include されているモジュールを読みにいくと、 double や、 instance_double といったお馴染みの機能が定義されており、 allow についてもここで定義されていることが読み取れます。

実際に、Example を実行する時は、 example_group のインスタンスの instance_exec 1 によりブロックを実行するので、ExampleGroup で定義されているメソッドを実行できるようになっています。

def run(example_group_instance, reporter)
  @example_group_instance = example_group_instance
  # ... (省略) ...
          # ExampleGroup のコンテキストで block を実行する
          @example_group_instance.instance_exec(self, &@example_block)
  # ... (省略) ...
end

rspec/rspec-core/lib/rspec/core/example.rb at 0c37a88d4ff511debd563d68e10c1c7672318c3c · rspec/rspec · GitHub

allow の実行

ExampleMethods の中に allow がいることが分かりましたので、それを読んでいきましょう。

def allow(target)
  AllowanceTarget.new(target)
end

rspec/rspec-mocks/lib/rspec/mocks/example_methods.rb at 0c37a88d4ff511debd563d68e10c1c7672318c3c · rspec/rspec · GitHub

どうやら、 allow の正体は、 AllowanceTarget のインスタンスであることが分かりました。こちらも追っていくと、比較的意味がわかりやすいクラスが出てきます。 not_to や、 to_not は許容しない。 to については、なんらかの setup_allowance というメソッドに委譲していそうだということがわかりました。

class AllowanceTarget < TargetBase
  def expression
    :allow
  end

  delegate_to :setup_allowance
  disallow_negation :not_to
  disallow_negation :to_not
end

rspec/rspec-mocks/lib/rspec/mocks/targets.rb at 0c37a88d4ff511debd563d68e10c1c7672318c3c · rspec/rspec · GitHub

to が動的に定義され、その引数を matcher として受け取っていることが分かります。

def delegate_to(matcher_method)
  define_method(:to) do |matcher, &block|
    unless matcher_allowed?(matcher)
      raise_unsupported_matcher(:to, matcher)
    end
    define_matcher(matcher, matcher_method, &block)
  end
end

rspec/rspec-mocks/lib/rspec/mocks/targets.rb at 0c37a88d4ff511debd563d68e10c1c7672318c3c · rspec/rspec · GitHub

さらに define_matcher をたどると、以下のようなコードが出てきます。 つまり、allow(SomethingClient).to(matcher) の正体は、 matcher 自体の name メソッドを呼び出すことだと分かりました。 delegate_to の定義を見ると :setup_allowance に委譲されているため、最終的に matcher.setup_allowance が、target と to に渡した block を引数として実行されることになります。

def define_matcher(matcher, name, &block)
  matcher.__send__(name, target, &block)
end

rspec/rspec-mocks/lib/rspec/mocks/targets.rb at 0c37a88d4ff511debd563d68e10c1c7672318c3c · rspec/rspec · GitHub

matcher の生成

では、 to に渡す、 matcher とは何か? というと、以下の文で言うと後半の receive 以下ということになります。

#                         -> ここから先がmatcher
allow(SomethingClient).to receive(:new).and_return(my_something_client)

こちらも、ExampleMethods で定義されています。こちらも、なんらかのクラスのインスタンスを作るものだったんですね。

def receive(method_name, &block)
  Matchers::Receive.new(method_name, block)
end

rspec/rspec-mocks/lib/rspec/mocks/example_methods.rb at 0c37a88d4ff511debd563d68e10c1c7672318c3c · rspec/rspec · GitHub

しかも、 Matchers::Receive ではメタプログラミング的な手法で、動的にメソッドを作っています。 MessageExpectation から、 public_instance_methods を呼び出して、メソッドを作り出します。ここの面白いところは、

  • MessageExpectation 自体はメソッド名の参照に使っているだけで、ここでは呼ばない。
  • Receive 自体のインスタンスを返すことで、メソッドチェーンを可能にする。( with(1).and_return(something) のような呼び出しを可能にする。)
  • @recorded_customizations には、 ExpectationCustomization のインスタンスを記録するだけ。

というところにあるでしょう。ここでは、 ruby2_keywords(method) という「いにしえ」のメソッドが呼ばれています。 これは Ruby 3.0 でキーワード引数の挙動が変化しており、互換性を保つために存在しています。現在リリースされている最新バージョンの rspec-mocks におけるサポートバージョンは、Ruby 1.8.7 以上であることから、互換性の観点でこの実装がされています。main branch を見る限りは、Ruby 3.0 以降を必須としているため、そのうちこのコードも消えるのでしょうか? コントリビューションチャンス!? 2

own_methods = (instance_methods - superclass.instance_methods)
MessageExpectation.public_instance_methods(false).each do |method|
  next if own_methods.include?(method)

  define_method(method) do |*args, &block|
    @recorded_customizations << ExpectationCustomization.new(method, args, block)
    self
  end
  ruby2_keywords(method) if respond_to?(:ruby2_keywords, true)
end

rspec/rspec-mocks/lib/rspec/mocks/matchers/receive.rb at 0c37a88d4ff511debd563d68e10c1c7672318c3c · rspec/rspec · GitHub

これで、 receive(:new).and_return(my_something_client) のように、チェーンしていても Receive のインスタンスであるということがわかりました。これが、 matcher として機能します。

setup_allowance

次に、Receivesetup_allowance が何をやっているのかを見にいきましょう。

RSpec::Mocks.space.proxy_for(subject) によって、特定のターゲットオブジェクトに対する Proxy があるかを検索し、なければ作ります。 自前のクラスをモックする場合は、 VerifyingPartialClassDoubleProxy のインスタンスが利用されます。( space は今のスコープにおけるメモリ管理に利用しているもので、複数の Proxy を管理している。)

Proxy は、状況に応じて様々な種類が使いわけられます。

  Proxy (基底クラス)
    ├─ PartialDoubleProxy
    │   ├─ PartialClassDoubleProxy
    │   │   └─ include PartialClassDoubleProxyMethods
    │   └─ VerifyingPartialDoubleProxy
    │       ├─ include VerifyingProxyMethods
    │       └─ VerifyingPartialClassDoubleProxy
    │           └─ include PartialClassDoubleProxyMethods

setup_method_substitute では、 Proxy に対して add_stub を行います。後述にはなりますが、ここでは MessageExpectation のインスタンスが返ってきます。

そして、 @recorded_customizations に対して、 playback_onto を実行します。シンプルに、 MessageExpectation のインスタンスを呼んでいます。 ExpectationCustomization には、 and_return といった Receive を修飾する情報が与えられており、 setup_allowance のタイミングで実際の処理が行われていることがわかりました。

メソッドチェーンを行っている最中は単純に記録しておき、それを使うタイミングになるまでは何もせずに待っておくというテクニックを使っているわけですね。

def setup_allowance(subject, &block)
  warn_if_any_instance("allow", subject)
  setup_mock_proxy_method_substitute(subject, :add_stub, block)
end
# ... (省略) ...
def setup_mock_proxy_method_substitute(subject, method, block)
  # ここで、用途に合わせて適切な Proxy が生成される
  proxy = ::RSpec::Mocks.space.proxy_for(subject)
  setup_method_substitute(proxy, method, block)
end
# ... (省略) ...
def setup_method_substitute(host, method, block, *args)
  args << @message.to_sym
  block = move_block_to_last_customization(block)

  # 今回の場合は proxy.add_stub になる
  expectation = host.__send__(method, *args, &(@block || block))

  @recorded_customizations.each do |customization|
    customization.playback_onto(expectation)
  end
  expectation
end

rspec/rspec-mocks/lib/rspec/mocks/matchers/receive.rb at 0c37a88d4ff511debd563d68e10c1c7672318c3c · rspec/rspec · GitHub

def playback_onto(expectation)
  # and_return などが呼び出される
  expectation.__send__(@method_name, *@args, &@block)
end

rspec/rspec-mocks/lib/rspec/mocks/matchers/expectation_customization.rb at 0c37a88d4ff511debd563d68e10c1c7672318c3c · rspec/rspec · GitHub

Proxy 自体はメソッドの上書き状態を管理します。 @method_doubles の管理に利用する Hash は、呼び出したときに存在しない場合は、 MethodDouble を新たに作ろうとします。 MethodDouble をどこで作っているんだろう? と迷いそうなコードですが、Ruby の Hash.new は、ブロックを受け取ると未定義な要素を呼び出した場合の挙動を定義できます。

def initialize(object, order_group, options={})
  # ... (省略) ...
  @method_doubles = Hash.new { |h, k| h[k] = MethodDouble.new(@object, k, self) }
end
# ... (省略) ...
def method_double_for(message)
  @method_doubles[message.to_sym]
end
# ... (省略) ...
def add_stub(method_name, opts={}, &implementation)
  location = opts.fetch(:expected_from) { CallerFilter.first_non_rspec_line }
  method_double_for(method_name).add_stub @error_generator, @order_group, location, opts, &implementation
end

rspec/rspec-mocks/lib/rspec/mocks/proxy.rb at 0c37a88d4ff511debd563d68e10c1c7672318c3c · rspec/rspec · GitHub

MethodDouble では、元のメソッドの退避を行い、プロキシメソッドを定義しています。 definition_target には、通常、対象のオブジェクトのシングルトンクラスを定義しており、 class_exec によりそのスコープで define_method を行います。

# @private
def configure_method
  @original_visibility = visibility
  # ここで元メソッドを退避 (今回はここは詳しく説明しない)
  @method_stasher.stash unless @method_is_proxied
  define_proxy_method
end

# @private
def define_proxy_method
  return if @method_is_proxied

  save_original_implementation_callable!

  # https://docs.ruby-lang.org/ja/latest/method/Module/i/class_exec.html
  definition_target.class_exec(self, method_name, @original_visibility || visibility) do |method_double, method_name, visibility|
    define_method(method_name) do |*args, &block|
      method_double.proxy_method_invoked(self, *args, &block)
    end
    # This can't be `if respond_to?(:ruby2_keywords, true)`,
    # see https://github.com/rspec/rspec-mocks/pull/1385#issuecomment-755340298
    ruby2_keywords(method_name) if Module.private_method_defined?(:ruby2_keywords)
    __send__(visibility, method_name)
  end

  @method_is_proxied = true
rescue FrozenError
  raise ArgumentError, "Cannot proxy frozen objects, rspec-mocks relies on proxies for method stubbing and expectations."
end
# ... (省略) ...
def add_stub(error_generator, expectation_ordering, expected_from, opts={}, &implementation)
  configure_method
  # stub は、MessageExpectation のインスタンス
  stub = message_expectation_class.new(error_generator, expectation_ordering, expected_from,
                                       self, :stub, opts, &implementation)
  stubs.unshift stub
  stub
end

rspec/rspec-mocks/lib/rspec/mocks/method_double.rb at 0c37a88d4ff511debd563d68e10c1c7672318c3c · rspec/rspec · GitHub

上書きされたメソッドでは proxy_method_invoked が呼ばれ、最終的には Proxymessage_received が呼ばれることが分かります。

def proxy_method_invoked(_obj, *args, &block)
  @proxy.message_received method_name, *args, &block
end
ruby2_keywords :proxy_method_invoked if respond_to?(:ruby2_keywords, true)

rspec/rspec-mocks/lib/rspec/mocks/method_double.rb at 0c37a88d4ff511debd563d68e10c1c7672318c3c · rspec/rspec · GitHub

ちょっとややこしくなってきたので、Claude Code に Space, Proxy, MethodDouble の関係を、図にしてもらいました。

  Space  ──1:N──▶  Proxy  ──1:N──▶  MethodDouble  ──1:N──▶  MessageExpectation
    │                │                   │
    │                │                   └─ 1メソッドの stub/expectation を管理
    │                └─ 1オブジェクトの全メソッドを管理
    └─ 全オブジェクトのProxyを管理 (現在のスコープごと)

モックの利用

今までの一連の流れで、ようやく allow(SomethingClient).to receive(:new).and_return(my_something_client) という呼び出しをすることで、 SomethingClient.new の処理が置き換わり @proxy.message_received が呼び出されるということが分かりました。

あとは最後に、これが何をやっているかを読んでおきましょう。そこそこ複雑そうな処理ですが、順を追っていくと、条件に合致する expectation (こちらは allow ではなく expect でモックを検査するときに利用する) と、 stub を探します。

今回の例だと、stub だけがヒットするはずなので、最初の条件式が該当します。ここでは単純に、 stub.invoke(nil, []) となりそうです。 stub は、 Proxy#add_stub で追加されたもので、実態は、 message_expectation_class (MethodDouble の場合、 MessageExpectation 固定) のインスタンスとなります。

def message_received(message, *args, &block)
  record_message_received message, *args, &block

  expectation = find_matching_expectation(message, *args)

  # 条件に一致する stub を探す
  # with がある場合などは、引数に合わせて一致する stub を探しにいくようになっている
  stub = find_matching_method_stub(message, *args)

  if (stub && expectation && expectation.called_max_times?) || (stub && !expectation)
    expectation.increase_actual_received_count! if expectation && expectation.actual_received_count_matters?
    if (expectation = find_almost_matching_expectation(message, *args))
      expectation.advise(*args) unless expectation.expected_messages_received?
    end
    stub.invoke(nil, *args, &block)
  # ... (省略) ...
  end
end
ruby2_keywords :message_received if respond_to?(:ruby2_keywords, true)

MessageExpectation も複雑ですが、要点としては and_return によって、 @implementation.terminal_action が定義されているというのがポイントになります。これにより、 AndReturnImplementation が実行され、値が返される挙動になります。

def invoke(parent_stub, *args, &block)
  if invoking_internals
    safe_invoke_without_incrementing_received_count(parent_stub, *args, &block)
  else
    # 通常はこちらが呼ばれる
    invoke_incrementing_actual_calls_by(1, true, parent_stub, *args, &block)
  end
end
# ...(省略)...

def invoke_incrementing_actual_calls_by(increment, allowed_to_fail, parent_stub, *args, &block)
  # ... (省略) ...

  if implementation.present?
    implementation.call(*args, &block)
  elsif parent_stub
    parent_stub.invoke(nil, *args, &block)
  end

  # ... (省略) ...
end

rspec/rspec-mocks/lib/rspec/mocks/message_expectation.rb at 0c37a88d4ff511debd563d68e10c1c7672318c3c · rspec/rspec · GitHub

and_return によって、 terminal_implementation_action が、 AndReturnImplementation となります。

def and_return(first_value, *values, &_block)
  # ... (省略) ...
  self.terminal_implementation_action = AndReturnImplementation.new(values)

  nil
end
# ... (省略) ...
def terminal_implementation_action=(action)
  implementation.terminal_action = action
end

rspec/rspec-mocks/lib/rspec/mocks/message_expectation.rb at 0c37a88d4ff511debd563d68e10c1c7672318c3c · rspec/rspec · GitHub

and_returnでは位置引数を複数とるので、中身は shift によって1つずつ取り出されるようになっています。これで呼び出される回数によって、別のものを返す挙動もできるようになっています。(allow(SomethingClient).to receive(:new).and_return(my_object_1, my_object_2) とすると、1回目は my_object_1 となり、2回目は my_object_2 となる。)

class Implementation
  attr_accessor :initial_action, :inner_action, :terminal_action

  def call(*args, &block)
    actions.map do |action|
      action.call(*args, &block)
    end.last
  end
  ruby2_keywords :call if respond_to?(:ruby2_keywords, true)

  def present?
    actions.any?
  end

private

  def actions
    [initial_action, inner_action, terminal_action].compact
  end
end

# ... (省略) ...

class AndReturnImplementation
  def initialize(values_to_return)
    @values_to_return = values_to_return
  end

  def call(*_args_to_ignore, &_block)
    if @values_to_return.size > 1
      @values_to_return.shift
    else
      @values_to_return.first
    end
  end
end

rspec/rspec-mocks/lib/rspec/mocks/message_expectation.rb at 0c37a88d4ff511debd563d68e10c1c7672318c3c · rspec/rspec · GitHub

お片付け

テストが終わったら、モックしたクラスは元に戻って欲しいです。それぞれの Exampleafter では、 teardown_mocks_for_rspec が呼び出されます。 これにより、 spacereset_all が呼び出され、すべての proxiesreset されることが読み取れます。

def teardown_mocks_for_rspec
  ::RSpec::Mocks.teardown
end

rspec/rspec-core/lib/rspec/core/mocking_adapters/rspec.rb at 0c37a88d4ff511debd563d68e10c1c7672318c3c · rspec/rspec · GitHub

def self.teardown
  space.reset_all
  @space_stack.pop
  @space = @space_stack.last || @root_space
end

rspec/rspec-mocks/lib/rspec/mocks.rb at main · rspec/rspec · GitHub

def reset_all
  proxies.each_value { |proxy| proxy.reset }
  any_instance_recorders.each_value { |recorder| recorder.stop_all_observation! }
  any_instance_recorders.clear
  @constant_mutators.reverse.each { |mut| mut.idempotently_reset }
end

rspec/rspec-mocks/lib/rspec/mocks/space.rb at main · rspec/rspec · GitHub

PartialDoubleProxyreset では、管理している @method_doubles に対して、 reset を実行します。

def reset
  @method_doubles.each_value { |d| d.reset }
  super
end

rspec/rspec-mocks/lib/rspec/mocks/proxy.rb at main · rspec/rspec · GitHub

stash していたメソッドを元に戻し、保存していた stub 情報をすべて削除します。

def reset
  restore_original_method
  clear
end

rspec/rspec-mocks/lib/rspec/mocks/method_double.rb at main · rspec/rspec · GitHub

まとめ: コードリーディングから見えること

ということで、終始メタプログラミングの魔術を見ていった感じでした。ここまでわかるようになると、自前で Expectation を拡張できることも分かりそうなので、ビジネス表現に合わせた DSL の拡張もできるようになりそうです。

普段から使っているものの中身がどうなっているのかを詳細に見ていくのはなかなか楽しいですね! 特に、Ruby2.x の互換性を保つコードがまだ多く残っていることもわかりました。これからのバージョンに向け、よりシンプルなコードになっていくと予想されます。

今回は、Claude Code にも手伝ってもらいコードリーディングを行なっていったのですが、歴史的な背景を含めて検索するのは、ひとりでは到底難しかったので、OSS コードリーディングのお供としてもエージェントを使うのは良さそうでした!

記事の分量の関係で省略してしまいましたが、メソッド退避の挙動や、RuboCop などでは使うなと言われる allow_any_instance_of がより複雑そうなことをやっている様子なども見てとれます。

また明日

初日からヘビーな記事なってしまった気がしますが、明日は nil0ka さんによる、AI麻雀?の記事です。お楽しみに。


  1. instance_exec は、インスタンスのスコープでブロックを実行することができます。https://docs.ruby-lang.org/ja/latest/method/BasicObject/i/instance_exec.html
  2. 次期バージョンは Ruby2 系のサポートを止めることを明示しています。https://github.com/rspec/rspec/pull/258