Seam: A place where you can alter behavior in your program without editing in that place.
I came across some Ruby code I'd written in October 2006 that surprised me. I recognized it immediately for what it was--Java written in Ruby. Let me explain.
I wanted to write a test for a method Graph#write_graph:
class Graph
def write_graph(file)
formatter = Formatter.new
formatter.write_header(file)
@connections.each do |connection|
formatter.write_connection(file, connection)
end
formatter.write_footer(file)
end
# ...
endI wanted to write a test like this:
class TestGraph < Test::Unit::TestCase
context 'Graph' do
should 'write a graph with a single connection' do
graph = Graph.new
graph.add_connection(Connection.new('1', '2'))
file = ''
graph.write_graph(file)
assert_equal "1->2;\n", file
end
end
end
The problem is that the assertion fails, since the actual graph contains a lengthy header, as you can see from the implementation of the formatter class:
class Formatter
def write_header(file)
file << "digraph G\n"
file << "{\n"
file << " graph[fontsize=8,concentrate=true];\n"
file << " node[fontname=Helvetica];\n"
file << " node[fontsize=8];\n"
file << " node[height=.1];\n"
file << " node[width=.1];\n"
file << " edge[arrowsize=.25];\n"
file << " ranksep=.2;\n"
file << " nodesep=.08;\n"
end
def write_footer(file)
file << '}'
end
def write_connection(file, connection)
file << connection.source << '->'
<< connection.destination << ";\n"
end
end
I wanted to override Formatter#write_header and Formatter#writer_footer in the test so they became no-ops. How to do this? I settled on constructor injection--my tried and true way of inserting mocks and stubs in Java.
I added a parameterized initializer (the Ruby name for 'constructor') that takes a formatter object:
class Graph
def initialize(graph_formatter)
@graph_formatter = graph_formatter
end
def write_graph(file)
@graph_formatter.write_header(file)
@connections.each do |connection|
@graph_formatter.write_connection(file, connection)
end
@graph_formatter.write_footer(file)
end
# ...
end
This forced me to change the calling code to specify the formatter. This
class GraphFactory
attr_reader :graph
def initialize(parser)
@parser = parser
@graph = Graph.new
end
end
became this
class GraphFactory
attr_reader :graph
def initialize(parser)
@parser = parser
@graph = Graph.new(Formatter.new)
end
end
I then created a formatter subclass that stubbed out methods 'write_header' and 'write_footer':
class MockFormatter < Formatter def write_header(file) end def write_footer(file) end end
Lastly, I modified the test to use the mock formatter class:
class TestGraph < Test::Unit::TestCase
context 'Graph' do
should 'write a graph with a single connection' do
graph = Graph.new(MockFormatter.new)
graph.add_connection(Connection.new('1', '2'))
file = ''
graph.write_graph(file)
assert_equal "1->2;\n", file
end
end
end
That was where the code stood from October 2006 until I looked at it again recently. Constructor injection, setter injection, and using extract method to make creation methods overridable in tests are all vital maneuvers in the world of Java, but they are unneeded overhead in Ruby. The difference between Java and Ruby is that Java classes are hard (thanks to Jim Weirich for pointing this out to me). Java classes are not objects, are principally source code constructs, and are unchangeable at runtime. This is not the case in Ruby. I will show you a couple ways to skin this cat in Ruby.
First, let's change the code back to the way it was before we introduced constructor injection:
class Graph
def write_graph(file)
formatter = Formatter.new
formatter.write_header(file)
@connections.each do |connection|
formatter.write_connection(file, connection)
end
formatter.write_footer(file)
end
# ...
end
It turns out we don't need to change a line of this code to stub out Formatter#write_header and Formatter#write_footer.
Ruby classes are open. The first solution involves redefining the methods for the class in the test:
class Formatter
def write_header(file)
end
def write_footer(file)
end
end
class TestGraph < Test::Unit::TestCase
context 'Graph' do
should 'write a graph with a single connection' do
graph = Graph.new
graph.add_connection(Connection.new('1', '2'))
file = ''
graph.write_graph(file)
assert_equal "1->2;\n", file
end
end
end
The second (and my preferred) way is to use a test mock library that takes advantage of Ruby class interception. This example uses the Mocha test mock library, but all the popular test mock libraries support this functionality.
class TestGraph < Test::Unit::TestCase
context 'Graph' do
should 'write a graph with a single connection' do
Formatter.any_instance.stubs(:write_header)
Formatter.any_instance.stubs(:write_footer)
graph = Graph.new
graph.add_connection(Connection.new('1', '2'))
file = ''
graph.write_graph(file)
assert_equal "1->2;\n", file
end
end
end