🔴

Rubyで特定のメソッドが呼ばれたかを検知する(gem依存なし)

2024/09/08に公開

前提

# 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