21st
Ruby: hooks and metaprogramming 2
Here are some real life examples that come from Ruby-Concordion.
(A ruby active specification framework based on annotated HTML. Try it out with: gem install concordion .)
The “inherited” method, to detect subclasses of ConcordionTestCase:
def self.inherited(subclass)
# Concordion config/housekeeping code omitted.
bind_test_method_to(subclass, config)
end
and the similarly defined “included” method, to detect classes that include the ConcordionTestMethods module:
def self.included(cmod)
# Concordion config/housekeeping code omitted.
bind_test_method_to(cmod, config)
end
Note that we’re keeping these methods DRY. The method we want to add to both classes is the same: a test method that runs the association concordion spec. (Note: in practice be sure to capture the existing inherited/included() methods with alias_method, and delegate to the original implementation!).
Here’s the metaprogramming code that creates the appropriate test method:
def bind_test_method_to(subclass, config)
subclass.class_eval do
define_method :test_spec do
filename = snake_cased_test_name(subclass.to_s)
parse_spec(filename,config)
failures = run_spec(filename, config)
report_spec(filename,config, failures)
end
end
subclass
end
This code opens the users class (which descends from ConcordionTestCase or includes ConcordionTestMethods) and defines a new method: “test_spec”. Why is it done this way?
Because the framework can then:
- Infer the name of the specification (e.g. a class FooBarTest is bound to the spec “foo_bar.html”).
- Look for fixture code in the user’s class without the user doing anything but extending ConcordionTestCase or including ConcordionTestMethod: no plumbing code in user fixture code.
- Report errors with the appropriate context. For example: specification errors in a concordion fixture UserTest come from UserTest.test_spec, not the parent class: ConcordionTestCase.
- Provide support for declarative configuration per user fixture. For example: expected errors for a specific test case can define the method “expected_failure_count” to return the expected number of incorrect results in the spec.
I think it provides a nice example of the trade-offs involved in metaprogramming. On the one side you have flexibility: the power to create a more friendly API than could be practically created without it. On the other hand, the code is less direct and thus harder to read/maintain.
Readers interested in further context can view the complete Ruby-Concordion source code online.
The methods quoted come from “lib/concordion_test_methods.rb”.