2008-03-06

Seams in Ruby

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
  # ...
end

I 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