Saturday, May 05, 2007

Ruby: Unit testing delegations

When working with Rails I tend to use a lot of delegations. Since I'm a big fan of the Law of Demeter I often find myself delegating an_ad_instance.lister_name to the name method of the lister attribute.
Class Ad < ActiveRecord::Base
extend Forwardable
def_delegator :lister, name, lister_name
def lister
...
end
end
Often, I end up writing tests for something even as simple as delegation, since I try to TDD all the code I write.
class AdTest < Test::Unit::TestCase
def test_lister_name_is_delegated_to_listers_name
ad = Ad.new(:lister => Lister.new(:name => 'lister_name'))
assert_equal 'lister_name', ad.lister_name
end
end
After a few of these tests, the duplication begins to tell me that I can do something simpler with some metaprogramming. So, I stole the idea from Validatable and created some delegation custom assertions.
class AdTest < Test::Unit::TestCase
Ad.delegates do
lister_name.to(:lister).via(:name)
lister_address.to(:lister).via(:address)
...
end
end
The above delegation testing DSL allows me to test multiple validations without the need for many (very similar) tests.

The implementation to get this working is fairly straightforward: I create a subclass of the class under test, add a constructor to the subclass that takes the objec tthat's going to be delegated to, and set the value of the attribute on the delegate object. I also add an attr_accessor to the subclass, since all I care about is the delegation (for this test). It doesn't matter to me that I'm changing the implementation of the lister method because I'm testing the delegation, not the lister method.
class DelegateAssertion
attr_accessor :desired_method, :delegate_object, :original_method

def initialize(desired_method)
self.desired_method = desired_method
end

def to(delegate_object)
self.delegate_object = delegate_object
self
end

def via(original_method)
self.original_method = original_method
end
end

class DelegateCollector
def self.gather(block)
collector = new
collector.instance_eval(&block)
collector.delegate_assertions
end

attr_accessor :delegate_assertions

def delegate_assertions
@delegate_assertions ||= []
end

def method_missing(sym, *args)
assertion = DelegateAssertion.new(sym)
delegate_assertions << assertion
assertion
end
end

class Class
def delegates(&block)
test_class = eval "self", block.binding
assertions = DelegateCollector.gather(block)
assertions.each do |assertion|
klass = Class.new(self)
klass.class_eval do
attr_accessor assertion.delegate_object
define_method :initialize do |delegate_object|
self.send :"#{assertion.delegate_object}=", delegate_object
end
end
test_class.class_eval do
define_method "test_#{assertion.desired_method}_is_delegated_to_#{assertion.delegate_object}_via_#{assertion.original_method}" do
klass_instance = klass.new(stub(assertion.original_method => :original_value))
begin
assert_equal :original_value, klass_instance.send(assertion.desired_method)
rescue Exception => ex
add_failure "Delegating #{assertion.desired_method } to #{assertion.delegate_object} via #{assertion.original_method} doesn't work"
end
end
end
end
end
end
The resulting tests make it hard to justify leaving of tests, even if you are 'only doing simple delegation'.

note: In the example I use an ActiveRecord object, but since I'm adding this behavior to Class any class can take advantage of these custom validations.

No comments:

Post a Comment

Note: Only a member of this blog may post a comment.