rrのstub/mockと特異クラスのmethod_missing

Rubyのテストでstubやmockが使えるようになるrrについて、不思議な感じの挙動に出会ったので動作を追跡してみた。

まずは基準となる環境を整えることにする。READMEによれば2.0.0-p195でテストを通せということなのでそのようにする。

$ rbenv install 2.0.0-p195
[...]
make: *** [build-ext] Error 2

残念。

ログファイルを見たところ、エラーとなっているのはtcl/tkのようだ。

compiling tcltklib.c
In file included from stubs.c:16:
/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/usr/include/tk.h:86:11: fatal error: 'X11/Xlib.h' file not found
#       include <X11/Xlib.h>
                ^~~~~~~~~~~~
linking shared-object syslog.bundle
1 error generated.
ld: warning: directory not found for option '-L/Users/akira/.rbenv/versions/2.0.0-p195/lib'
make[2]: *** [stubs.o] Error 1
make[2]: *** Waiting for unfinished jobs....
checking ../.././parse.y and ../.././ext/ripper/eventids2.c
ld: warning: directory not found for option '-L/Users/akira/.rbenv/versions/2.0.0-p195/lib'
In file included from tcltklib.c:79:
/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/usr/include/tk.h:86:11: fatal error: 'X11/Xlib.h' file not found
#       include <X11/Xlib.h>
                ^~~~~~~~~~~~

今回のインストールではtcl/tkは使わないのでスキップする。

$ RUBY_CONFIGURE_OPTS=--with-out-ext=tk rbenv install 2.0.0-p195
[...]
Installed ruby-2.0.0-p195 to /.../.rbenv/versions/2.0.0-p195

$ rbenv shell 2.0.0-p195

$ ruby -v
ruby 2.0.0p195 (2013-05-14 revision 40734) [x86_64-darwin19.3.0]

続いて素の状態でテストを通して、インストールに問題ないことを確認する。

$ git clone git@github.com:rr/rr.git

$ cd rr

$ bundle install
rbenv: bundle: command not found

おっと。忘れていた。

$ gem install bundler
Fetching: bundler-2.1.4.gem (100%)
ERROR:  Error installing bundler:
    bundler requires Ruby version >= 2.3.0.

いろいろ起きる…… たぶん2.x系はダメなのだろう。

$ gem install bundler -v '~> 1'
Fetching: bundler-1.17.3.gem (100%)
Successfully installed bundler-1.17.3
1 gem installed

$ bundle install
[...]

$ bundle exec rake
[...]

テストを実行するとものすごい量の出力がある。が、最終的に正常終了した。(試しにわざと失敗するようにテストを書き換えてみたところ、きちんと異常終了した。)

不思議に思った挙動は以下のようなもの。

$ irb -I./lib -rrr t.rb
t.rb(main):001:0> RR::VERSION
=> "1.2.2"
t.rb(main):002:0> if defined?(RR::DSL)
t.rb(main):003:1>   include RR::DSL
t.rb(main):004:1> else
t.rb(main):005:1*   include RR::Adapters::RRMethods
t.rb(main):006:1> end
=> Object
t.rb(main):007:0> class T
t.rb(main):008:1>   class << self
t.rb(main):009:2>     def method_missing(name, *_args, &_block)
t.rb(main):010:3>       return name if %i(a b).include?(name)
t.rb(main):011:3>       super
t.rb(main):012:3>     end
t.rb(main):013:2>     def respond_to_missing?(name, _include_private = false)
t.rb(main):014:3>       return true if %i(a b).include?(name)
t.rb(main):015:3>       super
t.rb(main):016:3>     end
t.rb(main):017:2>   end
t.rb(main):018:1> end
=> nil
t.rb(main):019:0> T.a == :a
=> true
t.rb(main):020:0> T.b == :b
=> true
t.rb(main):021:0> stub(T).a { :A }
=> #<RR::DoubleDefinitions::DoubleDefinition:0x007fcd059f6b20 ... >
t.rb(main):022:0> stub(T).x { :x }
=> #<RR::DoubleDefinitions::DoubleDefinition:0x007fcd059dd9e0 ... >
t.rb(main):023:0> T.a == :A
NoMethodError: undefined method `a' for T:Class
    from /.../src/rr/lib/rr/injections/method_missing_injection.rb:77:in `method_missing'
    from /.../src/rr/lib/rr/injections/double_injection.rb:134:in `a'
    from t.rb:23
    from /.../.rbenv/versions/2.0.0-p195/bin/irb:12:in `<main>'
t.rb(main):024:0> T.b == :b
NoMethodError: undefined method `b' for T:Class
    from /.../src/rr/lib/rr/injections/method_missing_injection.rb:77:in `method_missing'
    from t.rb:24
    from /.../.rbenv/versions/2.0.0-p195/bin/irb:12:in `<main>'
t.rb(main):025:0> T.x == :x
=> true
t.rb(main):026:0>

クラスTのクラスメソッドをstubすると存在していたものがなくなってしまう、かのように見える。

例外が発生している部分を見てみる。

def bind_method
  id = BoundObjects.size
  BoundObjects[id] = subject_class

  subject_class.class_eval((<<-METHOD), __FILE__, __LINE__ + 1)
    def method_missing(method_name, *args, &block)
      if respond_to_missing?(method_name, true)
        super # ← 77行目
      else
        obj = ::RR::Injections::MethodMissingInjection::BoundObjects[#{id}]
        MethodDispatches::MethodMissingDispatch.new(self, obj, method_name, args, block).call
      end
    end
  METHOD
end

respond_to_missing?method_missingがよしなに処理してくることを確認できたらsuperにより親に処理をまかせている。

ここでsubject_classはstub対象の特異クラスである。stub対象がクラスではないオブジェクトのとき、この特異クラスの「親」は自身のクラスとなる。

irb(main):001:0> subject = ''
irb(main):002:0> subject.singleton_class.ancestors
=> [#<Class:#<String:0x00007ff4d108d590>>, String, Comparable, Object, Kernel, BasicObject]

stub対象がクラスのとき、この特異クラスの「親」はObjectの特異クラスとなる。

irb(main):003:0> subject.class.singleton_class.ancestors
=> [#<Class:String>, #<Class:Object>, #<Class:BasicObject>, Class, Module, Object, Kernel, BasicObject]

このため77行目のsuperはObjectの特異クラスによって受け取められる。そしてObjectの特異クラスには、メソッドabへの特別な処理はないので、最終的にNoMethodErrorが発生することになる。

どうやらこういう流れらしい。

もともと、この挙動に気付いたのは、ActionMailerを使ったとあるコードを見ていたときで、ふとrrを1.1.2から1.2.1にアップグレードしてみたところテストがうまく動かなくなるケースがあった。

x = nil
stub(FooMailer).send_foo {|arg| x = arg }
FooMailer.send_foo(:bar).deliver_later
x # => rr 1.1.2では:bar、1.2.1ではnil

こんな感じのコードで、stubしたはずのsend_fooのブロックが実行されない。

rrの動きは上述のサンプルと同じなのだが、ActionMailerの場合、少し前提が違っているところがある。

FooMailerはActionMailer::Base(またはApplicationMailer)を継承しているから、FooMailerの特異クラスの「親」はActionMailer::Baseの特異クラスとなる。このことにより77行目のsuperはActionMailer::Base.send_fooを呼び出す。

ActionMailer::Baseの特異クラスにはsend_fooの定義はもちろんないが、method_missingが定義されていて、呼び出されたメソッド名(send_foo)や引数(:bar)をもとにMessageDeliveryオブジェクトを生成して返すという、通常通りの動作をすることになる。

こうしてstubしたはずのsend_fooは、例外を起こすことはなく、しかしstubのブロックを実行することなく処理を終えるのだった。