Ruby の Mocha がとてもメタボの匂いがしたので読んでみた
テストにスタブとかモックを導入しようと思って、Mocha を使おうとしたら expects メソッドでつまづき id:mzp にヘルプ出したら
「test_* が終わった時点で呼び出されたかを判断するっぽい動き」
と説明されました。
なんでそんなことになるんだ、ということで読んでみました。
id:mzp が基本的なことを書いているので、こちらは落穂ひろいということで
RubyのMochaはメタボ気味だった件について - みずぴー日記
メタプログラミングRubyを読んだばかりなので、ちょうどよい勉強になりました。
まずは例
require 'test/unit' require 'rubygems' require 'mocha' class StringTest < Test::Unit::TestCase def test_size str = 'hoge' str.expects(:size) str.size end end
非常に恣意的な例ですが、Stringオブジェクトのsizeメソッドが呼ばれることを期待しています。
これを実行するとこんな感じ
$ ruby string_test.rb
Loaded suite string_test
Started
.
Finished in 0.000561 seconds.1 tests, 1 assertions, 0 failures, 0 errors
これを、以下のように size メソッドの呼び出しをやめて
require 'test/unit' require 'rubygems' require 'mocha' class StringTest < Test::Unit::TestCase def test_size str = 'hoge' str.expects(:size) # str.size <- sizeメソッドの呼び出しをやめる end end
実行してみると、
$ ruby string_test.rb
Loaded suite string_test
Started
F
Finished in 0.001196 seconds.1) Failure:
test_size(StringTest) [string_test.rb:8]:
not all expectations were satisfied
unsatisfied expectations:
1 tests, 1 assertions, 1 failures, 0 errors
なんだよ not yet invoked って!
mocha を require している以外は普通の runit のコードなので、 test_size を走らせてる部分が拡張されているとしか思えませんね。
ソースを追ってみる
mocha (0.9.8) のソースを追ってみました。
id:mzp が説明している部分は省いて、さらに一歩踏み込んでみます。
# lib/mocha/integration/test_unit/ruby_version_186_and_above.rb module Mocha module Integration module TestUnit module RubyVersion186AndAbove def run(result) ・・・ assertion_counter = AssertionCounter.new(result) ・・・ setup __send__(@method_name) mocha_verify(assertion_counter) ・・・ end end end end
ここが黒魔術の正体ですね。
__send__(@method_name) でもとのテストを行ったあとに mocha_verify を追加しています。
あとは、mocha_verify の中で id:mzp の記事のとおり、expects で登録されたメソッドが呼ばれたかどうかをチェックします。
しかし、mocha_verify はテストメソッド本体の終了後に呼び出されます。
いったい、どうやってメソッド呼び出しをカウントするんでしょうか?
ここも黒魔術の匂いがします。
ここで expectsの定義。
# lib/mocha/mock.rb def expects(method_name_or_hash, backtrace = nil) iterator = ArgumentIterator.new(method_name_or_hash) iterator.each { |*args| method_name = args.shift ensure_method_not_already_defined(method_name) expectation = Expectation.new(self, method_name, backtrace) expectation.returns(args.shift) if args.length > 0 @expectations.add(expectation) } end
Expectation というクラスのインスタンスを生成し登録します。
Expectation は expects メソッド呼び出しに対して一つ生成されます。
ここで、ensure_method_not_already_defined という恐ろしい名前のメソッドが呼ばれてます。
# lib/mocha/mock.rb def ensure_method_not_already_defined(method_name) self.__metaclass__.send(:undef_method, method_name) if self.__metaclass__.method_defined?(method_name) end
ktkr!
expects を呼んだ時、そのメソッド定義を殺してます。
例でいえば、 size メソッドを undefined にしちゃってます。
たしかに、expects を呼んだってことはレシーバはモックのはずなので、具体的な定義はいらないですね。
ということは、、、
# lib/mocha/mock.rb def method_missing(symbol, *arguments, &block) if @responder and not @responder.respond_to?(symbol) raise NoMethodError, "undefined method `#{symbol}' for #{self.mocha_inspect} which responds like #{@responder.mocha_inspect}" end if matching_expectation_allowing_invocation = @expectations.match_allowing_invocation(symbol, *arguments) matching_expectation_allowing_invocation.invoke(&block) else if (matching_expectation = @expectations.match(symbol, *arguments)) || (!matching_expectation && !@everything_st ubbed) message = UnexpectedInvocation.new(self, symbol, *arguments).to_s message << Mockery.instance.mocha_inspect raise ExpectationError.new(message, caller) end end end
メタプログラミングRubyではお約束(?)の method_missing です。
expects で殺されたメソッドは当然どこを探してもないので、method_missingが発生し、ここにたどり着きます。
ここで対応するExplanationオブジェクトに対して invoke(&block) を呼び出しています。
# lib/mocha/expectation.rb def invoke @invocation_count += 1 perform_side_effects() if block_given? then @yield_parameters.next_invocation.each do |yield_parameters| yield(*yield_parameters) end end @return_values.next end
やっと辿りつきました。
invoke を呼び出したときに一回カウントアップしています。
まとめ
expects による呼び出しチェックは概ね以下の手順で行われているようです。
- obj.expects(:name) で obj のメソッド name を undef。Expectation クラスのインスタンスを生成
- obj.name をコールしたときに method_missing が発生 -> Expectation のカウントアップ
- テストメソッド終了時にすべてのExpectationのカウントをチェック
Mocha はやっぱりメタボ気味。