30 Days of Tech: Day 10 - Super Stub!
June 10th, 2008
If you’re a Rubyist, doing TDD, and using mocks and stubs of any sort you may have encountered difficulties with mocking or stubbing super calls in subclasses. The reason is simple: super isn’t a method, so you can’t mock it with a method mocking framework. There are a number of ways to get around this problem—I’ll discuss four possible techniques below with short discussion of potential strengths and weaknesses …
If you’re a Rubyist, doing TDD, and using mocks and stubs of any sort you may have encountered difficulties with mocking or stubbing super calls in subclasses. The reason is simple: super isn’t a method, so you can’t mock it with a method mocking framework. There are a number of ways to get around this problem—I’ll discuss four possible techniques below with short discussion of potential strengths and weaknesses. Whether or not you want to employ any of these techniques is up to you.
For all the examples below, let’s assume we’re trying to test Subclass#a_method, as defined here:
class Superclass
def a_method
1 + 2
end
end
class Subclass < Superclass
def a_method
super + 3
end
end
The simplest technique is to test the super call implicitly. Simply allow the subclass instance to rely on the superclass implementation and incorporate the super call into the expected output.
def test_super_is_called_implicitly
assert_equal 6, Subclass.new.a_method
end
I actually think this is the strongest test. It most directly tests the behavior of the Subclass and involves no metaprogrammatic testing. Furthermore, if the implementation of Subclass changes to call super differently or to call a method other than super the test continues to test the behavour of a_method without needing to be change. Unfortunately, this is also the test the most quickly falls on its face if super is doing something worthy of mocking or stubbing. For instance, if we are writing unit tests disconnected from the database and super hits the database we’ll need to either mock it out or write a functional test.
If we want to mock it out we need to somehow intercept the super call, track it, and supply the behaviour we want super to have in the context of our test (a canned result). Since class are open in Ruby the most obvious way to do this is by opening the superclass and changing the behaviour of a_method:
def test_super_is_called_explicitly_by_modifying_superclass
Superclass.class_eval do
alias_method :original_method, :a_method
attr_reader :a_method_called
def a_method
@a_method_called = true
4
end
end
instance = Subclass.new
assert_equal 7, instance.a_method
assert instance.a_method_called
ensure
Superclass.class_eval do
alias_method :a_method, :original_method
remove_method :a_method_called
end
assert_equal 6, Subclass.new.a_method
end
This method works just fine, but has a major drawback—complicated and error prone cleanup code. Because the test is modifying Superclass directly it must ensure that the changes it makes are undone, otherwise other tests would be run against the hacked Superclass rather than the original. Since the ensure block must undo the changes done above, the two must always be kept in sync. The signal to noise ratio in this test is also quite bad: 3 lines of actual test code versus 14 lines of setup and teardown.
We can improve on this technique slightly if we realize we can inject a Module between the Subclass and Superclass to intercept the super call.
def test_super_is_called_explicitly_by_injecting_a_module
super_tracker = Module.new do
attr_reader :a_method_called
def a_method
@a_method_called = true
4
end
end
Subclass.send :include, super_tracker
instance = Subclass.new
assert_equal 7, instance.a_method
assert instance.a_method_called
ensure
super_tracker.module_eval do
instance_methods.each {|m| remove_method m}
end
assert_equal 6, Subclass.new.a_method
end
Here we’ve injected the same code for verifying the super call but we’ve left the Superclass untouched. On the other hand, the Subclass needed to be changed to include the anonymous module that catches the call, which is not ideal. Fortunately the cleanup code is a bit nicer in this case. By removing all the methods in the anonymous module, we ensure that it doesn’t affect the behavior of the class even though it is still in the classes ancestory. Unfortunately this does mean that if you have code that depends on the ancestory you might run into issues, but it may still be an improvement over the previous solution. The signal to noise ratio here is about the same as the last solution as well (3 to 12).
What if we could eliminate the cleanup code? It turns out this is easy once we’ve hit on the module technique—we just need to dup the class under test.
def test_super_is_called_explicitly_by_injecting_a_module_into_a_dup_class
super_tracker = Module.new do
attr_reader :a_method_called
def a_method
@a_method_called = true
4
end
end
test_class = Subclass.dup
test_class.send :include, super_tracker
instance = test_class.new
assert_equal 7, instance.a_method
assert instance.a_method_called
ensure
assert_equal 6, Subclass.new.a_method
end
Here we have 3 lines of test code to 9 lines of setup, which is a marked improvement. I didn’t count the final assertion since I wouldn’t include it in practice, it’s just there to prove that we didn’t alter the original class. We’ve also avoiding changing either Superclass or Subclass in this example, which is a boon. There is a downside, however. The test is now running a against a duplicate of the original class rather than the real thing. Most of the time this won’t matter, but if the code under test depends on the name of the class you will encounter problems.
If you’ve been thinking this is all quite complicated, you’re absolutely right. I would probably only use any of the above solutions (besides the very first one) in practice if I absolutely had to have a Subclass for some reason. If I could change the design of the code, I would strongly consider changing the Subclass to perform delegation:
class DelegatingClass
def initialize(delegate = Superclass.new)
@delegate = delegate
end
def a_method
@delegate.a_method + 3
end
end
Once the design of the class has been changed, you gain a lot of flexibility. For one, the original test from the first example still works
def test_delegate_is_called_implicitly
assert_equal 6, DelegatingClass.new.a_method
end
And because the DelegatingClass can take an instance to delegate to, you can pass an arbitrary stub in. Using code similar to that above, it would look like this:
def test_delegate_is_called_explicitly_with_dependency_injection
test_delegate_class = Class.new do
attr_reader :a_method_called
def a_method
@a_method_called = true
4
end
end
delegate_instance = test_delegate_class.new
instance = DelegatingClass.new(delegate_instance)
assert_equal 7, instance.a_method
assert delegate_instance.a_method_called
assert_equal 6, DelegatingClass.new.a_method
end
As written this is only an incremental improvement over the last solution. It doesn’t involve duping classes, injecting modules, or renaming methods so it’s much easier to understand. It’s the cleanest of any of the non trivial solutions we’ve seen in this regard. The biggest advantage of changing to a delegate, however, is that we’ve regained the ability to use a normal mocking framework.
def test_delegate_is_called_explicitly_with_mocks
delegate_instance = mock
delegate_instance.expects(:a_method).returns(4)
instance = DelegatingClass.new(delegate_instance)
assert_equal 7, instance.a_method
end
Clearly this is a cleaner option than any of the metaprogramming solutions present above. Delegating in much more flexible than inheritance, so try not to underestimate the advantage of changing your design. You will still run into situations where you have to have a subclass, however. In those cases, remember the techinques above and use them as a starting point to figure out what approach makes sense for your application. And if you come up with something slick, let me know!
Sorry, comments are closed for this article.