🔴
Rubyで特定のメソッドが呼ばれたかを検知する(gem依存なし)
前提
# ruby --version
ruby 3.2.3 (2024-01-18 revision 52bb2ac0a6) [x86_64-linux]
単体テスト目的で特定のメソッドが呼ばれたかを検知したい場合、テスト用フレームワークに stub や spy といった機能が用意されていることがあります。例えば、デフォルトで入っている minitest/autorun
では、stub
を使ってメソッド呼び出しを検知できます。
require 'minitest/autorun'
# 検知対象のオブジェクト
str = "abcbc"
# 置換前のメソッド
original_method_count = str.method(:count)
# 置換するメソッド
mock = lambda do |x|
puts "called: #{x}"
original_method_count.call(x) # 置換前のメソッドを呼ぶ
end
str.stub(:count, mock) do
p str.count('b')
end
本記事では、このメソッドに似た以下の要件を満たしたメソッドを自作します。
- オブジェクト、メソッド名シンボル、ブロックが与えられる。そのオブジェクトの指定されたメソッドが呼ばれると、元々のメソッドの動作に加えて、ブロックが呼び出される。
諸事情で gem に依存できないような状況を想定しています。普通は RSpec 等の機能を使うべきでしょう。
実装
def spy_on(object, method_symbol, &observer_block)
original_method = object.method(method_symbol)
object.define_singleton_method(method_symbol) do |*args|
observer_block.call(*args)
original_method.call(*args)
end
end
使用方法
str = "abcbc"
# str オブジェクトの count メソッドを監視する
spy_on(str, :count) do |args|
puts "called: #{x}"
end
p str.count('b')
解説
それぞれのオブジェクトは、オブジェクト固有の特異クラスを持っています。特異クラスにメソッドを定義することで、そのオブジェクトはそのメソッドを呼び出すようになります。
str = "abcbc"
class << str # 特異クラス定義
def count(*chars) # str の count を上書き
-1
end
end
p str.count('b') # => -1
p "bbbb".count('b') # => 4 インスタンスメソッドはそのまま
上記のようにクラス定義の構文を使うと、外部のローカル変数にアクセス出来ず支障をきたすので、 Object#define_singleton_method
を使います。
str = "abcbc"
str.define_singleton_method(:count) do |*args|
-1
end
p str.count('b') # => -1
メソッドを取得するには Object#method
を使います。
str = "abcbc"
m1 = str.method(:count)
str.define_singleton_method(:count) do |*args|
-1
end
m2 = str.method(:count)
p m1.call('b') # => 2
p m2.call('b') # => -1
Object#method
を利用すれば、置換前のメソッドを呼び出せます。
str = "abcbc"
original_method = str.method(:count)
str.define_singleton_method(:count) do |*args|
original_method.call(*args) + 1000
end
p str.count('ab', 'bc') # => 1002
一般化できそうだね
def spy_on(object, method_symbol, &observer_block)
original_method = object.method(method_symbol)
object.define_singleton_method(method_symbol) do |*args|
observer_block.call(*args)
original_method.call(*args)
end
end
Discussion