We know that it's pretty simple to "reopen" a Ruby class at runtime and change the method definitions in it. Typically you'd want to be very careful when you use this Ruby feature.
One place where it's probably okay to do such a thing is in test code (although I'd much rather use a mocking framework like mocha to stub out methods). However even in test code you need to be careful... as this example will reveal:
require 'test/unit' class SomeClass def do_something "whatever" end end class MyClassTest < Test::Unit::TestCase def test_my_class_behavior ... end end
In the above, do_something is being stubbed out and it is probably being used indirectly by some invocation in one of the tests.
A better way to handle the above would be:
require 'test/unit' require 'mocha' class MyClassTest < Test::Unit::TestCase def setup SomeClass.any_instance.stubs(:do_something).returns('whatever') end def test_my_class_behavior ... end end
The above two both work and hence may seem equivalent. But there is a difference. Doing the former, running this test class in isolation will not fail but when using rake to run multiple tests, with some other tests relying on do_something to work normally, will cause those tests to fail without good reason. This is because rake will load MyClassTest and hence will have changed the behavior of do_something affecting other tests. To add to the confusion, running those failing tests in isolation will not be able to reproduce the failure because in that case SomeClass will not have been reopened.
Moral of the story: be very careful about the lasting repercussions of reopening a class.
As an afterthought, I started wondering how to fix the problem of the former approach without using a mocking library. This is what I came up with:
module RevertableMethods def update_method(method_name, &block) alias_method "orig_#{method_name}", method_name unless instance_methods.include?("orig_#{method_name}") define_method method_name, block end def revert_method(method_name) remove_method method_name alias_method method_name, "orig_#{method_name}" remove_method "orig_#{method_name}" end end if __FILE__ == $0 require 'test/unit' class RevertableMethodsTest < Test::Unit::TestCase def setup @klass = Class.new do extend RevertableMethods def hi; "hi"; end end end def test_update_should_invoke_new_implementation @klass.update_method(:hi) { "bye" } assert_equal "bye", @klass.new.hi end def test_update_twice_should_invoke_latest_implementation @klass.update_method(:hi) { "hello" } @klass.update_method(:hi) { "bye" } assert_equal "bye", @klass.new.hi end def test_update_once_and_revert_once_should_give_back_original_implementation @klass.update_method(:hi) { "bye" } @klass.revert_method(:hi) assert_equal "hi", @klass.new.hi end def test_update_once_and_revert_once_should_remove_copy_of_original @klass.update_method(:hi) { "bye" } @klass.revert_method(:hi) assert_equal false, @klass.new.methods.include?("orig_hi") end def test_revert_without_update_first_should_raise_error assert_raise(NameError) { @klass.revert_method(:hi) } end def test_revert_twice_after_update_should_raise_error @klass.update_method(:hi) { "bye" } @klass.revert_method(:hi) assert_raise(NameError) { @klass.revert_method(:hi) } end def test_update_twice_and_revert_once_should_give_back_original_implementation @klass.update_method(:hi) { "hello" } @klass.update_method(:hi) { "bye" } @klass.revert_method(:hi) assert_equal "hi", @klass.new.hi end def test_update_should_modify_behavior_of_all_instances existing_instance = @klass.new @klass.update_method(:hi) { "bye" } assert_equal "bye", @klass.new.hi assert_equal "bye", existing_instance.hi end def test_revert_should_revert_behavior_of_all_instances @klass.update_method(:hi) { "bye" } existing_instance = @klass.new @klass.revert_method(:hi) assert_equal "hi", @klass.new.hi assert_equal "hi", existing_instance.hi end end end
Now using RevertableMethods, my original test case would look like this:
require 'test/unit' require 'revertablemethods' SomeClass.extend(RevertableMethods) class MyClassTest < Test::Unit::TestCase def setup SomeClass.update_method(:do_something) { 'whatever' } end def teardown SomeClass.revert_method(:do_something) end def test_my_class_behavior ... end end