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の特異クラスには、メソッドa
やb
への特別な処理はないので、最終的に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のブロックを実行することなく処理を終えるのだった。