Rspecのモック(double)はどのように作られているのか

普段の業務でrspecのモック機能には非常にお世話になっています。使い方は非常に簡単で、既存クラスに定義された関数の振る舞いを変更することが出来ます。 (※実際に既存クラスの定義が書き変わるわけではありません)

class Hoge
  def foo
    'foo!'
  end

  def bar
    'bar!'
  end
end

# ref: https://relishapp.com/rspec/rspec-mocks/docs/basics/test-doubles
RSpec.describe "A test double" do
  it "returns canned responses from the methods named in the provided hash" do
    dbl = double('Hoge', :foo => 'this is foo!', :bar => 'this is bar!')
    expect(dbl.foo).to eq('this is foo!')
    expect(dbl.bar).to eq('this is bar!')
  end
end

すごいですね。どうやって作られているのでしょうか。最近はOSSのコードを読む機会が多いのでせっかくなので、rspecのモック(double)がどのように作られているのかコードを読みながら深ぼってみました。

www.okb-shelf.work

結論としては、Doubleクラスのインスタンスを作成しclass_execdefine_methodを使用してシングルトンクラスの振る舞いを定義することで、モックを作成していました。

処理の流れ

Doubleクラスのインスタンス作成

まず呼び出されたdouble関数ではExampleMethodsdeclare_double関数が呼び出されます。またdeclare_double関数は第一引数で指定されたクラスのインスタンスを作成しています。

def double(*args)
  ExampleMethods.declare_double(Double, *args)
end

rspec-mocks/example_methods.rb at e643b40800eb1b7859215647c035cfd2ed8cce08 · rspec/rspec-mocks · GitHub

module ExampleMethods
  # @private
  def self.declare_double(type, *args)
    args << {} unless Hash === args.last
    type.new(*args)
  end
end

ここまででDoubleクラスのインスタンスが作られました。また、Doubleクラスは内部でTestDoubleモジュールを読み込んでおり、実質、Doubleクラスのインスタンス作成はTestDoubleモジュールの呼び出しとなっています。

class Double
  include TestDouble
end

連続的なインスタンス作成

次にTestDoubleの実装を見てみるとinitialize関数(インスタンス作成時に呼び出される)で2つの引数を定義しています。第一引数にはモックを作成したいクラス名(eg: Hoge)。第二引数の可変長引数には関数の振る舞いを記録したハッシュ(eg: :foo => 'this is foo!', :bar => 'this is bar!')が指定されます。

module TestDouble
  # Creates a new test double with a `name` (that will be used in error
  # messages only)
  def initialize(name=nil, stubs={})
    @__expired = false
    if Hash === name && stubs.empty?
      stubs = name
      @name = nil
    else
      @name = name
    end
    assign_stubs(stubs)
  end

  def assign_stubs(stubs)
    stubs.each_pair do |message, response|
      __mock_proxy.add_simple_stub(message, response)
    end
  end

  def __mock_proxy
    ::RSpec::Mocks.space.proxy_for(self)
  end
end

第一引数のクラス名は@nameに記録され、第二引数のハッシュはassign_stubs関数に渡されます。この関数内では指定されたキーバリューのペアごと:RSpec::Mocks.space.proxy_for(self)に定義されたadd_simple_stub関数が呼び出されます。この部分の呼び出し階層がかなり深いため、情報を割愛しますが、最終的に::RSpec::Mocks.space.proxy_for(self)TestDoubleProxyクラスのインスタンスを返します。

class TestDoubleProxy < Proxy
  def reset
    @method_doubles.clear
    object.__disallow_further_usage!
    super
  end
end

rspec-mocks/proxy.rb at e643b40800eb1b7859215647c035cfd2ed8cce08 · rspec/rspec-mocks · GitHub

TestDoubleProxyクラスはProxyクラスを継承しており、resetの振る舞いだけオーバーライドしています。先程のadd_simple_stub関数はProxyクラスに実装された関数です。

class Proxy
  def initialize(object, order_group, options={})
    ensure_can_be_proxied!(object)

    @object = object
    @order_group = order_group
    @error_generator = ErrorGenerator.new(object)
    @messages_received = []
    @messages_received_mutex = Mutex.new
    @options = options
    @null_object = false
    @method_doubles = Hash.new { |h, k| h[k] = MethodDouble.new(@object, k, self) }
  end

  def add_simple_stub(method_name, response)
    method_double_for(method_name).add_simple_stub method_name, response
  end

  private

  def method_double_for(message)
    @method_doubles[message.to_sym]
  end
end

add_simple_stub関数ではmethod_double_for関数の呼び出しを通して@method_doublesに記録されたバリューを取得しています。initialize関数の@method_doublesHash.newにはブロック引数が指定されており、この指定があることでキーに対応するバリューが存在しなかった場合にブロック引数に指定された挙動が実行されます。今回は初回呼び出しのため、MethodDouble.new(@object, k, self)が実行されてMethodDoubleクラスのインスタンスが作成されます。

なのでMethodDoubleクラスに定義されたadd_simple_stub関数が呼び出される訳ですね。階層が深くて頭がこんがらがってきました。

シングルトンクラスとメタプログラミング

ここに来るまでに多くのクラスを中継してきましたが、いよいよ核部分であるMethodDoubleに辿り着きました。

  • ExampleMethods
  • Double(TestDouble)
  • TestDoubleProxy
  • MethodDouble

長くなるため、部分的に割愛しています。呼びだされたadd_simple_stub関数から色々な関数を中継していきます。重要なのはdefine_proxy_method関数です。他の関数やインスタンス変数はmutex等を使ってすでに記録した関数の振る舞いやモックとなったクラスに実装されている振る舞いなどを記録しています。

  • add_simple_stub
  • setup_simple_method_double
  • define_proxy_method
class MethodDouble
  attr_reader :method_name, :object, :expectations, :stubs, :method_stasher

  # @private
  def initialize(object, method_name, proxy)
    @method_name = method_name
    @object = object
    @proxy = proxy

    @original_visibility = nil
    @method_stasher = InstanceMethodStasher.new(object, method_name)
    @method_is_proxied = false
    @expectations = []
    @stubs = []
  end

  def add_simple_stub(method_name, response)
    setup_simple_method_double method_name, response, stubs
  end

  def setup_simple_method_double(method_name, response, collection, error_generator=nil, backtrace_line=nil)
    define_proxy_method

    me = SimpleMessageExpectation.new(method_name, response, error_generator, backtrace_line)
    collection.unshift me
    me
  end

  def define_proxy_method
    return if @method_is_proxied

    save_original_implementation_callable!
    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
      # :
    end

    @method_is_proxied = true
  rescue RuntimeError, TypeError => e
    # :
  end
end

rspec-mocks/method_double.rb at e643b40800eb1b7859215647c035cfd2ed8cce08 · rspec/rspec-mocks · GitHub

define_proxy_method関数でdefinition_target関数が呼ばれることで、おそらく最後のobject_singleton_class関数が呼び出されます。

def definition_target
  @definition_target ||= usable_rspec_prepended_module || object_singleton_class
end

def usable_rspec_prepended_module
   :
end

def object_singleton_class
  class << @object; self; end
end

object_singleton_class関数の内部を見てみると何やら見慣れない記述が...。これは@objectのシングルトンクラスが取得できるそうで、Rubyでクラスメソッドを定義する際にclass << selfと書きますが、これも記述したクラスのシングルトンクラスに対して実装を行うという宣言のために記述していたということに今になって気づきました。

stackoverflow.com

class Hoge
end

singleton = class << Hoge; self; end
singleton.class_exec do
  define_method(:bar) { "bar!" }
end

puts Hoge.new.singleton_class.bar # bar!

そして、取得したシングルトンクラスに対してclass_exec関数とdefine_method関数を用いてメタプログラミングで関数の振る舞いを定義していきます。内部でproxy_method_invoked関数が呼び出されていますが、非常に処理が難解です。クラス変数の配列に値を記録したり削除したりして、振る舞いを管理しているようです。気になる方はぜひコードを見てみてください。

これで作成したモックが呼び出された時の振る舞いが定義されました。前回調べた、travel_toと同じような実装がされていました。 特定の振る舞いを固定したい場合はシングルトンクラスを使うのがよくあるパターンなのかなと感じました。

www.okb-shelf.work

参考文献