普段の業務で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)がどのように作られているのかコードを読みながら深ぼってみました。
結論としては、Double
クラスのインスタンスを作成しclass_exec
やdefine_method
を使用してシングルトンクラスの振る舞いを定義することで、モックを作成していました。
処理の流れ
Doubleクラスのインスタンス作成
まず呼び出されたdouble
関数ではExampleMethods
のdeclare_double
関数が呼び出されます。またdeclare_double
関数は第一引数で指定されたクラスのインスタンスを作成しています。
def double(*args) ExampleMethods.declare_double(Double, *args) end
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_doubles
のHash.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
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
と書きますが、これも記述したクラスのシングルトンクラスに対して実装を行うという宣言のために記述していたということに今になって気づきました。
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
と同じような実装がされていました。
特定の振る舞いを固定したい場合はシングルトンクラスを使うのがよくあるパターンなのかなと感じました。