AmanKing

Updating and re...Control PanelChange LogBrowse PagesSearch?

Updating and reverting methods in Ruby

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

Tags: technology:coding, technology:ruby Last modified 06:34 Mon, 6 Oct 2008 by AmanKing. Accessed 134 times Children What Links Here share Share Except where expressly noted, this work is licensed under a Creative Commons License.