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:

  • expected exactly once, not yet invoked: 'hoge'.size(any_parameters)

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 による呼び出しチェックは概ね以下の手順で行われているようです。

  1. obj.expects(:name) で obj のメソッド name を undef。Expectation クラスのインスタンスを生成
  2. obj.name をコールしたときに method_missing が発生 -> Expectation のカウントアップ
  3. テストメソッド終了時にすべてのExpectationのカウントをチェック

Mocha はやっぱりメタボ気味。




メタプログラミングRuby
Paolo Perrotta
アスキー・メディアワークス
売り上げランキング: 99948