0

Say, I have:

class Test
  def initialize(m)
    @m = m
  end

  def test
    @m
  end
end

How can I temporarily make method #test of all instances (both existing and new ones) of Test return 113, and then restore the original method later?

It sounds like such a simple thing, yet I can't find a nice way to achieve it. Probably because of my poor knowledge of Ruby.

What I have found so far is:

# saving the original method
Test.send(:alias_method, :old_test, :test)

# redefining the method
Test.send(:define_method, :test) { 113 }

# restore the original method
Test.send(:alias_method, :test, :old_test)

Which does the job but, as I understand, it would also redefine the existing #old_test if one existed?.. And it just feels like a hack rather than proper use of metaprogramming?..

  1. Is it possible to do something like this without using aliases (but also without modifying the source code of Test)?
  2. If not (or if there are easier/nicer ways) how would you do this if you could modify the source code of Test?

I would appreciate if you could describe multiple ways of achieving the same thing, even those that are hard or impractical. Just to give an idea about the flexibility and limitations of metaprogramming in Ruby :)

Thank you very much

P.S. The reason I started all of this: I am using gem rack-throttle to throttle requests starting with /api, but other urls shouldn't be affected., and I want to test all of this to make sure it works. In order to test the throttling I had to add the middleware to the test environment too. I've successfully tested it (using minitest), however all other tests that test ApiController shouldn't be throttled because it makes tests take much longer if we need to wait 1 second after each request.

I decided to monkey patch the RequestSpecificIntervalThrottle#allowed? with { true } in minitest's #setups to temporarily disable throttling for all of those tests, and then reenable it again in #teardowns (as otherwise the tests testing the throttling itself will fail). I would appreciate if you tell me how you would approach this.

However now that I've already started digging into metaprogramming, I am also just curious how to achieve this (temporarily redefining a method) even if I am not actually going to use it.

Bo Yi
  • 123
  • 1
  • 9
  • 2
    What is the reason for temporarily redefining the method? On the surface (not knowing the reasoning) it seems like this may be a situation where exception handling or conditional statements with an alternative path would be more appropriate. –  Sep 01 '21 at 19:39
  • @MichaelB I've now edited the question -- added the reason :) – Bo Yi Sep 01 '21 at 19:54
  • 1
    Would stubbing do what you need? Instead of "redefining" the method, you can stub it in the test where you need something different to happen. – JohnP Sep 01 '21 at 20:59
  • I am not sure how I would use the stubbing here... as because the throttling is in the middleware itself, I'd need to somehow change the middleware for those tests (make it not use throttling). – Bo Yi Sep 01 '21 at 21:11
  • 2
    Rspec supports `allow_` and `expect_any_instance_of(Class).to receive(method_name)` https://relishapp.com/rspec/rspec-mocks/docs/working-with-legacy-code/any-instance – melcher Sep 01 '21 at 23:07
  • @melcher Thanks. Sounds like that could work. I use minitest but there must be something similar for minitest too -- i will google. – Bo Yi Sep 02 '21 at 10:30
  • 1
    @JohnP @melcher by the way, just to let you know. I did it using `Test.any_instance.stubs(:test).returns(113)` and it worked very well which is essentially what you advised me. Thanks a lot – Bo Yi Sep 03 '21 at 08:53

2 Answers2

3

You can use instance_method to get a UnboundMethod object from any instance method:

class Foo
  def bar
    "Hello"
  end
end
old_method = Foo.instance_method(:bar)
# Redifine the method
Foo.define_method(:bar) do
  puts "Goodbye"
end
puts Foo.new.bar # Goodbye

# restore the old method:
Foo.define_method(old_method.name, old_method)

Unbound methods are a reference to the method at the time it was objectified: subsequent changes to the underlying class will not affect the unbound method.

The equivilent for class methods is:

class Foo
  def self.baz
    "Hello"
  end
end

old_method = Foo.method(:baz).unbind

If you want to make the worlds smallest (and perhaps the most useless) stubbing library you could do it with:

class Stubby
  def initialize(klass, method_name, &block)
    @klass = klass
    @old_method = klass.instance_method(method_name)
    @klass.define_method(method_name, &block)
  end

  def restore
    @klass.define_method(@old_method.name, @old_method)
  end

  def run_and_restore
    yield
  ensure
    restore
  end
end

puts Foo.new.bar # Hello

Stubby.new(Foo, :bar) do
  "Goodbye"
end.run_and_restore do
  puts Foo.new.bar # Goodbye
end

puts Foo.new.bar # Hello
max
  • 96,212
  • 14
  • 104
  • 165
  • Thank you very much for the explanation Max! Seems like my problem was that I was doing `Foo.send(:define_method, old_method.name, &old_method)` (extra ampersand) and it complained with `TypeError: wrong argument type UnboundMethod (expected Proc)` ‍♂️. Thank you very much for the answer and helping me find my silly mistake! By the way, you should probably also change `puts Foo.bar` to `Foo.new.bar` as I guess that is what you were intending to write, as we need the instance method, not the class method :) Thanks again! :) – Bo Yi Sep 01 '21 at 22:25
  • 1
    Oops. You're right - I intended to write `Foo.new.bar`. – max Sep 01 '21 at 22:27
1

It's impractical as you asked in the question :)

class Test
  def initialize(m)
    @m = m
  end

  def test
    @m
  end
end

t = Test.new(9)
t.test # => 9

Test.define_method(:test) { 113 }
t.test # => 113

Test.define_method(:test) { instance_variable_get(:@m) }
t.test # => 9

Test.undef_method(:test)
t.test # will raise NoMethodError
mechnicov
  • 12,025
  • 4
  • 33
  • 56