Isolated subclass specs in Ruby

Unit tests should isolate their subject, but tests of subclass functionality usually test the class as a whole. One reason for this is that in Ruby there is no way to dynamically change the superclass, but more importantly, it’s because you don’t want to modify the thing you’re testing. Sometimes however, isolating a subclass from its parent makes sense.

Schemabot, our tool for applying database changes in a replication friendly way, is based on the Sequel gem and its migration DSL. We added a preview function by subclassing the MySQL adapter and having it ignore any SQL that would change the database.

A simplified version of our implementation (allowing just SELECT and DESCRIBE queries) looks like this:

module Sequel
  module MySQLPreview
    class Database < Sequel::MySQL::Database

      def execute(sql, opts={})
        if sql.match(/^SELECT/) || sql.match(/^DESCRIBE/)
          puts "RUNNING: #{sql}"
          super(sql, opts)
        else
          puts "PREVIEW: #{sql}"
        end
      end

    end
  end
end

Satisfied with the assumption that all queries go through #execute and the fact that Sequel itself is already well tested, we wanted to add a set of specs that would simply validate that our #execute method passed queries on to the superclass or ignored them as appropriate.

Set an expectation on #execute or on #super?

Our #execute calls the #execute in the superclass, and that action is what we want to set an expection on. RSpec lets us set message expectations on a class (ClassName.should_receive), on an instance (ClassName.new.should_receive), or all instances (ClassName.any_instance.should_receive), but the #execute we care about is an instance method on the superclass. It’s not a class method, and it’s not a method on any regular instance.

Instead of checking calls to the superclass’s #execute, it would be nice to set expectation on the call to super. Although Ruby language features are often implemented as methods (require, method_missing, etc.) super is actually a keyword. It is possible to create a method named super, but mocking such a method would be no help, as it would remain completely separate from the super keyword, and require a different, more explicit syntax to invoke.

RSpec’s David Chelimsky has commented on this issue:

Since we don’t get into the class hierarchy in rspec’s mock/stub framework, there is no facility for managing this.

So, it’s up to us to intercept the call if we want to verify that the subclass is behaving correctly.

Sneak a module into the inheritance chain

Somehow we need to get some in between the subclass and its superclass. Including a module in a class will not modify its superclass (which can’t be changed), but the module will be inserted into the class’s list of ancestors, and be able to intercept super before the actual superclass is reached.

To demonstrate this, let’s start with a simple parent class and subclass, both implementing #execute methods:

class ParentClass
  def execute
    puts "ParentClass#execute"
  end
end

class SubClass < ParentClass
  def execute
    puts "SubClass#execute"
    super
  end
end

The output of SubClass.new.execute will be:

SubClass#execute
ParentClass#execute

Including a module changes the ancestors list:

module SneakyModule
  def execute
    puts "SneakyModule#execute"
    super
  end
end

SubClass.ancestors # => [SubClass, ParentClass, Object, Kernel, BasicObject]
SubClass.instance_eval { include SneakyModule }
SubClass.ancestors # => [SubClass, SneakyModule, ParentClass, Object, Kernel, BasicObject]

And SubClass.new.execute shows the sequence of execution to be:

SubClass#execute
SneakyModule#execute
ParentClass#execute

This technique allows us to programatically verify the behaviour of the subclass’s #execute method by having the sneaky module in the middle record and report on method calls it intercepts. Here’s an example implementation:

module FakeMySQLAdapter
  class << self
    attr_accessor :last_execute
  end
  def execute(*args)
    FakeMySQLAdapter.last_execute = args
  end
end

With this done, we can write an example to verify that super is being invoked appropriately:

before(:all) { subject.class.instance_eval { include FakeMySQLAdapter } }
before(:each) { FakeMySQLAdapter.last_execute = nil }

it "should pass on SELECT statements" do
  subject.execute("SELECT * FROM foo;")
  FakeMySQLAdapter.last_execute.should == ["SELECT * FROM foo;", {}]
end

You can see a full implemenation of this on GitHub.

This is a solution. It exercises the logic of our subclass’s #execute and verifies its query handling. It’s not a great solution. One problem is that this code modifies the subclass being tested (by including the module), and another is that the subclass stays modified. It’s not possible to uninclude the module. If we had other tests that relied on the subclass, running the subclass’s specs first might break others.

There is a cleaner way, as we’ll see next.

Extract a module with the method override

Rather than overriding #execute in the subclass directly, we can override it by including a module that implements #execute. This structure allows us to include the logic of interest in a dummy class to test it in isolation.

First, we refactor our preview adapter to the following:

module Sequel
  module PreviewMySQL

    module MethodOverrides
      def execute(sql, opts={})
        if sql.match(/^SELECT/) || sql.match(/^DESCRIBE/)
          puts "RUNNING: #{sql}"
          super(sql, opts)
        else
          puts "PREVIEW: #{sql}"
        end
      end
    end

    class Database < Sequel::MySQL::Database
      include MethodOverrides
    end

  end
end

Before we test the module, let’s write an assertion that Database is actually using the module:

describe Sequel::PreviewMySQL::Database do
  it "should include the override methods" do
    subject.class.ancestors.should include(Sequel::PreviewMySQL::MethodOverrides)
  end
end

After that, we can test the module. We start by creating a dummy superclass for the module to be included in. We’ll use method_missing to record all method calls passed up to the superclass by its subclasses.

By using Class.new to create anonymous subclasses that include the module being tested, we can ensure that each example in our spec runs with a fresh copy of the subject.

describe Sequel::PreviewMySQL::MethodOverrides do

  class FakeSuperClass
    attr_reader :last_super_call
    def method_missing(method, *args)
      @last_super_call = [method, args]
    end
  end

  subject do
    Class.new(FakeSuperClass).instance_eval do
      include Sequel::PreviewMySQL::MethodOverrides
    end.new
  end

  #...
end

This solution is easier to read than the one in the previous post, and it avoids the problems of modifying the subject. Although we are now testing a module rather than the actual subclass we’ll be using it in, our assertion that the subclass should include the module of override methods completes the loop and will automatically warn us if we make changes to our hookup code.

With this in place, our individual examples are clean and simple, as follows:

it "should allow SELECTs" do
  sql = "SELECT * FROM sometable"
  subject.execute(sql)
  subject.last_super_call.should == [:execute, [sql, {}]]
end

You can see a full implemenation of this on GitHub.

This is a clean solution, and one that I’d have been satisfied with if I’d come up with it first. However, the extra time on this got me thinking… it really shouldn’t be that hard to stub out irrelevant and troublemaking parts of the full class.

Test the full class and stub problematic parts

Testing the full class really shouldn’t be hard and RSpec should point out any code that gets in the way. I gave it another go.

First, I reverted to the original implementation of the subclass:

module Sequel
  module MySQLPreview
    class Database < Sequel::MySQL::Database

      def execute(sql, opts={})
        if sql.match(/^SELECT/) || sql.match(/^DESCRIBE/)
          puts "RUNNING: #{sql}"
          super(sql, opts)
        else
          puts "PREVIEW: #{sql}"
        end
      end

    end
  end
end

And started with a simple spec:

describe Sequel::MySQLPreview::Database do
  it "should allow SELECTs" do
    sql = "SELECT * FROM sometable"
    subject.should_receive(:puts).with(/^RUNNING:/)
    subject.execute(sql)
  end
end

The assertion here is on #puts rather than the superclass’s #execute method, but we’ll add more comprehensive assertions later.

That example raised an error with a rather brief backtrace:

1) Sequel::MySQLPreview::Database should allow SELECTs
   Failure/Error: super(sql, opts)
   Sequel::DatabaseConnectionError:
     Mysql::Error: Can't connect to local MySQL server through socket '/tmp/mysql.sock' (2)
   # <internal:prelude>:10:in `synchronize'
   # ./spec/whole_spec.rb:11:in `execute'
   # ./spec/whole_spec.rb:30:in `block (2 levels) in <top (required)>'

RSpec tries to show you just the parts of the full backtrace that are relevant to the code you’re testing, but you can tell it to be more verbose by running it with the --backtrace option. That got me this:

1) Sequel::MySQLPreview::Database should allow SELECTs
   Failure/Error: super(sql, opts)
   Sequel::DatabaseConnectionError:
     Mysql::Error: Can't connect to local MySQL server through socket '/tmp/mysql.sock' (2)
   # /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/sequel-3.35.0/lib/sequel/adapters/mysql.rb:110:in `real_connect'
   # /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/sequel-3.35.0/lib/sequel/adapters/mysql.rb:110:in `connect'
   # /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/sequel-3.35.0/lib/sequel/database/misc.rb:48:in `block in initialize'
   # /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/sequel-3.35.0/lib/sequel/connection_pool.rb:100:in `call'
   # /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/sequel-3.35.0/lib/sequel/connection_pool.rb:100:in `make_new'
   # /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/sequel-3.35.0/lib/sequel/connection_pool/threaded.rb:144:in `make_new'
   # /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/sequel-3.35.0/lib/sequel/connection_pool/threaded.rb:130:in `available'
   # /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/sequel-3.35.0/lib/sequel/connection_pool/threaded.rb:120:in `block in acquire'
   # /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/sequel-3.35.0/lib/sequel/connection_pool/threaded.rb:162:in `block in sync'
   # <internal:prelude>:10:in `synchronize'
   # /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/sequel-3.35.0/lib/sequel/connection_pool/threaded.rb:162:in `sync'
   # /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/sequel-3.35.0/lib/sequel/connection_pool/threaded.rb:119:in `acquire'
   # /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/sequel-3.35.0/lib/sequel/connection_pool/threaded.rb:91:in `hold'
   # /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/sequel-3.35.0/lib/sequel/database/connecting.rb:229:in `synchronize'
   # /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/sequel-3.35.0/lib/sequel/adapters/shared/mysql_prepared_statements.rb:23:in `execute'
   # ./spec/whole_spec.rb:11:in `execute'
   # ./spec/whole_spec.rb:30:in `block (2 levels) in <top (required)>'
   # /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/rspec-core-2.10.1/lib/rspec/core/example.rb:87:in `instance_eval'
   # /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/rspec-core-2.10.1/lib/rspec/core/example.rb:87:in `block in run'
   # /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/rspec-core-2.10.1/lib/rspec/core/example.rb:195:in `with_around_each_hooks'
   # /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/rspec-core-2.10.1/lib/rspec/core/example.rb:84:in `run'
   # /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/rspec-core-2.10.1/lib/rspec/core/example_group.rb:353:in `block in run_examples'
   # /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/rspec-core-2.10.1/lib/rspec/core/example_group.rb:349:in `map'
   # /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/rspec-core-2.10.1/lib/rspec/core/example_group.rb:349:in `run_examples'
   # /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/rspec-core-2.10.1/lib/rspec/core/example_group.rb:335:in `run'
   # /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/rspec-core-2.10.1/lib/rspec/core/command_line.rb:28:in `block (2 levels) in run'
   # /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/rspec-core-2.10.1/lib/rspec/core/command_line.rb:28:in `map'
   # /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/rspec-core-2.10.1/lib/rspec/core/command_line.rb:28:in `block in run'
   # /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/rspec-core-2.10.1/lib/rspec/core/reporter.rb:34:in `report'
   # /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/rspec-core-2.10.1/lib/rspec/core/command_line.rb:25:in `run'
   # /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/rspec-core-2.10.1/lib/rspec/core/runner.rb:69:in `run'
   # /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/rspec-core-2.10.1/lib/rspec/core/runner.rb:10:in `block in autorun'

I could see that a #synchronize method was responsible for acquiring a connection to the database. This was the method I needed to stub to to isolate my test from an actual database instance.

Following through the execution path showed that the superclass’s #execute would eventually call #_execute to finish its work. Setting an expectation on this method would allow me to test whether or not the SQL query was in fact being passed up to the superclass for execution.

Incorporating these two points brought me to the final form of my specs:

describe Sequel::MySQLPreview::Database do

  let(:connection) { stub("connection") }
  before { subject.stub(:synchronize).and_yield(connection) }

  it "should allow SELECTs" do
    sql = "SELECT * FROM sometable"
    subject.should_receive(:puts).with(/^RUNNING:/)
    subject.should_receive(:_execute).with(connection, sql, anything)
    subject.execute(sql)
  end

end

The two assertions here cover both the direct actions of the subclass, as well as its correct interaction with the superclass.

So, it turns out that (with --backtrace) this was quite straightforward. You can see the full code on GitHub

In conclusion

If I’d gotten this full class spec working first, I probably would have stopped there. However, even though this tests the class as a whole - the normal way - it is not necessarily the best approach.

The downside here is that it requires stubbing the class under test (even though it’s a part of the class we’re not specifically interested in testing). The other thing is that it couples itself more tightly to the internal implementation of the superclass. It requires the #execute, #synchronize and #_execute methods to behave in certain ways. It also places on the reader the burden of understanding the significance of the #_execute expectation.

The assumptions made by the isolated module spec are simpler: that all SQL goes via the #execute method and that it can be stopped there without any problems. It’s a smaller, cleaner set of assumptions, and there’s no stubbing of the subject. I’d say that extracting and testing a module in isolation is the best approach here.