Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Restore a class/module to a virginal state

Tags:

ruby

rspec

I am wondering how RSpec achieves this:

expect( MyObject ).to receive(:the_answer).and_return(42)

Does RSpec utilize define_singleton_method to inject :the_answer into MyObject?

That could work. Lets pretend this is what MyObject looks like:

class MyObject
    def self.the_answer
        21 * 2
    end
end

The injected the_answer method could contain something like this:

@the_answer_called = true
42

However, how does RSpec then restore the class to its unmocked state? How does it restore it so MyObject.the_answer "truly" returns 42 again? (Via 21 * 2)

Since writing this question I'm thinking it doesn't. The class methods remain mocked until RSpec stops running.

However, (and this is the crux of the question) how could one undo a define_singleton_method?

I'm thinking the easiest way would be running old_object = Object.dup before define_singleton_method is utilized, but then, how do I restore old_object to Object?

That also doesn't strike me as efficient, when all I want to do is restore one class method. While this isn't a concern at the moment, maybe in the future I also want to keep certain instance variables inside MyObject intact. Is there a non-hacky way to duplicate the code (21 * 2) and redefine that as the_answer via define_singleton_method rather than completely replacing Object?

All ideas welcome, I definitely want to know how to make Object === old_object regardless.

like image 742
JayTarka Avatar asked Sep 27 '22 17:09

JayTarka


1 Answers

RSpec does not duplicate / replace the instance or its class. It dynamically removes the instance method and defines a new one. Here's how it works: (you can do the same for class methods)

Given your class MyObject and an instance o:

class MyObject
  def the_answer
    21 * 2
  end
end

o = MyObject.new
o.the_answer #=> 42

RSpec first saves the original method using Module#instance_method. It returns an UnboundMethod:

original_method = MyObject.instance_method(:the_answer)
#=> #<UnboundMethod: MyObject#the_answer>

it then removes the method using Module#remove_method: (we have to use send here because remove_method is private).

MyObject.send(:remove_method, :the_answer)

and defines a new one using Module#define_method:

MyObject.send(:define_method, :the_answer) { 'foo' }

If you call the_answer now, you are instantly invoking the new method:

o.the_answer #=> "foo"

After the example, RSpec removes the new method

MyObject.send(:remove_method, :the_answer)

and restores the original one (define_method accepts either a block or a method):

MyObject.send(:define_method, :the_answer, original_method)

Calling the method works as expected:

o.the_answer #=> 42

RSpec's actual code is much more complex, but you get the idea.

like image 57
Stefan Avatar answered Oct 12 '22 05:10

Stefan